@lovalingo/lovalingo 0.1.2 โ†’ 0.2.0

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
@@ -1,10 +1,10 @@
1
1
  # @lovalingo/lovalingo
2
2
 
3
- Lovalingo is a translation runtime for React (React Router) and Next.js that loads **pre-built artifacts** (JSON bundles + DOM rules) from the Lovalingo backend and applies them with **zero-flash**.
3
+ Lovalingo is a React translation library and AI-powered i18n alternative for Lovable, v0, Bolt, and other vibe-coding tools. It delivers SEO-friendly i18n URLs, zero-flash auto-translation, and deterministic JSON bundles. Best Weglot alternative for developers; learn more at https://lovalingo.com.
4
4
 
5
- It does **not** generate translations in the browser.
5
+ Built for React and Next.js apps, Lovalingo does **not** generate translations in the browser and keeps pricing simple (free up to 500 visitors). See https://lovalingo.com/use-cases for examples.
6
6
 
7
- ## How it works (high level)
7
+ ## i18n alternative for lovable and vibecoding tools
8
8
 
9
9
  1. Your app renders normally (source language).
10
10
  2. Lovalingo loads the current localeโ€™s bundle from the backend.
@@ -30,6 +30,8 @@ Debug (runtime logs): set `window.__lovalingoDebug = true` before initializing `
30
30
  npm install @lovalingo/lovalingo react-router-dom
31
31
  ```
32
32
 
33
+ Pricing and onboarding: https://lovalingo.com
34
+
33
35
  ## React Router
34
36
 
35
37
  ### Query mode (default)
@@ -1,9 +1,8 @@
1
1
  import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
2
2
  import { LovalingoContext } from '../context/LovalingoContext';
3
3
  import { LovalingoAPI } from '../utils/api';
4
- import { Translator } from '../utils/translator';
5
4
  import { applyDomRules } from '../utils/domRules';
6
- import { startMarkerEngine } from '../utils/markerEngine';
5
+ import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions, startMarkerEngine } from '../utils/markerEngine';
7
6
  import { logDebug, warnDebug, errorDebug } from '../utils/logger';
8
7
  import { LanguageSwitcher } from './LanguageSwitcher';
9
8
  import { NavigationOverlay } from './NavigationOverlay';
@@ -51,14 +50,12 @@ navigateRef, // For path mode routing
51
50
  const [isLoading, setIsLoading] = useState(false);
52
51
  const [isNavigationLoading, setIsNavigationLoading] = useState(false);
53
52
  const [editMode, setEditMode] = useState(initialEditMode);
54
- const translatorRef = useRef(new Translator());
55
53
  // Enhanced path normalization with supportedLocales for path mode
56
54
  const enhancedPathConfig = routing === 'path'
57
55
  ? { ...pathNormalization, supportedLocales: allLocales }
58
56
  : pathNormalization;
59
57
  const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
60
58
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
61
- const observerRef = useRef(null);
62
59
  const retryTimeoutRef = useRef(null);
63
60
  const isNavigatingRef = useRef(false);
64
61
  const isInternalNavigationRef = useRef(false);
@@ -276,8 +273,8 @@ navigateRef, // For path mode routing
276
273
  if (targetLocale === defaultLocale) {
277
274
  if (showOverlay)
278
275
  setIsNavigationLoading(false);
279
- translatorRef.current.setTranslations([]);
280
- translatorRef.current.restoreDOM(); // Safe to restore when going back to source language
276
+ setActiveTranslations(null);
277
+ restoreDom(document.body); // React-safe: only text/attrs, no DOM structure mutation
281
278
  isNavigatingRef.current = false;
282
279
  return;
283
280
  }
