@lovalingo/lovalingo 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/components/AixsterProvider.d.ts +0 -17
- package/dist/components/AixsterProvider.js +94 -194
- package/dist/components/NavigationOverlay.js +5 -27
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- package/dist/types.d.ts +1 -16
- package/dist/utils/api.d.ts +15 -2
- package/dist/utils/api.js +28 -63
- package/dist/utils/translator.d.ts +1 -12
- package/dist/utils/translator.js +1 -141
- package/package.json +2 -2
- package/dist/components/AutoTranslate.d.ts +0 -10
- package/dist/components/AutoTranslate.js +0 -69
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ npm install @lovalingo/lovalingo react-router-dom
|
|
|
12
12
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
|
+
Lovalingo **erzeugt Übersetzungen nicht im Browser**. Die Runtime lädt fertige Artefakte (JSON‑Bundle + DOM‑Rules) vom Backend; die server‑seitige Pipeline rendert Seiten, extrahiert Marker/Strings, übersetzt deterministisch und erzeugt bei Bedarf DOM‑Fixes.
|
|
16
|
+
|
|
15
17
|
### Option 1: Path Mode (Recommended - Automatic Language URLs)
|
|
16
18
|
|
|
17
19
|
**One-line setup** that automatically handles language routing like `/en/pricing`, `/fr/pricing`, `/de/pricing`:
|
|
@@ -165,7 +167,10 @@ Then you may omit `publicAnonKey` and Lovalingo will read it from the meta tag o
|
|
|
165
167
|
- Automatic language routing
|
|
166
168
|
- SEO-optimized URLs
|
|
167
169
|
- Non-localized by default: `/auth`, `/login`, `/signup` (no `/:lang/` prefix)
|
|
168
|
-
-
|
|
170
|
+
- Examples:
|
|
171
|
+
- `/en/` → English home
|
|
172
|
+
- `/de/pricing` → German pricing
|
|
173
|
+
- `/fr/about` → French about
|
|
169
174
|
|
|
170
175
|
## Path Mode Helpers (v0.0.x+)
|
|
171
176
|
|
|
@@ -6,22 +6,5 @@ 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
|
-
}
|
|
26
9
|
export declare const LovalingoProvider: React.FC<LovalingoProviderProps>;
|
|
27
10
|
export {};
|
|
@@ -57,17 +57,12 @@ navigateRef, // For path mode routing
|
|
|
57
57
|
const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
|
|
58
58
|
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
59
59
|
const observerRef = useRef(null);
|
|
60
|
-
const missReportIntervalRef = useRef(null);
|
|
61
60
|
const retryTimeoutRef = useRef(null);
|
|
62
61
|
const isNavigatingRef = useRef(false);
|
|
63
62
|
const isInternalNavigationRef = useRef(false);
|
|
64
63
|
const translationCacheRef = useRef(new Map());
|
|
65
64
|
const exclusionsCacheRef = useRef(null);
|
|
66
65
|
const domRulesCacheRef = useRef(new Map());
|
|
67
|
-
// NEW: Hash-based translation cache for React Context system
|
|
68
|
-
const [hashTranslations, setHashTranslations] = useState(new Map());
|
|
69
|
-
const contextMissQueueRef = useRef(new Set());
|
|
70
|
-
const contextMissFlushTimeoutRef = useRef(null);
|
|
71
66
|
const config = {
|
|
72
67
|
apiKey: resolvedApiKey,
|
|
73
68
|
publicAnonKey: resolvedApiKey,
|
|
@@ -104,77 +99,114 @@ navigateRef, // For path mode routing
|
|
|
104
99
|
return false;
|
|
105
100
|
return seo !== false;
|
|
106
101
|
}, [entitlements, seo]);
|
|
107
|
-
const updateSeoLinks = useCallback((activeLocale, hreflangEnabled) => {
|
|
102
|
+
const updateSeoLinks = useCallback(async (activeLocale, hreflangEnabled) => {
|
|
108
103
|
try {
|
|
109
104
|
const head = document.head;
|
|
110
105
|
if (!head)
|
|
111
106
|
return;
|
|
112
|
-
// Remove old links inserted by Lovalingo
|
|
113
107
|
head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
|
|
114
108
|
if (!isSeoActive())
|
|
115
109
|
return;
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
110
|
+
const bundle = await apiRef.current.fetchSeoBundle(activeLocale);
|
|
111
|
+
const seo = (bundle?.seo && typeof bundle.seo === "object" ? bundle.seo : {});
|
|
112
|
+
const alternates = (bundle?.alternates && typeof bundle.alternates === "object" ? bundle.alternates : {});
|
|
113
|
+
const setOrCreateMeta = (attrs, content) => {
|
|
114
|
+
const key = attrs.name ? `meta[name="${attrs.name}"]` : attrs.property ? `meta[property="${attrs.property}"]` : "";
|
|
115
|
+
const selector = key || "meta";
|
|
116
|
+
const existing = selector ? head.querySelector(selector) : null;
|
|
117
|
+
const el = existing || document.createElement("meta");
|
|
118
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
119
|
+
el.setAttribute(k, v);
|
|
124
120
|
}
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const next = new URL(window.location.origin + basePathname + url.search + url.hash);
|
|
135
|
-
if (routing === "path") {
|
|
136
|
-
const path = basePathname === "/" ? "" : basePathname;
|
|
137
|
-
next.pathname = localeForUrl === defaultLocale ? `${path || "/"}` : `/${localeForUrl}${path}`;
|
|
138
|
-
return next.toString();
|
|
139
|
-
}
|
|
140
|
-
// Query mode: keep pathname, set/remove locale param
|
|
141
|
-
next.searchParams.delete("t");
|
|
142
|
-
next.searchParams.delete("locale");
|
|
143
|
-
if (localeForUrl !== defaultLocale) {
|
|
144
|
-
next.searchParams.set("t", localeForUrl);
|
|
121
|
+
el.setAttribute("content", content);
|
|
122
|
+
if (!existing)
|
|
123
|
+
head.appendChild(el);
|
|
124
|
+
};
|
|
125
|
+
const setOrCreateTitle = (value) => {
|
|
126
|
+
const existing = head.querySelector("title");
|
|
127
|
+
if (existing) {
|
|
128
|
+
existing.textContent = value;
|
|
129
|
+
return;
|
|
145
130
|
}
|
|
146
|
-
|
|
131
|
+
const el = document.createElement("title");
|
|
132
|
+
el.textContent = value;
|
|
133
|
+
head.appendChild(el);
|
|
147
134
|
};
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
135
|
+
const getString = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
|
|
136
|
+
const title = getString(seo.title);
|
|
137
|
+
if (title)
|
|
138
|
+
setOrCreateTitle(title);
|
|
139
|
+
const description = getString(seo.description);
|
|
140
|
+
if (description)
|
|
141
|
+
setOrCreateMeta({ name: "description" }, description);
|
|
142
|
+
const robots = getString(seo.robots);
|
|
143
|
+
if (robots)
|
|
144
|
+
setOrCreateMeta({ name: "robots" }, robots);
|
|
145
|
+
const ogTitle = getString(seo.og_title);
|
|
146
|
+
if (ogTitle)
|
|
147
|
+
setOrCreateMeta({ property: "og:title" }, ogTitle);
|
|
148
|
+
const ogDescription = getString(seo.og_description);
|
|
149
|
+
if (ogDescription)
|
|
150
|
+
setOrCreateMeta({ property: "og:description" }, ogDescription);
|
|
151
|
+
const ogImage = getString(seo.og_image);
|
|
152
|
+
if (ogImage)
|
|
153
|
+
setOrCreateMeta({ property: "og:image" }, ogImage);
|
|
154
|
+
const ogImageAlt = getString(seo.og_image_alt);
|
|
155
|
+
if (ogImageAlt)
|
|
156
|
+
setOrCreateMeta({ property: "og:image:alt" }, ogImageAlt);
|
|
157
|
+
const twitterCard = getString(seo.twitter_card);
|
|
158
|
+
if (twitterCard)
|
|
159
|
+
setOrCreateMeta({ name: "twitter:card" }, twitterCard);
|
|
160
|
+
const twitterTitle = getString(seo.twitter_title);
|
|
161
|
+
if (twitterTitle)
|
|
162
|
+
setOrCreateMeta({ name: "twitter:title" }, twitterTitle);
|
|
163
|
+
const twitterDescription = getString(seo.twitter_description);
|
|
164
|
+
if (twitterDescription)
|
|
165
|
+
setOrCreateMeta({ name: "twitter:description" }, twitterDescription);
|
|
166
|
+
const twitterImage = getString(seo.twitter_image);
|
|
167
|
+
if (twitterImage)
|
|
168
|
+
setOrCreateMeta({ name: "twitter:image" }, twitterImage);
|
|
169
|
+
const twitterImageAlt = getString(seo.twitter_image_alt);
|
|
170
|
+
if (twitterImageAlt)
|
|
171
|
+
setOrCreateMeta({ name: "twitter:image:alt" }, twitterImageAlt);
|
|
172
|
+
const canonicalHref = typeof seo.canonical_url === "string" && seo.canonical_url.trim()
|
|
173
|
+
? seo.canonical_url.trim()
|
|
174
|
+
: typeof alternates.canonical === "string" && alternates.canonical.trim()
|
|
175
|
+
? alternates.canonical.trim()
|
|
176
|
+
: "";
|
|
177
|
+
if (canonicalHref) {
|
|
178
|
+
const canonical = document.createElement("link");
|
|
179
|
+
canonical.rel = "canonical";
|
|
180
|
+
canonical.href = canonicalHref;
|
|
181
|
+
canonical.setAttribute("data-Lovalingo", "canonical");
|
|
182
|
+
head.appendChild(canonical);
|
|
183
|
+
}
|
|
155
184
|
if (!hreflangEnabled)
|
|
156
185
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
186
|
+
const languages = alternates.languages && typeof alternates.languages === "object" ? alternates.languages : {};
|
|
187
|
+
for (const [lang, href] of Object.entries(languages)) {
|
|
188
|
+
if (!href)
|
|
189
|
+
continue;
|
|
159
190
|
const link = document.createElement("link");
|
|
160
191
|
link.rel = "alternate";
|
|
161
|
-
link.hreflang =
|
|
162
|
-
link.href =
|
|
192
|
+
link.hreflang = lang;
|
|
193
|
+
link.href = href;
|
|
163
194
|
link.setAttribute("data-Lovalingo", "hreflang");
|
|
164
195
|
head.appendChild(link);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
196
|
+
}
|
|
197
|
+
if (alternates.xDefault) {
|
|
198
|
+
const xDefault = document.createElement("link");
|
|
199
|
+
xDefault.rel = "alternate";
|
|
200
|
+
xDefault.hreflang = "x-default";
|
|
201
|
+
xDefault.href = alternates.xDefault;
|
|
202
|
+
xDefault.setAttribute("data-Lovalingo", "hreflang");
|
|
203
|
+
head.appendChild(xDefault);
|
|
204
|
+
}
|
|
173
205
|
}
|
|
174
206
|
catch (e) {
|
|
175
207
|
console.warn("[Lovalingo] updateSeoLinks() failed:", e);
|
|
176
208
|
}
|
|
177
|
-
}, [
|
|
209
|
+
}, [isSeoActive]);
|
|
178
210
|
// Detect locale from URL or localStorage
|
|
179
211
|
const detectLocale = useCallback(() => {
|
|
180
212
|
// 1. Check URL first based on routing mode
|
|
@@ -222,7 +254,7 @@ navigateRef, // For path mode routing
|
|
|
222
254
|
// Keep <html lang> + canonical/hreflang in sync with routing + entitlements
|
|
223
255
|
useEffect(() => {
|
|
224
256
|
setDocumentLocale(locale);
|
|
225
|
-
updateSeoLinks(locale, Boolean(entitlements?.hreflangEnabled));
|
|
257
|
+
void updateSeoLinks(locale, Boolean(entitlements?.hreflangEnabled));
|
|
226
258
|
}, [locale, entitlements, setDocumentLocale, updateSeoLinks]);
|
|
227
259
|
// Load translations and exclusions
|
|
228
260
|
const loadData = useCallback(async (targetLocale, previousLocale, showOverlay = false) => {
|
|
@@ -236,10 +268,8 @@ navigateRef, // For path mode routing
|
|
|
236
268
|
if (targetLocale === defaultLocale) {
|
|
237
269
|
if (showOverlay)
|
|
238
270
|
setIsNavigationLoading(false);
|
|
239
|
-
setHashTranslations(new Map());
|
|
240
271
|
translatorRef.current.setTranslations([]);
|
|
241
272
|
translatorRef.current.restoreDOM(); // Safe to restore when going back to source language
|
|
242
|
-
translatorRef.current.restoreHead();
|
|
243
273
|
isNavigatingRef.current = false;
|
|
244
274
|
return;
|
|
245
275
|
}
|
|
@@ -253,15 +283,11 @@ navigateRef, // For path mode routing
|
|
|
253
283
|
if (cachedEntry && cachedExclusions) {
|
|
254
284
|
// CACHE HIT - Use cached data immediately (FAST!)
|
|
255
285
|
console.log(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
|
|
256
|
-
setHashTranslations(new Map(Object.entries(cachedEntry.hashMap || {})));
|
|
257
286
|
translatorRef.current.setTranslations(cachedEntry.translations);
|
|
258
287
|
translatorRef.current.setExclusions(cachedExclusions);
|
|
259
288
|
if (mode === 'dom') {
|
|
260
289
|
translatorRef.current.translateDOM();
|
|
261
290
|
}
|
|
262
|
-
if (isSeoActive()) {
|
|
263
|
-
translatorRef.current.translateHead();
|
|
264
|
-
}
|
|
265
291
|
if (autoApplyRules) {
|
|
266
292
|
if (Array.isArray(cachedDomRules)) {
|
|
267
293
|
applyDomRules(cachedDomRules);
|
|
@@ -282,25 +308,10 @@ navigateRef, // For path mode routing
|
|
|
282
308
|
if (mode === 'dom') {
|
|
283
309
|
translatorRef.current.translateDOM();
|
|
284
310
|
}
|
|
285
|
-
if (isSeoActive()) {
|
|
286
|
-
translatorRef.current.translateHead();
|
|
287
|
-
}
|
|
288
311
|
if (autoApplyRules) {
|
|
289
312
|
const rules = domRulesCacheRef.current.get(cacheKey) || cachedDomRules || [];
|
|
290
313
|
applyDomRules(rules);
|
|
291
314
|
}
|
|
292
|
-
if (mode === "dom") {
|
|
293
|
-
// Immediately report any misses found
|
|
294
|
-
const missed = translatorRef.current.getMissedStrings();
|
|
295
|
-
if (missed.length > 0) {
|
|
296
|
-
console.log(`[Lovalingo] 📤 Reporting ${missed.length} misses immediately`);
|
|
297
|
-
apiRef.current.reportMisses(missed, defaultLocale, targetLocale);
|
|
298
|
-
translatorRef.current.clearMissedStrings();
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
console.log(`[Lovalingo] ✅ No misses detected`);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
315
|
}, 500);
|
|
305
316
|
if (showOverlay) {
|
|
306
317
|
setTimeout(() => setIsNavigationLoading(false), 50);
|
|
@@ -331,10 +342,8 @@ navigateRef, // For path mode routing
|
|
|
331
342
|
target_locale: targetLocale,
|
|
332
343
|
}))
|
|
333
344
|
: [];
|
|
334
|
-
const hashMap = bundle?.hashMap || {};
|
|
335
|
-
setHashTranslations(new Map(Object.entries(hashMap)));
|
|
336
345
|
// Store in cache for next time
|
|
337
|
-
translationCacheRef.current.set(cacheKey, { translations
|
|
346
|
+
translationCacheRef.current.set(cacheKey, { translations });
|
|
338
347
|
exclusionsCacheRef.current = exclusions;
|
|
339
348
|
if (autoApplyRules) {
|
|
340
349
|
domRulesCacheRef.current.set(cacheKey, domRules);
|
|
@@ -344,9 +353,6 @@ navigateRef, // For path mode routing
|
|
|
344
353
|
if (mode === 'dom') {
|
|
345
354
|
translatorRef.current.translateDOM();
|
|
346
355
|
}
|
|
347
|
-
if (isSeoActive()) {
|
|
348
|
-
translatorRef.current.translateHead();
|
|
349
|
-
}
|
|
350
356
|
if (autoApplyRules) {
|
|
351
357
|
applyDomRules(domRules);
|
|
352
358
|
}
|
|
@@ -360,25 +366,10 @@ navigateRef, // For path mode routing
|
|
|
360
366
|
if (mode === "dom") {
|
|
361
367
|
translatorRef.current.translateDOM();
|
|
362
368
|
}
|
|
363
|
-
if (isSeoActive()) {
|
|
364
|
-
translatorRef.current.translateHead();
|
|
365
|
-
}
|
|
366
369
|
if (autoApplyRules) {
|
|
367
370
|
const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
|
|
368
371
|
applyDomRules(rules);
|
|
369
372
|
}
|
|
370
|
-
if (mode === "dom") {
|
|
371
|
-
// Immediately report any misses found
|
|
372
|
-
const missed = translatorRef.current.getMissedStrings();
|
|
373
|
-
if (missed.length > 0) {
|
|
374
|
-
console.log(`[Lovalingo] 📤 Reporting ${missed.length} misses immediately`);
|
|
375
|
-
apiRef.current.reportMisses(missed, defaultLocale, targetLocale);
|
|
376
|
-
translatorRef.current.clearMissedStrings();
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
console.log(`[Lovalingo] ✅ No misses detected`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
373
|
}, 500);
|
|
383
374
|
if (showOverlay) {
|
|
384
375
|
setTimeout(() => setIsNavigationLoading(false), 100);
|
|
@@ -450,66 +441,7 @@ navigateRef, // For path mode routing
|
|
|
450
441
|
isInternalNavigationRef.current = false;
|
|
451
442
|
});
|
|
452
443
|
}, [allLocales, locale, routing, loadData, defaultLocale, navigateRef]);
|
|
453
|
-
//
|
|
454
|
-
const getTranslation = useCallback((hash, fallback) => {
|
|
455
|
-
return hashTranslations.get(hash) || null;
|
|
456
|
-
}, [hashTranslations]);
|
|
457
|
-
const queueMiss = useCallback((text) => {
|
|
458
|
-
const cleaned = (text || "").toString().trim();
|
|
459
|
-
if (!cleaned || cleaned.length < 2)
|
|
460
|
-
return;
|
|
461
|
-
if (locale === defaultLocale)
|
|
462
|
-
return;
|
|
463
|
-
contextMissQueueRef.current.add(cleaned);
|
|
464
|
-
if (contextMissFlushTimeoutRef.current)
|
|
465
|
-
return;
|
|
466
|
-
contextMissFlushTimeoutRef.current = setTimeout(() => {
|
|
467
|
-
contextMissFlushTimeoutRef.current = null;
|
|
468
|
-
const batch = Array.from(contextMissQueueRef.current);
|
|
469
|
-
contextMissQueueRef.current.clear();
|
|
470
|
-
if (batch.length === 0)
|
|
471
|
-
return;
|
|
472
|
-
const misses = batch.map((value) => ({
|
|
473
|
-
text: value,
|
|
474
|
-
raw: value,
|
|
475
|
-
placeholderMap: {},
|
|
476
|
-
semanticContext: "react",
|
|
477
|
-
}));
|
|
478
|
-
void apiRef.current.reportMisses(misses, defaultLocale, locale);
|
|
479
|
-
}, 800);
|
|
480
|
-
}, [defaultLocale, locale]);
|
|
481
|
-
// Selenium bridge: allows automation to force-flush misses instead of relying on the 5s interval.
|
|
482
|
-
useEffect(() => {
|
|
483
|
-
if (typeof window === "undefined")
|
|
484
|
-
return;
|
|
485
|
-
const bridge = {
|
|
486
|
-
getStatus: () => ({
|
|
487
|
-
mode,
|
|
488
|
-
locale,
|
|
489
|
-
defaultLocale,
|
|
490
|
-
path: window.location.pathname + window.location.search,
|
|
491
|
-
missed: translatorRef.current.getMissedStrings().length,
|
|
492
|
-
}),
|
|
493
|
-
flushMisses: async () => {
|
|
494
|
-
// Only flush in DOM mode + non-default locale, matching the normal periodic reporter.
|
|
495
|
-
if (mode !== "dom" || locale === defaultLocale) {
|
|
496
|
-
return { reported: 0, locale, path: window.location.pathname + window.location.search };
|
|
497
|
-
}
|
|
498
|
-
const missed = translatorRef.current.getMissedStrings();
|
|
499
|
-
if (missed.length > 0) {
|
|
500
|
-
await apiRef.current.reportMisses(missed, defaultLocale, locale);
|
|
501
|
-
translatorRef.current.clearMissedStrings();
|
|
502
|
-
}
|
|
503
|
-
return { reported: missed.length, locale, path: window.location.pathname + window.location.search };
|
|
504
|
-
},
|
|
505
|
-
};
|
|
506
|
-
globalThis.__LOVALINGO_SELENIUM__ = bridge;
|
|
507
|
-
return () => {
|
|
508
|
-
if (globalThis.__LOVALINGO_SELENIUM__ === bridge) {
|
|
509
|
-
delete globalThis.__LOVALINGO_SELENIUM__;
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
}, [defaultLocale, locale, mode]);
|
|
444
|
+
// No string-level "misses" from the client: the pipeline (Browser Rendering) is source-of-truth for string discovery.
|
|
513
445
|
// Initialize
|
|
514
446
|
useEffect(() => {
|
|
515
447
|
const initialLocale = detectLocale();
|
|
@@ -521,7 +453,7 @@ navigateRef, // For path mode routing
|
|
|
521
453
|
if (next)
|
|
522
454
|
setEntitlements(next);
|
|
523
455
|
});
|
|
524
|
-
// Always prefetch artifacts for the initial locale
|
|
456
|
+
// Always prefetch artifacts for the initial locale (pipeline-produced translations + rules).
|
|
525
457
|
loadData(initialLocale);
|
|
526
458
|
// Set up keyboard shortcut for edit mode
|
|
527
459
|
const handleKeyPress = (e) => {
|
|
@@ -566,7 +498,6 @@ navigateRef, // For path mode routing
|
|
|
566
498
|
}, [sitemap, resolvedApiKey, apiBase, isSeoActive]);
|
|
567
499
|
// Watch for route changes (browser back/forward + SPA navigation)
|
|
568
500
|
useEffect(() => {
|
|
569
|
-
let navigationTimeout = null;
|
|
570
501
|
const handlePopState = () => {
|
|
571
502
|
if (isInternalNavigationRef.current)
|
|
572
503
|
return;
|
|
@@ -581,16 +512,11 @@ navigateRef, // For path mode routing
|
|
|
581
512
|
isNavigatingRef.current = true;
|
|
582
513
|
if (newLocale !== locale) {
|
|
583
514
|
setLocaleState(newLocale);
|
|
584
|
-
// Clear any pending navigation
|
|
585
|
-
if (navigationTimeout)
|
|
586
|
-
clearTimeout(navigationTimeout);
|
|
587
515
|
// Load translations immediately (no delay needed with overlay)
|
|
588
516
|
loadData(newLocale, previousLocale, true);
|
|
589
517
|
}
|
|
590
518
|
else if (locale !== defaultLocale) {
|
|
591
519
|
// Same locale but NEW PATH - fetch translations for this path
|
|
592
|
-
if (navigationTimeout)
|
|
593
|
-
clearTimeout(navigationTimeout);
|
|
594
520
|
// Load translations immediately (no delay needed with overlay)
|
|
595
521
|
loadData(locale, previousLocale, true);
|
|
596
522
|
}
|
|
@@ -614,9 +540,6 @@ navigateRef, // For path mode routing
|
|
|
614
540
|
}
|
|
615
541
|
// SET NAVIGATION FLAG (MutationObserver callback will ignore while navigating)
|
|
616
542
|
isNavigatingRef.current = true;
|
|
617
|
-
// Clear any pending navigation translation
|
|
618
|
-
if (navigationTimeout)
|
|
619
|
-
clearTimeout(navigationTimeout);
|
|
620
543
|
if (newLocale !== locale) {
|
|
621
544
|
setLocaleState(newLocale);
|
|
622
545
|
// Load translations immediately (no delay needed with overlay)
|
|
@@ -645,8 +568,6 @@ navigateRef, // For path mode routing
|
|
|
645
568
|
window.removeEventListener('popstate', handlePopState);
|
|
646
569
|
history.pushState = originalPushState;
|
|
647
570
|
history.replaceState = originalReplaceState;
|
|
648
|
-
if (navigationTimeout)
|
|
649
|
-
clearTimeout(navigationTimeout);
|
|
650
571
|
};
|
|
651
572
|
}, [locale, detectLocale, loadData, defaultLocale]);
|
|
652
573
|
// PATH mode: auto-prefix internal links that are missing a locale segment.
|
|
@@ -784,7 +705,7 @@ navigateRef, // For path mode routing
|
|
|
784
705
|
if (!fixed)
|
|
785
706
|
return;
|
|
786
707
|
event.preventDefault();
|
|
787
|
-
event.stopImmediatePropagation
|
|
708
|
+
event.stopImmediatePropagation();
|
|
788
709
|
event.stopPropagation();
|
|
789
710
|
const navigate = navigateRef?.current;
|
|
790
711
|
if (navigate) {
|
|
@@ -838,26 +759,7 @@ navigateRef, // For path mode routing
|
|
|
838
759
|
observerRef.current = null;
|
|
839
760
|
};
|
|
840
761
|
}, [locale, defaultLocale, mode]);
|
|
841
|
-
//
|
|
842
|
-
useEffect(() => {
|
|
843
|
-
if (mode !== 'dom')
|
|
844
|
-
return; // Skip for context mode
|
|
845
|
-
if (locale === defaultLocale)
|
|
846
|
-
return;
|
|
847
|
-
const interval = setInterval(() => {
|
|
848
|
-
const missed = translatorRef.current.getMissedStrings();
|
|
849
|
-
if (missed.length > 0) {
|
|
850
|
-
apiRef.current.reportMisses(missed, defaultLocale, locale);
|
|
851
|
-
translatorRef.current.clearMissedStrings();
|
|
852
|
-
}
|
|
853
|
-
}, 5000);
|
|
854
|
-
missReportIntervalRef.current = interval;
|
|
855
|
-
return () => {
|
|
856
|
-
if (missReportIntervalRef.current) {
|
|
857
|
-
clearInterval(missReportIntervalRef.current);
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
|
-
}, [locale, defaultLocale, mode]);
|
|
762
|
+
// No periodic string-miss reporting. Page discovery is tracked via pageview only.
|
|
861
763
|
const translateElement = useCallback((element) => {
|
|
862
764
|
translatorRef.current.translateElement(element);
|
|
863
765
|
}, []);
|
|
@@ -883,8 +785,6 @@ navigateRef, // For path mode routing
|
|
|
883
785
|
editMode,
|
|
884
786
|
toggleEditMode,
|
|
885
787
|
excludeElement,
|
|
886
|
-
getTranslation,
|
|
887
|
-
queueMiss,
|
|
888
788
|
};
|
|
889
789
|
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
890
790
|
children,
|
|
@@ -8,37 +8,15 @@ export const NavigationOverlay = ({ isVisible }) => {
|
|
|
8
8
|
left: 0,
|
|
9
9
|
right: 0,
|
|
10
10
|
bottom: 0,
|
|
11
|
-
backdropFilter: 'blur(
|
|
12
|
-
WebkitBackdropFilter: 'blur(
|
|
11
|
+
backdropFilter: 'blur(3px)',
|
|
12
|
+
WebkitBackdropFilter: 'blur(3px)',
|
|
13
|
+
// Keep a tiny alpha background to ensure the element paints (some browsers won't apply backdrop-filter otherwise).
|
|
14
|
+
backgroundColor: 'rgba(255, 255, 255, 0.01)',
|
|
13
15
|
zIndex: 9999,
|
|
14
|
-
display: 'flex',
|
|
15
|
-
alignItems: 'center',
|
|
16
|
-
justifyContent: 'center',
|
|
17
16
|
animation: 'fadeIn 0.1s ease-in-out',
|
|
17
|
+
cursor: 'progress',
|
|
18
18
|
}, "data-Lovalingo-exclude": "true" },
|
|
19
|
-
React.createElement("div", { style: {
|
|
20
|
-
width: '64px',
|
|
21
|
-
height: '64px',
|
|
22
|
-
borderRadius: '50%',
|
|
23
|
-
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
24
|
-
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
25
|
-
display: 'flex',
|
|
26
|
-
alignItems: 'center',
|
|
27
|
-
justifyContent: 'center',
|
|
28
|
-
} },
|
|
29
|
-
React.createElement("div", { style: {
|
|
30
|
-
width: '32px',
|
|
31
|
-
height: '32px',
|
|
32
|
-
border: '3px solid #f0f0f0',
|
|
33
|
-
borderTop: '3px solid #3b82f6',
|
|
34
|
-
borderRadius: '50%',
|
|
35
|
-
animation: 'spin 0.6s linear infinite',
|
|
36
|
-
} })),
|
|
37
19
|
React.createElement("style", null, `
|
|
38
|
-
@keyframes spin {
|
|
39
|
-
0% { transform: rotate(0deg); }
|
|
40
|
-
100% { transform: rotate(360deg); }
|
|
41
|
-
}
|
|
42
20
|
@keyframes fadeIn {
|
|
43
21
|
from { opacity: 0; }
|
|
44
22
|
to { opacity: 1; }
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { LovalingoProvider } from './components/LovalingoProvider';
|
|
16
16
|
export { LanguageSwitcher } from './components/LanguageSwitcher';
|
|
17
|
-
export { AutoTranslate } from './components/AutoTranslate';
|
|
18
17
|
export { LangRouter } from './components/LangRouter';
|
|
19
18
|
export { LangLink } from './components/LangLink';
|
|
20
19
|
export { useLang } from './hooks/useLang';
|
|
@@ -23,4 +22,4 @@ export { useLovalingo } from './hooks/useLovalingo';
|
|
|
23
22
|
export { useLovalingoTranslate } from './hooks/useLovalingoTranslate';
|
|
24
23
|
export { useLovalingoEdit } from './hooks/useLovalingoEdit';
|
|
25
24
|
export { VERSION } from './version';
|
|
26
|
-
export type { LovalingoConfig, LovalingoContextValue, Translation, Exclusion,
|
|
25
|
+
export type { LovalingoConfig, LovalingoContextValue, Translation, Exclusion, DomRule, DomRuleType } from './types';
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
// Core Provider & Components
|
|
16
16
|
export { LovalingoProvider } from './components/LovalingoProvider';
|
|
17
17
|
export { LanguageSwitcher } from './components/LanguageSwitcher';
|
|
18
|
-
export { AutoTranslate } from './components/AutoTranslate';
|
|
19
18
|
// Path Mode Components & Hooks (NEW in v4.2)
|
|
20
19
|
export { LangRouter } from './components/LangRouter';
|
|
21
20
|
export { LangLink } from './components/LangLink';
|
package/dist/types.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface LovalingoConfig {
|
|
|
18
18
|
editMode?: boolean;
|
|
19
19
|
editKey?: string;
|
|
20
20
|
pathNormalization?: PathNormalizationConfig;
|
|
21
|
-
mode?: 'dom'
|
|
21
|
+
mode?: 'dom';
|
|
22
22
|
autoApplyRules?: boolean;
|
|
23
23
|
}
|
|
24
24
|
export interface LovalingoContextValue {
|
|
@@ -32,8 +32,6 @@ export interface LovalingoContextValue {
|
|
|
32
32
|
editMode: boolean;
|
|
33
33
|
toggleEditMode: () => void;
|
|
34
34
|
excludeElement: (selector: string) => Promise<void>;
|
|
35
|
-
getTranslation: (hash: string, fallback: string) => string | null;
|
|
36
|
-
queueMiss: (text: string) => void;
|
|
37
35
|
}
|
|
38
36
|
export interface Translation {
|
|
39
37
|
source_text: string;
|
|
@@ -42,13 +40,6 @@ export interface Translation {
|
|
|
42
40
|
target_locale: string;
|
|
43
41
|
content_hash?: string;
|
|
44
42
|
}
|
|
45
|
-
export interface HashTranslation {
|
|
46
|
-
content_hash: string;
|
|
47
|
-
source_text: string;
|
|
48
|
-
target_text: string;
|
|
49
|
-
source_locale: string;
|
|
50
|
-
target_locale: string;
|
|
51
|
-
}
|
|
52
43
|
export interface Exclusion {
|
|
53
44
|
selector: string;
|
|
54
45
|
type: 'css' | 'xpath';
|
|
@@ -84,9 +75,3 @@ export interface ExtractedContent {
|
|
|
84
75
|
placeholderMap: Record<string, PlaceholderData>;
|
|
85
76
|
semanticContext: string;
|
|
86
77
|
}
|
|
87
|
-
export interface MissedTranslation {
|
|
88
|
-
text: string;
|
|
89
|
-
raw: string;
|
|
90
|
-
placeholderMap: Record<string, PlaceholderData>;
|
|
91
|
-
semanticContext: string;
|
|
92
|
-
}
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Translation, Exclusion,
|
|
1
|
+
import { Translation, Exclusion, DomRule } from '../types';
|
|
2
2
|
import { PathNormalizationConfig } from './pathNormalizer';
|
|
3
3
|
export interface ProjectEntitlements {
|
|
4
4
|
tier: 'starter' | 'startup' | 'global';
|
|
@@ -8,6 +8,19 @@ export interface ProjectEntitlements {
|
|
|
8
8
|
hreflangEnabled: boolean;
|
|
9
9
|
seoEnabled?: boolean;
|
|
10
10
|
}
|
|
11
|
+
export type SeoBundleResponse = {
|
|
12
|
+
locale?: string;
|
|
13
|
+
normalized_path?: string;
|
|
14
|
+
routing_strategy?: string;
|
|
15
|
+
seo?: Record<string, unknown>;
|
|
16
|
+
alternates?: {
|
|
17
|
+
canonical?: string;
|
|
18
|
+
xDefault?: string;
|
|
19
|
+
languages?: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
seoEnabled?: boolean;
|
|
22
|
+
entitlements?: ProjectEntitlements;
|
|
23
|
+
};
|
|
11
24
|
export declare class LovalingoAPI {
|
|
12
25
|
private apiKey;
|
|
13
26
|
private apiBase;
|
|
@@ -21,6 +34,7 @@ export declare class LovalingoAPI {
|
|
|
21
34
|
private isActivationRequiredResponse;
|
|
22
35
|
getEntitlements(): ProjectEntitlements | null;
|
|
23
36
|
fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
|
|
37
|
+
fetchSeoBundle(localeHint: string): Promise<SeoBundleResponse | null>;
|
|
24
38
|
trackPageview(pathOrUrl: string): Promise<void>;
|
|
25
39
|
fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
|
|
26
40
|
fetchBundle(localeHint: string): Promise<{
|
|
@@ -29,6 +43,5 @@ export declare class LovalingoAPI {
|
|
|
29
43
|
} | null>;
|
|
30
44
|
fetchExclusions(): Promise<Exclusion[]>;
|
|
31
45
|
fetchDomRules(targetLocale: string): Promise<DomRule[]>;
|
|
32
|
-
reportMisses(misses: MissedTranslation[], sourceLocale: string, targetLocale: string): Promise<void>;
|
|
33
46
|
saveExclusion(selector: string, type: 'css' | 'xpath'): Promise<void>;
|
|
34
47
|
}
|
package/dist/utils/api.js
CHANGED
|
@@ -22,8 +22,9 @@ export class LovalingoAPI {
|
|
|
22
22
|
isActivationRequiredPayload(data) {
|
|
23
23
|
if (!data || typeof data !== "object")
|
|
24
24
|
return false;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
25
|
+
const record = data;
|
|
26
|
+
const status = record["status"];
|
|
27
|
+
const errorCode = record["error_code"];
|
|
27
28
|
return status === "activation_required" || errorCode === "PROJECT_NOT_ACTIVATED";
|
|
28
29
|
}
|
|
29
30
|
isActivationRequiredResponse(response, data) {
|
|
@@ -68,6 +69,31 @@ export class LovalingoAPI {
|
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
async fetchSeoBundle(localeHint) {
|
|
73
|
+
try {
|
|
74
|
+
if (!this.hasApiKey()) {
|
|
75
|
+
this.warnMissingApiKey("fetchSeoBundle");
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const normalizedPath = processPath(window.location.pathname, this.pathConfig);
|
|
79
|
+
const response = await fetch(`${this.apiBase}/functions/v1/seo-bundle?key=${this.apiKey}&locale=${encodeURIComponent(localeHint)}&path=${encodeURIComponent(normalizedPath)}`, { cache: "no-store" });
|
|
80
|
+
if (this.isActivationRequiredResponse(response)) {
|
|
81
|
+
this.logActivationRequired("fetchSeoBundle", response);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok)
|
|
85
|
+
return null;
|
|
86
|
+
const data = (await response.json());
|
|
87
|
+
if (this.isActivationRequiredResponse(response, data)) {
|
|
88
|
+
this.logActivationRequired("fetchSeoBundle", response);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return (data || null);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
71
97
|
async trackPageview(pathOrUrl) {
|
|
72
98
|
try {
|
|
73
99
|
if (!this.hasApiKey())
|
|
@@ -188,67 +214,6 @@ export class LovalingoAPI {
|
|
|
188
214
|
return [];
|
|
189
215
|
}
|
|
190
216
|
}
|
|
191
|
-
async reportMisses(misses, sourceLocale, targetLocale) {
|
|
192
|
-
try {
|
|
193
|
-
if (!this.hasApiKey()) {
|
|
194
|
-
this.warnMissingApiKey('reportMisses');
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
// Use path normalization utility
|
|
198
|
-
const normalizedPath = processPath(window.location.pathname, this.pathConfig);
|
|
199
|
-
// CRITICAL: Filter out invalid misses
|
|
200
|
-
const validMisses = misses.filter(m => {
|
|
201
|
-
const isValid = m?.text &&
|
|
202
|
-
typeof m.text === 'string' &&
|
|
203
|
-
m.text.trim().length > 1;
|
|
204
|
-
if (!isValid) {
|
|
205
|
-
console.warn('[Lovalingo] ⚠️ Filtered invalid miss:', m);
|
|
206
|
-
}
|
|
207
|
-
return isValid;
|
|
208
|
-
});
|
|
209
|
-
if (validMisses.length === 0) {
|
|
210
|
-
console.log('[Lovalingo] ℹ️ No valid misses to report');
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
// Format for API
|
|
214
|
-
const formattedMisses = validMisses.map(m => ({
|
|
215
|
-
source_text: m.text.trim(), // Tokenized text
|
|
216
|
-
source_text_raw: m.raw, // Original HTML
|
|
217
|
-
placeholder_map: m.placeholderMap, // Token → HTML mapping
|
|
218
|
-
semantic_context: m.semanticContext // Element type
|
|
219
|
-
}));
|
|
220
|
-
console.log(`[Lovalingo] 📤 Reporting ${formattedMisses.length} misses to API...`);
|
|
221
|
-
console.log('[Lovalingo] Sample miss:', formattedMisses[0]);
|
|
222
|
-
const response = await fetch(`${this.apiBase}/functions/v1/misses`, {
|
|
223
|
-
method: 'POST',
|
|
224
|
-
headers: { 'Content-Type': 'application/json' },
|
|
225
|
-
body: JSON.stringify({
|
|
226
|
-
key: this.apiKey,
|
|
227
|
-
locale: targetLocale,
|
|
228
|
-
misses: formattedMisses,
|
|
229
|
-
path: normalizedPath,
|
|
230
|
-
}),
|
|
231
|
-
});
|
|
232
|
-
if (this.isActivationRequiredResponse(response)) {
|
|
233
|
-
this.logActivationRequired('reportMisses', response);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (!response.ok) {
|
|
237
|
-
const errorText = await response.text();
|
|
238
|
-
console.error(`[Lovalingo] ❌ API Error ${response.status}:`, errorText);
|
|
239
|
-
throw new Error(`Miss reporting failed: ${response.status}`);
|
|
240
|
-
}
|
|
241
|
-
const result = await response.json();
|
|
242
|
-
if (this.isActivationRequiredResponse(response, result)) {
|
|
243
|
-
this.logActivationRequired("reportMisses", response);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
console.log('[Lovalingo] ✅ Misses reported:', result);
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
console.error('[Lovalingo] ❌ Error reporting misses:', error);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
217
|
async saveExclusion(selector, type) {
|
|
253
218
|
try {
|
|
254
219
|
if (!this.hasApiKey()) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Translation, Exclusion
|
|
1
|
+
import { Translation, Exclusion } from '../types';
|
|
2
2
|
export declare class Translator {
|
|
3
3
|
private translationMap;
|
|
4
4
|
private exclusions;
|
|
5
|
-
private missedStrings;
|
|
6
5
|
private nonTranslatableTerms;
|
|
7
6
|
constructor();
|
|
8
7
|
/**
|
|
@@ -26,8 +25,6 @@ export declare class Translator {
|
|
|
26
25
|
private cleanupPreserveIds;
|
|
27
26
|
setTranslations(translations: Translation[]): void;
|
|
28
27
|
setExclusions(exclusions: Exclusion[]): void;
|
|
29
|
-
getMissedStrings(): MissedTranslation[];
|
|
30
|
-
clearMissedStrings(): void;
|
|
31
28
|
private isExcluded;
|
|
32
29
|
/**
|
|
33
30
|
* DOM-BASED EXTRACTION
|
|
@@ -80,12 +77,4 @@ export declare class Translator {
|
|
|
80
77
|
*/
|
|
81
78
|
private translateAttribute;
|
|
82
79
|
translateDOM(): void;
|
|
83
|
-
/**
|
|
84
|
-
* Translate SEO-relevant <head> elements (title + meta content) that are not part of the body DOM tree.
|
|
85
|
-
*/
|
|
86
|
-
translateHead(): void;
|
|
87
|
-
/**
|
|
88
|
-
* Restore original <head> SEO content (title + meta content) after returning to default locale.
|
|
89
|
-
*/
|
|
90
|
-
restoreHead(): void;
|
|
91
80
|
}
|
package/dist/utils/translator.js
CHANGED
|
@@ -2,7 +2,6 @@ export class Translator {
|
|
|
2
2
|
constructor() {
|
|
3
3
|
this.translationMap = new Map();
|
|
4
4
|
this.exclusions = [];
|
|
5
|
-
this.missedStrings = new Map();
|
|
6
5
|
// Brand names that should NEVER be translated
|
|
7
6
|
this.nonTranslatableTerms = new Set([
|
|
8
7
|
'Lovable', 'v0', 'Claude Code', 'Bolt', 'Base44',
|
|
@@ -127,12 +126,6 @@ export class Translator {
|
|
|
127
126
|
setExclusions(exclusions) {
|
|
128
127
|
this.exclusions = exclusions || [];
|
|
129
128
|
}
|
|
130
|
-
getMissedStrings() {
|
|
131
|
-
return Array.from(this.missedStrings.values());
|
|
132
|
-
}
|
|
133
|
-
clearMissedStrings() {
|
|
134
|
-
this.missedStrings.clear();
|
|
135
|
-
}
|
|
136
129
|
isExcluded(element) {
|
|
137
130
|
// Check CSS selectors
|
|
138
131
|
for (const exclusion of this.exclusions) {
|
|
@@ -632,14 +625,6 @@ export class Translator {
|
|
|
632
625
|
}
|
|
633
626
|
else {
|
|
634
627
|
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
|
-
}
|
|
643
628
|
}
|
|
644
629
|
}
|
|
645
630
|
finally {
|
|
@@ -696,14 +681,6 @@ export class Translator {
|
|
|
696
681
|
}
|
|
697
682
|
else {
|
|
698
683
|
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
|
-
}
|
|
707
684
|
}
|
|
708
685
|
}
|
|
709
686
|
finally {
|
|
@@ -782,15 +759,6 @@ export class Translator {
|
|
|
782
759
|
temp.innerHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
783
760
|
this.updateTextNodesOnly(el, temp);
|
|
784
761
|
}
|
|
785
|
-
else {
|
|
786
|
-
// Queue miss using the source used for lookup (prefer tokenized)
|
|
787
|
-
this.missedStrings.set(source, {
|
|
788
|
-
text: source,
|
|
789
|
-
raw: content.rawHTML,
|
|
790
|
-
placeholderMap: this.sanitizePlaceholderMap(content.placeholderMap),
|
|
791
|
-
semanticContext: tag.toLowerCase()
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
762
|
}
|
|
795
763
|
}
|
|
796
764
|
}
|
|
@@ -821,120 +789,12 @@ export class Translator {
|
|
|
821
789
|
if (translated) {
|
|
822
790
|
element.setAttribute(attr, translated);
|
|
823
791
|
}
|
|
824
|
-
else if (sourceValue.length < 500) {
|
|
825
|
-
// Queue as miss
|
|
826
|
-
this.missedStrings.set(sourceValue, {
|
|
827
|
-
text: sourceValue,
|
|
828
|
-
raw: sourceValue,
|
|
829
|
-
placeholderMap: {},
|
|
830
|
-
semanticContext: context
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
792
|
}
|
|
834
793
|
translateDOM() {
|
|
835
794
|
console.log(`[Lovalingo] 🔄 translateDOM() called with ${this.translationMap.size} translations`);
|
|
836
795
|
const startTime = performance.now();
|
|
837
796
|
this.translateElement(document.body);
|
|
838
797
|
const elapsed = performance.now() - startTime;
|
|
839
|
-
console.log(`[Lovalingo] 🏁 translateDOM() complete in ${elapsed.toFixed(2)}ms
|
|
840
|
-
}
|
|
841
|
-
/**
|
|
842
|
-
* Translate SEO-relevant <head> elements (title + meta content) that are not part of the body DOM tree.
|
|
843
|
-
*/
|
|
844
|
-
translateHead() {
|
|
845
|
-
try {
|
|
846
|
-
const head = document.head;
|
|
847
|
-
if (!head)
|
|
848
|
-
return;
|
|
849
|
-
const titleEl = head.querySelector("title");
|
|
850
|
-
if (titleEl) {
|
|
851
|
-
const originalKey = "data-Lovalingo-title-original";
|
|
852
|
-
if (!titleEl.getAttribute(originalKey)) {
|
|
853
|
-
titleEl.setAttribute(originalKey, titleEl.textContent || "");
|
|
854
|
-
}
|
|
855
|
-
const sourceTitle = (titleEl.getAttribute(originalKey) || "").trim();
|
|
856
|
-
if (sourceTitle && this.isTranslatableText(sourceTitle)) {
|
|
857
|
-
const translated = this.translationMap.get(sourceTitle);
|
|
858
|
-
if (translated) {
|
|
859
|
-
titleEl.textContent = translated;
|
|
860
|
-
}
|
|
861
|
-
else if (sourceTitle.length < 500) {
|
|
862
|
-
this.missedStrings.set(sourceTitle, {
|
|
863
|
-
text: sourceTitle,
|
|
864
|
-
raw: sourceTitle,
|
|
865
|
-
placeholderMap: {},
|
|
866
|
-
semanticContext: "title",
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
const metaSelectors = [
|
|
872
|
-
'meta[name="description"]',
|
|
873
|
-
'meta[property="og:title"]',
|
|
874
|
-
'meta[property="og:description"]',
|
|
875
|
-
'meta[name="twitter:title"]',
|
|
876
|
-
'meta[name="twitter:description"]',
|
|
877
|
-
];
|
|
878
|
-
metaSelectors.forEach((selector) => {
|
|
879
|
-
head.querySelectorAll(selector).forEach((node) => {
|
|
880
|
-
if (!(node instanceof HTMLMetaElement))
|
|
881
|
-
return;
|
|
882
|
-
const originalAttrKey = "data-Lovalingo-content-original";
|
|
883
|
-
if (!node.getAttribute(originalAttrKey)) {
|
|
884
|
-
const content = (node.getAttribute("content") || "").trim();
|
|
885
|
-
node.setAttribute(originalAttrKey, content);
|
|
886
|
-
}
|
|
887
|
-
const sourceValue = (node.getAttribute(originalAttrKey) || "").trim();
|
|
888
|
-
if (!sourceValue || sourceValue.length <= 1)
|
|
889
|
-
return;
|
|
890
|
-
if (!this.isTranslatableText(sourceValue))
|
|
891
|
-
return;
|
|
892
|
-
const translated = this.translationMap.get(sourceValue);
|
|
893
|
-
if (translated) {
|
|
894
|
-
node.setAttribute("content", translated);
|
|
895
|
-
}
|
|
896
|
-
else if (sourceValue.length < 500) {
|
|
897
|
-
const context = selector.includes("description") ? "meta-description" : "meta-title";
|
|
898
|
-
this.missedStrings.set(sourceValue, {
|
|
899
|
-
text: sourceValue,
|
|
900
|
-
raw: sourceValue,
|
|
901
|
-
placeholderMap: {},
|
|
902
|
-
semanticContext: context,
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
catch (e) {
|
|
909
|
-
console.warn("[Lovalingo] translateHead() failed:", e);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* Restore original <head> SEO content (title + meta content) after returning to default locale.
|
|
914
|
-
*/
|
|
915
|
-
restoreHead() {
|
|
916
|
-
try {
|
|
917
|
-
const head = document.head;
|
|
918
|
-
if (!head)
|
|
919
|
-
return;
|
|
920
|
-
const titleEl = head.querySelector("title");
|
|
921
|
-
if (titleEl) {
|
|
922
|
-
const original = titleEl.getAttribute("data-Lovalingo-title-original");
|
|
923
|
-
if (original !== null) {
|
|
924
|
-
titleEl.textContent = original;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
head.querySelectorAll('meta[data-Lovalingo-content-original]').forEach((node) => {
|
|
928
|
-
if (!(node instanceof HTMLMetaElement))
|
|
929
|
-
return;
|
|
930
|
-
const original = node.getAttribute("data-Lovalingo-content-original");
|
|
931
|
-
if (original !== null) {
|
|
932
|
-
node.setAttribute("content", original);
|
|
933
|
-
}
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
|
-
catch (e) {
|
|
937
|
-
console.warn("[Lovalingo] restoreHead() failed:", e);
|
|
938
|
-
}
|
|
798
|
+
console.log(`[Lovalingo] 🏁 translateDOM() complete in ${elapsed.toFixed(2)}ms.`);
|
|
939
799
|
}
|
|
940
800
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovalingo/lovalingo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"description": "React translation runtime with i18n routing, deterministic bundles + DOM rules, and zero-flash rendering.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dist"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
14
|
+
"build": "rm -rf dist && tsc",
|
|
15
15
|
"prepublishOnly": "npm run build"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
interface AutoTranslateProps {
|
|
3
|
-
children: React.ReactNode;
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* AutoTranslate component - automatically translates text nodes
|
|
7
|
-
* Uses content hashing for change detection and caching
|
|
8
|
-
*/
|
|
9
|
-
export declare const AutoTranslate: React.FC<AutoTranslateProps>;
|
|
10
|
-
export {};
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import React, { useContext } from 'react';
|
|
2
|
-
import { LovalingoContext } from '../context/LovalingoContext';
|
|
3
|
-
import { hashContent } from '../utils/hash';
|
|
4
|
-
/**
|
|
5
|
-
* AutoTranslate component - automatically translates text nodes
|
|
6
|
-
* Uses content hashing for change detection and caching
|
|
7
|
-
*/
|
|
8
|
-
export const AutoTranslate = ({ children }) => {
|
|
9
|
-
const context = useContext(LovalingoContext);
|
|
10
|
-
if (!context) {
|
|
11
|
-
// Not wrapped in LovalingoProvider - return children as-is
|
|
12
|
-
return React.createElement(React.Fragment, null, children);
|
|
13
|
-
}
|
|
14
|
-
const { locale, config, getTranslation, queueMiss } = context;
|
|
15
|
-
// If we're on default locale, no translation needed
|
|
16
|
-
if (locale === config.defaultLocale) {
|
|
17
|
-
return React.createElement(React.Fragment, null, children);
|
|
18
|
-
}
|
|
19
|
-
// Recursively translate all text nodes
|
|
20
|
-
const translateChildren = (node) => {
|
|
21
|
-
// Handle strings (text nodes)
|
|
22
|
-
if (typeof node === 'string') {
|
|
23
|
-
const match = node.match(/^(\s*)(.*?)(\s*)$/s);
|
|
24
|
-
const leading = match?.[1] ?? "";
|
|
25
|
-
const core = match?.[2] ?? node;
|
|
26
|
-
const trailing = match?.[3] ?? "";
|
|
27
|
-
const trimmed = core.trim();
|
|
28
|
-
// Skip empty or very short strings
|
|
29
|
-
if (trimmed.length === 0 || trimmed.length < 2) {
|
|
30
|
-
return node;
|
|
31
|
-
}
|
|
32
|
-
// Calculate content hash
|
|
33
|
-
const contentHash = hashContent(trimmed);
|
|
34
|
-
// Try to get translation from cache
|
|
35
|
-
const translation = getTranslation(contentHash, trimmed);
|
|
36
|
-
if (translation) {
|
|
37
|
-
// We have translation - return it
|
|
38
|
-
return `${leading}${translation}${trailing}`;
|
|
39
|
-
}
|
|
40
|
-
// No translation yet: report miss (may enqueue deterministic pipeline job) and show original.
|
|
41
|
-
queueMiss(trimmed);
|
|
42
|
-
return node;
|
|
43
|
-
}
|
|
44
|
-
// Handle numbers
|
|
45
|
-
if (typeof node === 'number') {
|
|
46
|
-
return node;
|
|
47
|
-
}
|
|
48
|
-
// Handle arrays
|
|
49
|
-
if (Array.isArray(node)) {
|
|
50
|
-
return node.map((child, index) => (React.createElement(React.Fragment, { key: index }, translateChildren(child))));
|
|
51
|
-
}
|
|
52
|
-
// Handle React elements
|
|
53
|
-
if (React.isValidElement(node)) {
|
|
54
|
-
const element = node;
|
|
55
|
-
// Don't translate if marked as excluded
|
|
56
|
-
if (element.props?.['data-Lovalingo-exclude']) {
|
|
57
|
-
return node;
|
|
58
|
-
}
|
|
59
|
-
// Clone element with translated children
|
|
60
|
-
return React.cloneElement(element, {
|
|
61
|
-
...element.props,
|
|
62
|
-
children: translateChildren(element.props.children),
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// Return anything else as-is
|
|
66
|
-
return node;
|
|
67
|
-
};
|
|
68
|
-
return React.createElement(React.Fragment, null, translateChildren(children));
|
|
69
|
-
};
|