@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 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
- - See [PATH_EXAMPLES.md](./PATH_EXAMPLES.md) for detailed examples
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 all = allLocales;
117
- const url = new URL(window.location.href);
118
- // Derive a locale-neutral base pathname for path routing
119
- let basePathname = url.pathname;
120
- if (routing === "path") {
121
- const parts = basePathname.split("/").filter(Boolean);
122
- if (parts.length > 0 && all.includes(parts[0])) {
123
- parts.shift();
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
- basePathname = "/" + parts.join("/");
126
- if (basePathname === "/") {
127
- // ok
128
- }
129
- else if (basePathname.endsWith("/") && basePathname.length > 1) {
130
- basePathname = basePathname.slice(0, -1);
131
- }
132
- }
133
- const buildUrlForLocale = (localeForUrl) => {
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
- return next.toString();
131
+ const el = document.createElement("title");
132
+ el.textContent = value;
133
+ head.appendChild(el);
147
134
  };
148
- // Canonical should point to the current locale variant
149
- const canonicalHref = buildUrlForLocale(activeLocale);
150
- const canonical = document.createElement("link");
151
- canonical.rel = "canonical";
152
- canonical.href = canonicalHref;
153
- canonical.setAttribute("data-Lovalingo", "canonical");
154
- head.appendChild(canonical);
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
- // hreflang alternates for each locale
158
- all.forEach((loc) => {
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 = loc;
162
- link.href = buildUrlForLocale(loc);
192
+ link.hreflang = lang;
193
+ link.href = href;
163
194
  link.setAttribute("data-Lovalingo", "hreflang");
164
195
  head.appendChild(link);
165
- });
166
- // x-default -> default locale
167
- const xDefault = document.createElement("link");
168
- xDefault.rel = "alternate";
169
- xDefault.hreflang = "x-default";
170
- xDefault.href = buildUrlForLocale(defaultLocale);
171
- xDefault.setAttribute("data-Lovalingo", "hreflang");
172
- head.appendChild(xDefault);
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
- }, [allLocales, defaultLocale, routing, isSeoActive]);
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, hashMap });
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
- // NEW: Get translation by hash (for React Context system)
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 so context mode has hashMap ready.
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
- // Report missed strings periodically (DOM mode only)
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(4px)',
12
- WebkitBackdropFilter: 'blur(4px)',
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, HashTranslation, DomRule, DomRuleType } from './types';
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' | 'context';
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
- }
@@ -1,4 +1,4 @@
1
- import { Translation, Exclusion, MissedTranslation, DomRule } from '../types';
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 status = data.status;
26
- const errorCode = data.error_code;
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, MissedTranslation } from '../types';
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
  }
@@ -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. Missed: ${this.missedStrings.size}`);
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.27",
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
- };