@@ -291,10 +288,10 @@ navigateRef, // For path mode routing
291
288
  if (cachedEntry && cachedExclusions) {
292
289
  // CACHE HIT - Use cached data immediately (FAST!)
293
290
  logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
294
- translatorRef.current.setTranslations(cachedEntry.translations);
295
- translatorRef.current.setExclusions(cachedExclusions);
291
+ setActiveTranslations(cachedEntry.translations);
292
+ setMarkerEngineExclusions(cachedExclusions);
296
293
  if (mode === 'dom') {
297
- translatorRef.current.translateDOM();
294
+ applyActiveTranslations(document.body);
298
295
  }
299
296
  if (autoApplyRules) {
300
297
  if (Array.isArray(cachedDomRules)) {
@@ -314,7 +311,7 @@ navigateRef, // For path mode routing
314
311
  }
315
312
  logDebug(`[Lovalingo] ๐Ÿ”„ Retry scan for late-rendering content`);
316
313
  if (mode === 'dom') {
317
- translatorRef.current.translateDOM();
314
+ applyActiveTranslations(document.body);
318
315
  }
319
316
  if (autoApplyRules) {
320
317
  const rules = domRulesCacheRef.current.get(cacheKey) || cachedDomRules || [];
@@ -356,10 +353,10 @@ navigateRef, // For path mode routing
356
353
  if (autoApplyRules) {
357
354
  domRulesCacheRef.current.set(cacheKey, domRules);
358
355
  }
359
- translatorRef.current.setTranslations(translations);
360
- translatorRef.current.setExclusions(exclusions);
356
+ setActiveTranslations(translations);
357
+ setMarkerEngineExclusions(exclusions);
361
358
  if (mode === 'dom') {
362
- translatorRef.current.translateDOM();
359
+ applyActiveTranslations(document.body);
363
360
  }
364
361
  if (autoApplyRules) {
365
362
  applyDomRules(domRules);
@@ -372,7 +369,7 @@ navigateRef, // For path mode routing
372
369
  }
373
370
  logDebug(`[Lovalingo] ๐Ÿ”„ Retry scan for late-rendering content`);
374
371
  if (mode === "dom") {
375
- translatorRef.current.translateDOM();
372
+ applyActiveTranslations(document.body);
376
373
  }
377
374
  if (autoApplyRules) {
378
375
  const rules = domRulesCacheRef.current.get(cacheKey) || domRules || [];
@@ -392,7 +389,47 @@ navigateRef, // For path mode routing
392
389
  setIsLoading(false);
393
390
  isNavigatingRef.current = false;
394
391
  }
395
- }, [defaultLocale]);
392
+ }, [autoApplyRules, defaultLocale, mode]);
393
+ // SPA router hook-in: track History API navigations (React Router/Next/etc) without app changes.
394
+ useEffect(() => {
395
+ const historyObj = window.history;
396
+ const originalPushState = historyObj.pushState.bind(historyObj);
397
+ const originalReplaceState = historyObj.replaceState.bind(historyObj);
398
+ const onNavigate = () => {
399
+ try {
400
+ apiRef.current.trackPageview(window.location.pathname + window.location.search);
401
+ }
402
+ catch {
403
+ // ignore
404
+ }
405
+ const nextLocale = detectLocale();
406
+ if (nextLocale !== locale) {
407
+ setLocaleState(nextLocale);
408
+ void loadData(nextLocale, locale, false);
409
+ }
410
+ else if (mode === "dom" && nextLocale !== defaultLocale) {
411
+ applyActiveTranslations(document.body);
412
+ }
413
+ };
414
+ historyObj.pushState = ((...args) => {
415
+ const ret = originalPushState(...args);
416
+ onNavigate();
417
+ return ret;
418
+ });
419
+ historyObj.replaceState = ((...args) => {
420
+ const ret = originalReplaceState(...args);
421
+ onNavigate();
422
+ return ret;
423
+ });
424
+ window.addEventListener("popstate", onNavigate);
425
+ window.addEventListener("hashchange", onNavigate);
426
+ return () => {
427
+ historyObj.pushState = originalPushState;
428
+ historyObj.replaceState = originalReplaceState;
429
+ window.removeEventListener("popstate", onNavigate);
430
+ window.removeEventListener("hashchange", onNavigate);
431
+ };
432
+ }, [defaultLocale, detectLocale, loadData, locale, mode]);
396
433
  // Change locale
