@lovalingo/lovalingo 0.0.26 → 0.0.27

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
@@ -123,7 +123,6 @@ export default function RootLayout({ children }) {
123
123
  ```tsx
124
124
  <LovalingoProvider
125
125
  publicAnonKey="aix_xxx" // Required: Your Lovalingo Public Anon Key (safe to expose)
126
- // Backwards compatible: apiKey="aix_xxx"
127
126
  defaultLocale="en" // Required: Source language
128
127
  locales={['en', 'de', 'fr']} // Required: Supported languages
129
128
  apiBase="https://..." // Optional: Custom API endpoint
@@ -66,8 +66,8 @@ navigateRef, // For path mode routing
66
66
  const domRulesCacheRef = useRef(new Map());
67
67
  // NEW: Hash-based translation cache for React Context system
68
68
  const [hashTranslations, setHashTranslations] = useState(new Map());
69
- const translatingHashesRef = useRef(new Set());
70
- const [, forceUpdate] = useState({});
69
+ const contextMissQueueRef = useRef(new Set());
70
+ const contextMissFlushTimeoutRef = useRef(null);
71
71
  const config = {
72
72
  apiKey: resolvedApiKey,
73
73
  publicAnonKey: resolvedApiKey,
@@ -236,6 +236,7 @@ navigateRef, // For path mode routing
236
236
  if (targetLocale === defaultLocale) {
237
237
  if (showOverlay)
238
238
  setIsNavigationLoading(false);
239
+ setHashTranslations(new Map());
239
240
  translatorRef.current.setTranslations([]);
240
241
  translatorRef.current.restoreDOM(); // Safe to restore when going back to source language
241
242
  translatorRef.current.restoreHead();
@@ -246,18 +247,22 @@ navigateRef, // For path mode routing
246
247
  const currentPath = window.location.pathname;
247
248
  const cacheKey = `${targetLocale}:${currentPath}`;
248
249
  // Check if we have cached translations for this locale + path
249
- const cachedTranslations = translationCacheRef.current.get(cacheKey);
250
+ const cachedEntry = translationCacheRef.current.get(cacheKey);
250
251
  const cachedExclusions = exclusionsCacheRef.current;
251
252
  const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
252
- if (cachedTranslations && cachedExclusions) {
253
+ if (cachedEntry && cachedExclusions) {
253
254
  // CACHE HIT - Use cached data immediately (FAST!)
254
255
  console.log(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
255
- translatorRef.current.setTranslations(cachedTranslations);
256
+ setHashTranslations(new Map(Object.entries(cachedEntry.hashMap || {})));
257
+ translatorRef.current.setTranslations(cachedEntry.translations);
256
258
  translatorRef.current.setExclusions(cachedExclusions);
257
- translatorRef.current.translateDOM();
258
- if (isSeoActive())
259
+ if (mode === 'dom') {
260
+ translatorRef.current.translateDOM();
261
+ }
262
+ if (isSeoActive()) {
259
263
  translatorRef.current.translateHead();
260
- if (autoApplyRules && mode === 'dom') {
264
+ }
265
+ if (autoApplyRules) {
261
266
  if (Array.isArray(cachedDomRules)) {
262
267
  applyDomRules(cachedDomRules);
263
268
  }
@@ -274,22 +279,27 @@ navigateRef, // For path mode routing
274
279
  return;
275
280
  }
276
281
  console.log(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
277
- translatorRef.current.translateDOM();
278
- if (isSeoActive())
282
+ if (mode === 'dom') {
283
+ translatorRef.current.translateDOM();
284
+ }
285
+ if (isSeoActive()) {
279
286
  translatorRef.current.translateHead();
280
- if (autoApplyRules && mode === 'dom') {
287
+ }
288
+ if (autoApplyRules) {
281
289
  const rules = domRulesCacheRef.current.get(cacheKey) || cachedDomRules || [];
282
290
  applyDomRules(rules);
283
291
  }
284
- // Immediately report any misses found
285
- const missed = translatorRef.current.getMissedStrings();
286
- if (missed.length > 0) {
287
- console.log(`[Lovalingo] 📤 Reporting ${missed.length} misses immediately`);
288
- apiRef.current.reportMisses(missed, defaultLocale, targetLocale);
289
- translatorRef.current.clearMissedStrings();
290
- }
291
- else {
292
- console.log(`[Lovalingo] ✅ No misses detected`);
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
+ }
293
303
  }
294
304
  }, 500);
295
305
  if (showOverlay) {
@@ -305,26 +315,39 @@ navigateRef, // For path mode routing
305
315
  if (previousLocale && previousLocale !== defaultLocale) {
306
316
  console.log(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
307
317
  }
308
- const [translations, exclusions, domRules] = await Promise.all([
309
- apiRef.current.fetchTranslations(defaultLocale, targetLocale),
318
+ const [bundle, exclusions, domRules] = await Promise.all([
319
+ apiRef.current.fetchBundle(targetLocale),
310
320
  apiRef.current.fetchExclusions(),
311
- autoApplyRules && mode === 'dom' ? apiRef.current.fetchDomRules(targetLocale) : Promise.resolve([]),
321
+ autoApplyRules ? apiRef.current.fetchDomRules(targetLocale) : Promise.resolve([]),
312
322
  ]);
313
323
  const nextEntitlements = apiRef.current.getEntitlements();
314
324
  if (nextEntitlements)
315
325
  setEntitlements(nextEntitlements);
326
+ const translations = bundle
327
+ ? Object.entries(bundle.map).map(([source_text, translated_text]) => ({
328
+ source_text,
329
+ translated_text,
330
+ source_locale: defaultLocale,
331
+ target_locale: targetLocale,
332
+ }))
333
+ : [];
334
+ const hashMap = bundle?.hashMap || {};
335
+ setHashTranslations(new Map(Object.entries(hashMap)));
316
336
  // Store in cache for next time
317
- translationCacheRef.current.set(cacheKey, translations);
337
+ translationCacheRef.current.set(cacheKey, { translations, hashMap });
318
338
  exclusionsCacheRef.current = exclusions;
319
- if (autoApplyRules && mode === 'dom') {
339
+ if (autoApplyRules) {
320
340
  domRulesCacheRef.current.set(cacheKey, domRules);
321
341
  }
322
342
  translatorRef.current.setTranslations(translations);
323
343
  translatorRef.current.setExclusions(exclusions);
324
- translatorRef.current.translateDOM();
325
- if (isSeoActive())
344
+ if (mode === 'dom') {
345
+ translatorRef.current.translateDOM();
346
+ }
347
+ if (isSeoActive()) {
326
348
  translatorRef.current.translateHead();
327
- if (autoApplyRules && mode === 'dom') {
349
+ }
350
+ if (autoApplyRules) {
328
351
  applyDomRules(domRules);
329
352
  }
330
353
  // Delayed retry scan to catch late-rendering content
@@ -334,22 +357,27 @@ navigateRef, // For path mode routing
334
357
  return;
335
358
  }
336
359
  console.log(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
337
- translatorRef.current.translateDOM();
338
- if (isSeoActive())
360
+ if (mode === "dom") {
361
+ translatorRef.current.translateDOM();
362
+ }
363
+ if (isSeoActive()) {
339
364
  translatorRef.current.translateHead();
340
- if (autoApplyRules && mode === 'dom') {
365
+ }
366
+ if (autoApplyRules) {
341
367
  const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
342
368
  applyDomRules(rules);
343
369
  }
344
- // Immediately report any misses found
345
- const missed = translatorRef.current.getMissedStrings();
346
- if (missed.length > 0) {
347
- console.log(`[Lovalingo] 📤 Reporting ${missed.length} misses immediately`);
348
- apiRef.current.reportMisses(missed, defaultLocale, targetLocale);
349
- translatorRef.current.clearMissedStrings();
350
- }
351
- else {
352
- console.log(`[Lovalingo] ✅ No misses detected`);
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
+ }
353
381
  }
354
382
  }, 500);
355
383
  if (showOverlay) {
@@ -426,43 +454,30 @@ navigateRef, // For path mode routing
426
454
  const getTranslation = useCallback((hash, fallback) => {
427
455
  return hashTranslations.get(hash) || null;
428
456
  }, [hashTranslations]);
429
- // NEW: Queue real-time translation
430
- const queueRealTimeTranslation = useCallback(async (hash, text, onComplete) => {
431
- // Already translating this hash
432
- if (translatingHashesRef.current.has(hash)) {
457
+ const queueMiss = useCallback((text) => {
458
+ const cleaned = (text || "").toString().trim();
459
+ if (!cleaned || cleaned.length < 2)
433
460
  return;
434
- }
435
- // Already have translation
436
- if (hashTranslations.has(hash)) {
461
+ if (locale === defaultLocale)
437
462
  return;
438
- }
439
- // Mark as translating
440
- translatingHashesRef.current.add(hash);
441
- try {
442
- // Call real-time translation API
443
- const translation = await apiRef.current.translateRealtime(hash, text, defaultLocale, locale);
444
- if (translation) {
445
- // Update cache
446
- setHashTranslations(prev => {
447
- const newMap = new Map(prev);
448
- newMap.set(hash, translation);
449
- return newMap;
450
- });
451
- // Force re-render to show translation
452
- forceUpdate({});
453
- if (onComplete) {
454
- onComplete();
455
- }
456
- }
457
- }
458
- catch (error) {
459
- console.error(`[Lovalingo] Failed to translate hash ${hash}:`, error);
460
- }
461
- finally {
462
- // Remove from translating set
463
- translatingHashesRef.current.delete(hash);
464
- }
465
- }, [defaultLocale, locale, hashTranslations]);
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]);
466
481
  // Selenium bridge: allows automation to force-flush misses instead of relying on the 5s interval.
467
482
  useEffect(() => {
468
483
  if (typeof window === "undefined")
@@ -506,10 +521,8 @@ navigateRef, // For path mode routing
506
521
  if (next)
507
522
  setEntitlements(next);
508
523
  });
509
- // Only load data for DOM mode (context mode uses AutoTranslate)
510
- if (mode === 'dom') {
511
- loadData(initialLocale);
512
- }
524
+ // Always prefetch artifacts for the initial locale so context mode has hashMap ready.
525
+ loadData(initialLocale);
513
526
  // Set up keyboard shortcut for edit mode
514
527
  const handleKeyPress = (e) => {
515
528
  if (e.code === editKey && (e.ctrlKey || e.metaKey)) {
@@ -530,7 +543,7 @@ navigateRef, // For path mode routing
530
543
  useEffect(() => {
531
544
  if (sitemap && resolvedApiKey && isSeoActive()) {
532
545
  // Prefer same-origin /sitemap.xml so crawlers discover the canonical sitemap URL.
533
- // Hosting should route /sitemap.xml to Lovalingo's generate-sitemap endpoint.
546
+ // Reminder: /sitemap.xml should be published by the host app (recommended: build-time copy from Lovalingo CDN).
534
547
  const sitemapUrl = `${window.location.origin}/sitemap.xml`;
535
548
  // Check if link already exists to avoid duplicates
536
549
  const existingLink = document.querySelector(`link[rel="sitemap"][href="${sitemapUrl}"]`);
@@ -871,7 +884,7 @@ navigateRef, // For path mode routing
871
884
  toggleEditMode,
872
885
  excludeElement,
873
886
  getTranslation,
874
- queueRealTimeTranslation,
887
+ queueMiss,
875
888
  };
876
889
  return (React.createElement(LovalingoContext.Provider, { value: contextValue },
877
890
  children,
@@ -1,4 +1,4 @@
1
- import React, { useContext, useState } from 'react';
1
+ import React, { useContext } from 'react';
2
2
  import { LovalingoContext } from '../context/LovalingoContext';
3
3
  import { hashContent } from '../utils/hash';
4
4
  /**
@@ -7,12 +7,11 @@ import { hashContent } from '../utils/hash';
7
7
  */
8
8
  export const AutoTranslate = ({ children }) => {
9
9
  const context = useContext(LovalingoContext);
10
- const [isTranslating, setIsTranslating] = useState(false);
11
10
  if (!context) {
12
11
  // Not wrapped in LovalingoProvider - return children as-is
13
12
  return React.createElement(React.Fragment, null, children);
14
13
  }
15
- const { locale, config, getTranslation, queueRealTimeTranslation } = context;
14
+ const { locale, config, getTranslation, queueMiss } = context;
16
15
  // If we're on default locale, no translation needed
17
16
  if (locale === config.defaultLocale) {
18
17
  return React.createElement(React.Fragment, null, children);
@@ -21,7 +20,11 @@ export const AutoTranslate = ({ children }) => {
21
20
  const translateChildren = (node) => {
22
21
  // Handle strings (text nodes)
23
22
  if (typeof node === 'string') {
24
- const trimmed = node.trim();
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();
25
28
  // Skip empty or very short strings
26
29
  if (trimmed.length === 0 || trimmed.length < 2) {
27
30
  return node;
@@ -32,22 +35,11 @@ export const AutoTranslate = ({ children }) => {
32
35
  const translation = getTranslation(contentHash, trimmed);
33
36
  if (translation) {
34
37
  // We have translation - return it
35
- return translation;
38
+ return `${leading}${translation}${trailing}`;
36
39
  }
37
- // No translation - queue for real-time translation
38
- if (!isTranslating) {
39
- setIsTranslating(true);
40
- queueRealTimeTranslation(contentHash, trimmed, () => {
41
- setIsTranslating(false);
42
- });
43
- }
44
- // Return blurred original text while translating
45
- return (React.createElement("span", { style: {
46
- filter: 'blur(3px)',
47
- opacity: 0.6,
48
- transition: 'filter 0.3s ease, opacity 0.3s ease',
49
- display: 'inline-block',
50
- }, "data-translating": "true", "data-hash": contentHash }, trimmed));
40
+ // No translation yet: report miss (may enqueue deterministic pipeline job) and show original.
41
+ queueMiss(trimmed);
42
+ return node;
51
43
  }
52
44
  // Handle numbers
53
45
  if (typeof node === 'number') {
package/dist/types.d.ts CHANGED
@@ -2,12 +2,10 @@ import { PathNormalizationConfig } from './utils/pathNormalizer';
2
2
  export interface LovalingoConfig {
3
3
  /**
4
4
  * Public project key (safe to expose in the browser).
5
- * Backwards compatible alias: you can still pass `apiKey`.
6
5
  */
7
6
  publicAnonKey?: string;
8
7
  /**
9
- * Backwards compatible name for the public project key.
10
- * Prefer `publicAnonKey` in new installs.
8
+ * @deprecated Use `publicAnonKey`.
11
9
  */
12
10
  apiKey?: string;
13
11
  defaultLocale: string;
@@ -35,7 +33,7 @@ export interface LovalingoContextValue {
35
33
  toggleEditMode: () => void;
36
34
  excludeElement: (selector: string) => Promise<void>;
37
35
  getTranslation: (hash: string, fallback: string) => string | null;
38
- queueRealTimeTranslation: (hash: string, text: string, onComplete?: () => void) => void;
36
+ queueMiss: (text: string) => void;
39
37
  }
40
38
  export interface Translation {
41
39
  source_text: string;
@@ -23,13 +23,12 @@ export declare class LovalingoAPI {
23
23
  fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
24
24
  trackPageview(pathOrUrl: string): Promise<void>;
25
25
  fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
26
+ fetchBundle(localeHint: string): Promise<{
27
+ map: Record<string, string>;
28
+ hashMap: Record<string, string>;
29
+ } | null>;
26
30
  fetchExclusions(): Promise<Exclusion[]>;
27
31
  fetchDomRules(targetLocale: string): Promise<DomRule[]>;
28
32
  reportMisses(misses: MissedTranslation[], sourceLocale: string, targetLocale: string): Promise<void>;
29
33
  saveExclusion(selector: string, type: 'css' | 'xpath'): Promise<void>;
30
- /**
31
- * Real-time translation - calls Groq directly for instant translation
32
- * Used when translation cache misses
33
- */
34
- translateRealtime(contentHash: string, sourceText: string, sourceLocale: string, targetLocale: string): Promise<string | null>;
35
34
  }
package/dist/utils/api.js CHANGED
@@ -16,7 +16,7 @@ export class LovalingoAPI {
16
16
  logActivationRequired(context, response) {
17
17
  console.error(`[Lovalingo] ${context} blocked (HTTP ${response.status}). ` +
18
18
  `This project is not activated yet. ` +
19
- `Publish a public routes manifest at "/.well-known/lovalingo-routes.json" on your domain, ` +
19
+ `Publish a public manifest at "/.well-known/lovalingo.json" on your domain, ` +
20
20
  `then verify it in the Lovalingo dashboard to activate translations + SEO.`);
21
21
  }
22
22
  isActivationRequiredPayload(data) {
@@ -92,19 +92,39 @@ export class LovalingoAPI {
92
92
  this.warnMissingApiKey('fetchTranslations');
93
93
  return [];
94
94
  }
95
- // Use path normalization utility
95
+ const bundle = await this.fetchBundle(targetLocale);
96
+ if (!bundle)
97
+ return [];
98
+ return Object.entries(bundle.map).map(([source_text, translated_text]) => ({
99
+ source_text,
100
+ translated_text,
101
+ source_locale: sourceLocale,
102
+ target_locale: targetLocale,
103
+ }));
104
+ }
105
+ catch (error) {
106
+ console.error('Error fetching translations:', error);
107
+ return [];
108
+ }
109
+ }
110
+ async fetchBundle(localeHint) {
111
+ try {
112
+ if (!this.hasApiKey()) {
113
+ this.warnMissingApiKey("fetchBundle");
114
+ return null;
115
+ }
96
116
  const normalizedPath = processPath(window.location.pathname, this.pathConfig);
97
- const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${targetLocale}&path=${normalizedPath}`);
117
+ const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${normalizedPath}`);
98
118
  if (this.isActivationRequiredResponse(response)) {
99
- this.logActivationRequired('fetchTranslations', response);
100
- return [];
119
+ this.logActivationRequired("fetchBundle", response);
120
+ return null;
101
121
  }
102
122
  if (!response.ok)
103
- throw new Error('Failed to fetch translations');
123
+ return null;
104
124
  const data = await response.json();
105
125
  if (this.isActivationRequiredResponse(response, data)) {
106
- this.logActivationRequired("fetchTranslations", response);
107
- return [];
126
+ this.logActivationRequired("fetchBundle", response);
127
+ return null;
108
128
  }
109
129
  if (data?.entitlements) {
110
130
  this.entitlements = {
@@ -112,20 +132,12 @@ export class LovalingoAPI {
112
132
  seoEnabled: typeof data?.seoEnabled === "boolean" ? data.seoEnabled : undefined,
113
133
  };
114
134
  }
115
- // Convert map to array of Translation objects
116
- if (data.map && typeof data.map === 'object') {
117
- return Object.entries(data.map).map(([source_text, translated_text]) => ({
118
- source_text,
119
- translated_text: translated_text,
120
- source_locale: sourceLocale,
121
- target_locale: targetLocale,
122
- }));
123
- }
124
- return [];
135
+ const map = data?.map && typeof data.map === "object" ? data.map : {};
136
+ const hashMap = data?.hashMap && typeof data.hashMap === "object" ? data.hashMap : {};
137
+ return { map, hashMap };
125
138
  }
126
- catch (error) {
127
- console.error('Error fetching translations:', error);
128
- return [];
139
+ catch {
140
+ return null;
129
141
  }
130
142
  }
131
143
  async fetchExclusions() {
@@ -257,53 +269,4 @@ export class LovalingoAPI {
257
269
  throw error;
258
270
  }
259
271
  }
260
- /**
261
- * Real-time translation - calls Groq directly for instant translation
262
- * Used when translation cache misses
263
- */
264
- async translateRealtime(contentHash, sourceText, sourceLocale, targetLocale) {
265
- try {
266
- if (!this.hasApiKey()) {
267
- this.warnMissingApiKey('translateRealtime');
268
- return null;
269
- }
270
- console.log(`[Lovalingo] 🚀 Real-time translation: "${sourceText.substring(0, 40)}..."`);
271
- const response = await fetch(`${this.apiBase}/functions/v1/translate-realtime`, {
272
- method: 'POST',
273
- headers: {
274
- 'Content-Type': 'application/json',
275
- 'x-api-key': this.apiKey,
276
- },
277
- body: JSON.stringify({
278
- contentHash,
279
- sourceText,
280
- sourceLocale,
281
- targetLocale,
282
- }),
283
- });
284
- if (this.isActivationRequiredResponse(response)) {
285
- this.logActivationRequired('translateRealtime', response);
286
- return null;
287
- }
288
- if (!response.ok) {
289
- const errorText = await response.text();
290
- console.error(`[Lovalingo] ❌ Real-time translation failed:`, errorText);
291
- return null;
292
- }
293
- const result = await response.json();
294
- if (this.isActivationRequiredResponse(response, result)) {
295
- this.logActivationRequired("translateRealtime", response);
296
- return null;
297
- }
298
- if (result.success && result.translation) {
299
- console.log(`[Lovalingo] ✅ Translated: "${result.translation.substring(0, 40)}..." ${result.cached ? '(cached)' : '(new)'}`);
300
- return result.translation;
301
- }
302
- return null;
303
- }
304
- catch (error) {
305
- console.error('[Lovalingo] ❌ Real-time translation error:', error);
306
- return null;
307
- }
308
- }
309
272
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.0.26",
4
- "description": "React translation library with automatic routing, real-time AI translation, and zero-flash rendering. One-line language routing setup.",
3
+ "version": "0.0.27",
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",
7
7
  "publishConfig": {