397
434
  const setLocale = useCallback((newLocale) => {
398
435
  void (async () => {
@@ -729,50 +766,17 @@ navigateRef, // For path mode routing
729
766
  document.removeEventListener('click', onClickCapture, true);
730
767
  };
731
768
  }, [routing, autoPrefixLinks, allLocales, locale, navigateRef]);
732
- // Set up MutationObserver for dynamic content (DOM mode only)
733
- useEffect(() => {
734
- if (mode !== 'dom')
735
- return; // Skip for context mode
736
- if (locale === defaultLocale)
737
- return;
738
- const observer = new MutationObserver((mutations) => {
739
- // SKIP translation during navigation to prevent React conflicts
740
- if (isNavigatingRef.current) {
741
- return;
742
- }
743
- mutations.forEach((mutation) => {
744
- // Avoid feedback loops: ignore mutations caused by Lovalingo DOM updates.
745
- if (mutation.target instanceof HTMLElement) {
746
- if (mutation.target.closest('[data-Lovalingo-translating="1"]')) {
747
- return;
748
- }
749
- }
750
- mutation.addedNodes.forEach((node) => {
751
- if (node.nodeType === Node.ELEMENT_NODE) {
752
- const el = node;
753
- if (el.closest?.('[data-Lovalingo-translating="1"]'))
754
- return;
755
- translatorRef.current.translateElement(node);
756
- }
757
- });
758
- });
759
- });
760
- observer.observe(document.body, {
761
- childList: true,
762
- subtree: true,
763
- });
764
- observerRef.current = observer;
765
- return () => {
766
- observer.disconnect();
767
- observerRef.current = null;
768
- };
769
- }, [locale, defaultLocale, mode]);
769
+ // Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
770
770
  // No periodic string-miss reporting. Page discovery is tracked via pageview only.
771
771
  const translateElement = useCallback((element) => {
772
- translatorRef.current.translateElement(element);
772
+ if (mode !== "dom")
773
+ return;
774
+ applyActiveTranslations(element);
773
775
  }, []);
774
776
  const translateDOM = useCallback(() => {
775
- translatorRef.current.translateDOM();
777
+ if (mode !== "dom")
778
+ return;
779
+ applyActiveTranslations(document.body);
776
780
  }, []);
777
781
  const toggleEditMode = useCallback(() => {
778
782
  setEditMode(prev => !prev);
@@ -780,7 +784,7 @@ navigateRef, // For path mode routing
780
784
  const excludeElement = useCallback(async (selector) => {
781
785
  await apiRef.current.saveExclusion(selector, 'css');
782
786
  const exclusions = await apiRef.current.fetchExclusions();
783
- translatorRef.current.setExclusions(exclusions);
787
+ setMarkerEngineExclusions(exclusions);
784
788
  }, []);
785
789
  const contextValue = {
786
790
  locale,
@@ -1,3 +1,4 @@
1
+ import type { Exclusion, Translation } from "../types";
1
2
  type MarkerStats = {
2
3
  totalTextNodes: number;
3
4
  markedNodes: number;
@@ -15,7 +16,29 @@ type MarkerStats = {
15
16
  type MarkerEngineOptions = {
16
17
  throttleMs?: number;
17
18
  };
19
+ export type DomScanOccurrence = {
20
+ source_text: string;
21
+ semantic_context?: string;
22
+ };
23
+ export type DomScanSegment = {
24
+ kind: "text" | "title" | "aria-label" | "placeholder";
25
+ selector: string | null;
26
+ original: string | null;
27
+ current: string | null;
28
+ html: null;
29
+ };
30
+ export type DomScanResult = {
31
+ version: 1;
32
+ stats: MarkerStats;
33
+ segments: DomScanSegment[];
34
+ occurrences: DomScanOccurrence[];
35
+ truncated: boolean;
36
+ };
18
37
  export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
19
38
  export declare function stopMarkerEngine(): void;
20
39
  export declare function getMarkerStats(): MarkerStats;
40
+ export declare function setMarkerEngineExclusions(exclusions: Exclusion[] | null): void;
41
+ export declare function setActiveTranslations(translations: Translation[] | null): void;
42
+ export declare function applyActiveTranslations(root?: ParentNode | null): number;
43
+ export declare function restoreDom(root?: ParentNode | null): void;
21
44
  export {};
@@ -1,9 +1,7 @@
1
1
  import { hashContent } from "./hash";
2
2
  const DEFAULT_THROTTLE_MS = 150;
3
3
  const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
4
- const MARKER_SELECTOR = "[data-lovalingo-original]";
5
4
  const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
6
- const DIRECT_MARK_TAGS = new Set(["option", "textarea"]);
7
5
  const ATTRIBUTE_MARKS = [
8
6
  { attr: "title", marker: "data-lovalingo-title-original" },
9
7
  { attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
@@ -15,6 +13,11 @@ let scheduled = null;
15
13
  let running = false;
16
14
  let lastStats = buildEmptyStats();
17
15
  let throttleMs = DEFAULT_THROTTLE_MS;
16
+ let applying = false;
17
+ let customExcludeSelector = null;
18
+ let activeTranslationMap = null;
19
+ const originalTextByNode = new WeakMap();
20
+ const originalAttrByEl = new WeakMap();
18
21
  function buildEmptyStats() {
19
22
  return {
20
23
  totalTextNodes: 0,
@@ -37,11 +40,31 @@ function setGlobalStats(stats) {
37
40
  return;
38
41
  window.__lovalingoMarkersReady = true;
39
42
  window.__lovalingoMarkerStats = stats;
43
+ const g = window;
44
+ if (!g.__lovalingo)
45
+ g.__lovalingo = {};
46
+ if (!g.__lovalingo.dom)
47
+ g.__lovalingo.dom = {};
48
+ g.__lovalingo.dom.getStats = () => lastStats;
49
+ g.__lovalingo.dom.scan = () => scanDom({ maxSegments: 20000 });
50
+ g.__lovalingo.dom.apply = (bundle) => ({ applied: applyTranslationMap(bundle, document.body) });
51
+ g.__lovalingo.dom.restore = () => restoreDom(document.body);
40
52
  }
41
53
  function isExcludedElement(el) {
42
54
  if (!el)
43
55
  return false;
44
- return Boolean(el.closest(EXCLUDE_SELECTOR));
56
+ if (el.closest(EXCLUDE_SELECTOR))
57
+ return true;
58
+ if (customExcludeSelector) {
59
+ try {
60
+ if (el.closest(customExcludeSelector))
61
+ return true;
62
+ }
63
+ catch {
64
+ // ignore invalid selector strings
65
+ }
66
+ }
67
+ return false;
45
68
  }
46
69
  function findUnsafeContainer(el) {
47
70
  if (!el)
@@ -85,6 +108,9 @@ function buildElementPath(el) {
85
108
  parts.push("body");
86
109
  return parts.reverse().join("/");
87
110
  }
111
+ function normalizeWhitespace(value) {
112
+ return (value || "").toString().replace(/\s+/g, " ").trim();
113
+ }
88
114
  function isTranslatableText(text) {
89
115
  if (!text || text.trim().length < 2)
90
116
  return false;
@@ -102,18 +128,51 @@ function buildStableId(el, text, textIndex) {
102
128
  const raw = `${path}#text[${textIndex}]|${text.trim()}|${key}`;
103
129
  return hashContent(raw);
104
130
  }
105
- function markElementDirect(el, rawText, stats) {
106
- if (!el.hasAttribute("data-lovalingo-original")) {
107
- el.setAttribute("data-lovalingo-original", rawText.trim());
131
+ function buildSelector(el) {
132
+ const id = el.id;
133
+ if (id)
134
+ return `#${id.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`;
135
+ const className = el.className;
136
+ if (typeof className === "string" && className.trim()) {
137
+ const classes = className
138
+ .split(/\s+/)
139
+ .map((c) => c.trim())
140
+ .filter(Boolean)
141
+ .slice(0, 3)
142
+ .map((c) => `.${c.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}`)
143
+ .join("");
144
+ if (classes)
145
+ return classes;
108
146
  }
109
- if (!el.hasAttribute("data-lovalingo-id")) {
110
- el.setAttribute("data-lovalingo-id", buildStableId(el, rawText, 0));
147
+ return null;
148
+ }
149
+ function getOrInitTextOriginal(node, parent) {
150
+ const existing = originalTextByNode.get(node);
151
+ if (existing)
152
+ return existing;
153
+ const raw = node.nodeValue || "";
154
+ const leading = raw.match(/^\s*/)?.[0] ?? "";
155
+ const trailing = raw.match(/\s*$/)?.[0] ?? "";
156
+ const trimmed = raw.trim();
157
+ const id = buildStableId(parent, trimmed, getTextNodeIndex(node));
158
+ const created = { raw, trimmed, leading, trailing, id };
159
+ originalTextByNode.set(node, created);
160
+ return created;
161
+ }
162
+ function getOrInitAttrOriginal(el, attr) {
163
+ let map = originalAttrByEl.get(el);
164
+ if (!map) {
165
+ map = new Map();
166
+ originalAttrByEl.set(el, map);
111
167
  }
112
- el.setAttribute("data-lovalingo-kind", "text");
113
- stats.markedNodes += 1;
114
- stats.markedChars += rawText.length;
168
+ const existing = map.get(attr);
169
+ if (existing != null)
170
+ return existing;
171
+ const value = (el.getAttribute(attr) || "").toString();
172
+ map.set(attr, value);
173
+ return value;
115
174
  }
116
- function markTextNode(node, stats) {
175
+ function considerTextNode(node, stats, segments, occurrences, seen, maxSegments) {
117
176
  const raw = node.nodeValue || "";
118
177
  if (!raw)
119
178
  return;
@@ -134,9 +193,6 @@ function markTextNode(node, stats) {
134
193
  if (unsafe) {
135
194
  stats.skippedUnsafeNodes += 1;
136
195
  stats.skippedUnsafeChars += raw.length;
137
- if (!unsafe.hasAttribute("data-lovalingo-unsafe")) {
138
- unsafe.setAttribute("data-lovalingo-unsafe", unsafe.tagName.toLowerCase());
139
- }
140
196
  return;
141
197
  }
142
198
  if (!isTranslatableText(trimmed)) {
@@ -144,53 +200,55 @@ function markTextNode(node, stats) {
144
200
  stats.skippedNonTranslatableChars += raw.length;
145
201
  return;
146
202
  }
147
- const existingMarker = parent.closest(MARKER_SELECTOR);
148
- if (existingMarker) {
149
- if (!existingMarker.getAttribute("data-lovalingo-id")) {
150
- const textIndex = getTextNodeIndex(node);
151
- existingMarker.setAttribute("data-lovalingo-id", buildStableId(parent, raw, textIndex));
152
- }
153
- stats.markedNodes += 1;
154
- stats.markedChars += raw.length;
155
- return;
156
- }
157
- const parentTag = parent.tagName.toLowerCase();
158
- if (DIRECT_MARK_TAGS.has(parentTag)) {
159
- markElementDirect(parent, raw, stats);
160
- return;
161
- }
162
- const wrapper = document.createElement("span");
163
- wrapper.setAttribute("data-lovalingo-original", trimmed);
164
- wrapper.setAttribute("data-lovalingo-id", buildStableId(parent, raw, getTextNodeIndex(node)));
165
- wrapper.setAttribute("data-lovalingo-kind", "text");
166
- wrapper.setAttribute("data-lovalingo-marker", "1");
167
- wrapper.textContent = raw;
168
- try {
169
- parent.replaceChild(wrapper, node);
170
- }
171
- catch {
172
- return;
173
- }
203
+ const original = getOrInitTextOriginal(node, parent);
174
204
  stats.markedNodes += 1;
175
205
  stats.markedChars += raw.length;
206
+ if (segments.length < maxSegments) {
207
+ const originalText = normalizeWhitespace(original.trimmed) || null;
208
+ const currentText = normalizeWhitespace(node.nodeValue || "") || null;
209
+ segments.push({
210
+ kind: "text",
211
+ selector: buildSelector(parent),
212
+ original: originalText,
213
+ current: currentText,
214
+ html: null,
215
+ });
216
+ if (originalText && !seen.has(originalText)) {
217
+ seen.add(originalText);
218
+ occurrences.push({ source_text: originalText, semantic_context: "text" });
219
+ }
220
+ }
176
221
  }
177
- function markAttributes(root) {
222
+ function considerAttributes(root, segments, occurrences, seen, maxSegments) {
178
223
  const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
179
224
  nodes.forEach((el) => {
180
225
  if (isExcludedElement(el))
181
226
  return;
182
227
  if (findUnsafeContainer(el))
183
228
  return;
184
- for (const { attr, marker } of ATTRIBUTE_MARKS) {
185
- if (el.hasAttribute(marker))
186
- continue;
229
+ for (const { attr } of ATTRIBUTE_MARKS) {
187
230
  const value = el.getAttribute(attr);
188
231
  if (!value)
189
232
  continue;
190
233
  const trimmed = value.trim();
191
234
  if (!trimmed || !isTranslatableText(trimmed))
192
235
  continue;
193
- el.setAttribute(marker, trimmed);
236
+ const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr)) || null;
237
+ const current = normalizeWhitespace(el.getAttribute(attr) || "") || null;
238
+ const kind = (attr === "title" ? "title" : attr === "aria-label" ? "aria-label" : "placeholder");
239
+ if (segments.length < maxSegments) {
240
+ segments.push({
241
+ kind,
242
+ selector: buildSelector(el),
243
+ original,
244
+ current,
245
+ html: null,
246
+ });
247
+ }
248
+ if (original && !seen.has(original)) {
249
+ seen.add(original);
250
+ occurrences.push({ source_text: original, semantic_context: `attr:${attr}` });
251
+ }
194
252
  }
195
253
  });
196
254
  }
@@ -206,27 +264,38 @@ function finalizeStats(stats) {
206
264
  stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
207
265
  stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
208
266
  }
209
- function scanAndMark() {
210
- if (!running)
211
- return;
267
+ function scanDom(opts) {
212
268
  const root = document.body;
213
269
  if (!root) {
214
- setGlobalStats(buildEmptyStats());
215
- return;
270
+ const empty = buildEmptyStats();
271
+ setGlobalStats(empty);
272
+ return { version: 1, stats: empty, segments: [], occurrences: [], truncated: false };
216
273
  }
217
274
  const stats = buildEmptyStats();
275
+ const maxSegments = Math.max(0, Math.floor(opts.maxSegments || 0)) || 20000;
218
276
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
219
277
  const nodes = [];
278
+ const segments = [];
279
+ const occurrences = [];
280
+ const seen = new Set();
220
281
  let node = walker.nextNode();
221
282
  while (node) {
222
283
  if (node.nodeType === Node.TEXT_NODE)
223
284
  nodes.push(node);
224
285
  node = walker.nextNode();
225
286
  }
226
- nodes.forEach((textNode) => markTextNode(textNode, stats));
227
- markAttributes(root);
287
+ nodes.forEach((textNode) => considerTextNode(textNode, stats, segments, occurrences, seen, maxSegments));
288
+ considerAttributes(root, segments, occurrences, seen, maxSegments);
228
289
  finalizeStats(stats);
229
290
  setGlobalStats(stats);
291
+ const truncated = segments.length >= maxSegments;
292
+ return {
293
+ version: 1,
294
+ stats,
295
+ segments,
296
+ occurrences,
297
+ truncated,
298
+ };
230
299
  }
231
300
  function scheduleScan() {
232
301
  if (!running)
@@ -235,7 +304,16 @@ function scheduleScan() {
235
304
  return;
236
305
  scheduled = window.setTimeout(() => {
237
306
  scheduled = null;
238
- scanAndMark();
307
+ try {
308
+ scanDom({ maxSegments: 20000 });
309
+ if (activeTranslationMap) {
310
+ applying = true;
311
+ applyActiveTranslations(document.body);
312
+ }
313
+ }
314
+ finally {
315
+ applying = false;
316
+ }
239
317
  }, throttleMs);
240
318
  }
241
319
  export function startMarkerEngine(options = {}) {
@@ -252,13 +330,17 @@ export function startMarkerEngine(options = {}) {
252
330
  window.setTimeout(startObserver, 50);
253
331
  return;
254
332
  }
255
- observer = new MutationObserver(() => scheduleScan());
333
+ observer = new MutationObserver(() => {
334
+ if (applying)
335
+ return;
336
+ scheduleScan();
337
+ });
256
338
  observer.observe(document.body, {
257
339
  childList: true,
258
340
  subtree: true,
259
341
  characterData: true,
260
342
  });
261
- scanAndMark();
343
+ scanDom({ maxSegments: 20000 });
262
344
  };
263
345
  startObserver();
264
346
  return stopMarkerEngine;
@@ -277,3 +359,158 @@ export function stopMarkerEngine() {
277
359
  export function getMarkerStats() {
278
360
  return lastStats;
279
361
  }
362
+ export function setMarkerEngineExclusions(exclusions) {
363
+ if (!exclusions || exclusions.length === 0) {
364
+ customExcludeSelector = null;
365
+ return;
366
+ }
367
+ const selectors = exclusions
368
+ .filter((e) => e && e.type === "css" && typeof e.selector === "string" && e.selector.trim())
369
+ .map((e) => e.selector.trim());
370
+ customExcludeSelector = selectors.length ? selectors.join(",") : null;
371
+ }
372
+ export function setActiveTranslations(translations) {
373
+ if (!translations || translations.length === 0) {
374
+ activeTranslationMap = null;
375
+ return;
376
+ }
377
+ const map = new Map();
378
+ for (const t of translations) {
379
+ const source = (t?.source_text || "").toString().trim();
380
+ const translated = (t?.translated_text ?? "").toString();
381
+ if (!source || !translated)
382
+ continue;
383
+ map.set(source, translated);
384
+ }
385
+ activeTranslationMap = map;
386
+ }
387
+ function applyTranslationMap(bundle, root) {
388
+ if (!root)
389
+ return 0;
390
+ const map = new Map();
391
+ for (const [k, v] of Object.entries(bundle || {})) {
392
+ const source = (k || "").toString().trim();
393
+ const translated = (v ?? "").toString();
394
+ if (!source || !translated)
395
+ continue;
396
+ map.set(source, translated);
397
+ }
398
+ activeTranslationMap = map;
399
+ return applyActiveTranslations(root);
400
+ }
401
+ export function applyActiveTranslations(root = document.body) {
402
+ if (!root || !activeTranslationMap || activeTranslationMap.size === 0)
403
+ return 0;
404
+ const map = activeTranslationMap;
405
+ let applied = 0;
406
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
407
+ const nodes = [];
408
+ let node = walk.nextNode();
409
+ while (node) {
410
+ if (node.nodeType === Node.TEXT_NODE)
411
+ nodes.push(node);
412
+ node = walk.nextNode();
413
+ }
414
+ for (const textNode of nodes) {
415
+ const parent = textNode.parentElement;
416
+ if (!parent)
417
+ continue;
418
+ const raw = textNode.nodeValue || "";
419
+ const trimmed = raw.trim();
420
+ if (!trimmed)
421
+ continue;
422
+ if (isExcludedElement(parent))
423
+ continue;
424
+ if (findUnsafeContainer(parent))
425
+ continue;
426
+ if (!isTranslatableText(trimmed))
427
+ continue;
428
+ const original = getOrInitTextOriginal(textNode, parent);
429
+ const translation = map.get(original.trimmed);
430
+ if (!translation)
431
+ continue;
432
+ const next = `${original.leading}${translation}${original.trailing}`;
433
+ if (textNode.nodeValue === next)
434
+ continue;
435
+ try {
436
+ textNode.nodeValue = next;
437
+ applied += 1;
438
+ }
439
+ catch {
440
+ // ignore
441
+ }
442
+ }
443
+ if (root instanceof HTMLElement) {
444
+ const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
445
+ elements.forEach((el) => {
446
+ if (isExcludedElement(el))
447
+ return;
448
+ if (findUnsafeContainer(el))
449
+ return;
450
+ for (const { attr } of ATTRIBUTE_MARKS) {
451
+ const current = el.getAttribute(attr);
452
+ if (!current)
453
+ continue;
454
+ const trimmed = current.trim();
455
+ if (!trimmed || !isTranslatableText(trimmed))
456
+ continue;
457
+ const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
458
+ const translation = map.get(original);
459
+ if (!translation)
460
+ continue;
461
+ if (el.getAttribute(attr) === translation)
462
+ continue;
463
+ try {
464
+ el.setAttribute(attr, translation);
465
+ applied += 1;
466
+ }
467
+ catch {
468
+ // ignore
469
+ }
470
+ }
471
+ });
472
+ }
473
+ return applied;
474
+ }
475
+ export function restoreDom(root = document.body) {
476
+ if (!root)
477
+ return;
478
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
479
+ let node = walk.nextNode();
480
+ while (node) {
481
+ if (node.nodeType === Node.TEXT_NODE) {
482
+ const textNode = node;
483
+ const original = originalTextByNode.get(textNode);
484
+ if (original && textNode.nodeValue !== original.raw) {
485
+ try {
486
+ textNode.nodeValue = original.raw;
487
+ }
488
+ catch {
489
+ // ignore
490
+ }
491
+ }
492
+ }
493
+ node = walk.nextNode();
494
+ }
495
+ if (root instanceof HTMLElement) {
496
+ const elements = root.querySelectorAll("[title],[aria-label],[placeholder]");
497
+ elements.forEach((el) => {
498
+ const originals = originalAttrByEl.get(el);
499
+ if (!originals)
500
+ return;
501
+ for (const { attr } of ATTRIBUTE_MARKS) {
502
+ const original = originals.get(attr);
503
+ if (original == null)
504
+ continue;
505
+ if (el.getAttribute(attr) === original)
506
+ continue;
507
+ try {
508
+ el.setAttribute(attr, original);
509
+ }
510
+ catch {
511
+ // ignore
512
+ }
513
+ }
514
+ });
515
+ }
516
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.2";
1
+ export declare const VERSION = "0.2.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.2";
1
+ export const VERSION = "0.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",