@lovalingo/lovalingo 0.5.8 → 0.5.10

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.
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { LovalingoContext } from '../context/LovalingoContext';
3
3
  import { LangRoutingContext } from '../context/LangRoutingContext';
4
4
  import { LovalingoAPI } from '../utils/api';
@@ -11,10 +11,13 @@ import { useNavigationPrefetch } from '../hooks/provider/useNavigationPrefetch';
11
11
  import { useLinkAutoPrefix } from '../hooks/provider/useLinkAutoPrefix';
12
12
  import { usePageviewTracking } from '../hooks/provider/usePageviewTracking';
13
13
  import { useSitemapLinkTag } from '../hooks/provider/useSitemapLinkTag';
14
+ import { useStringMissReporting } from '../hooks/provider/useStringMissReporting';
14
15
  import { LanguageSwitcher } from './LanguageSwitcher';
15
16
  const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
16
17
  const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
17
18
  const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
19
+ // Why: run initial load before first paint on the client to avoid a prehide flash; useEffect on SSR.
20
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
18
21
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
19
22
  autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
20
23
  mode = 'dom', // Default to legacy DOM mode for backward compatibility
@@ -524,7 +527,7 @@ navigateRef, // For path mode routing
524
527
  detectLocaleRef.current = detectLocale;
525
528
  }, [detectLocale]);
526
529
  // Initialize
527
- useEffect(() => {
530
+ useIsomorphicLayoutEffect(() => {
528
531
  const initialLocale = detectLocaleRef.current();
529
532
  lastNormalizedPathRef.current = processPath(window.location.pathname, enhancedPathConfig);
530
533
  // Track initial page (fallback discovery for pages not present in the routes feed).
@@ -561,6 +564,17 @@ navigateRef, // For path mode routing
561
564
  allLocales,
562
565
  enhancedPathConfig,
563
566
  });
567
+ useStringMissReporting({
568
+ apiRef,
569
+ resolvedApiKey,
570
+ locale,
571
+ defaultLocale,
572
+ routing,
573
+ allLocales,
574
+ nonLocalizedPaths: routingConfig.nonLocalizedPaths,
575
+ isLoading,
576
+ mode,
577
+ });
564
578
  // Dynamic DOM updates are handled by the marker engine observer (React-safe in-place text/attr updates).
565
579
  // No periodic string-miss reporting. Page discovery is tracked via pageview only.
566
580
  const translateElement = useCallback((element) => {
@@ -0,0 +1,14 @@
1
+ import type { MutableRefObject } from "react";
2
+ import type { LovalingoAPI } from "../../utils/api";
3
+ import { type NonLocalizedPathRule } from "../../utils/nonLocalizedPaths";
4
+ export declare function useStringMissReporting(args: {
5
+ apiRef: MutableRefObject<LovalingoAPI>;
6
+ resolvedApiKey: string;
7
+ locale: string;
8
+ defaultLocale: string;
9
+ routing: "path" | "query";
10
+ allLocales: string[];
11
+ nonLocalizedPaths: NonLocalizedPathRule[];
12
+ isLoading: boolean;
13
+ mode: "dom";
14
+ }): void;
@@ -0,0 +1,150 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { addActiveTranslations, applyActiveTranslations, clearMissBlur, scanDomForMisses } from "../../utils/markerEngine";
3
+ import { isNonLocalizedPath, stripLocalePrefix } from "../../utils/nonLocalizedPaths";
4
+ // Why: throttle miss scans so we stay responsive on DOM-heavy pages.
5
+ const MISS_SCAN_THROTTLE_MS = 600;
6
+ // Why: allow large pages to report plenty of misses while keeping payloads bounded.
7
+ const MISS_MAX_PER_PAGE = 500;
8
+ export function useStringMissReporting(args) {
9
+ const pendingRef = useRef(new Set());
10
+ const seenRef = useRef(new Set());
11
+ const blurredRef = useRef(new Map());
12
+ const scheduledRef = useRef(null);
13
+ const inFlightRef = useRef(false);
14
+ const mergeBlurred = useCallback((incoming) => {
15
+ for (const [text, elements] of incoming.entries()) {
16
+ let set = blurredRef.current.get(text);
17
+ if (!set) {
18
+ set = new Set();
19
+ blurredRef.current.set(text, set);
20
+ }
21
+ elements.forEach((el) => set.add(el));
22
+ }
23
+ }, []);
24
+ const clearBlurForText = useCallback((text) => {
25
+ const elements = blurredRef.current.get(text);
26
+ if (!elements)
27
+ return;
28
+ clearMissBlur(elements);
29
+ blurredRef.current.delete(text);
30
+ }, []);
31
+ useEffect(() => {
32
+ pendingRef.current.clear();
33
+ seenRef.current.clear();
34
+ for (const elements of blurredRef.current.values()) {
35
+ clearMissBlur(elements);
36
+ }
37
+ blurredRef.current.clear();
38
+ }, [args.locale]);
39
+ const shouldSkip = useCallback(() => {
40
+ if (typeof window === "undefined" || typeof document === "undefined")
41
+ return true;
42
+ if (!args.resolvedApiKey || args.mode !== "dom")
43
+ return true;
44
+ if (args.isLoading)
45
+ return true;
46
+ if (args.locale === args.defaultLocale)
47
+ return true;
48
+ if (args.routing === "path") {
49
+ const stripped = stripLocalePrefix(window.location.pathname, args.allLocales);
50
+ if (isNonLocalizedPath(stripped, args.nonLocalizedPaths))
51
+ return true;
52
+ }
53
+ return false;
54
+ }, [args.allLocales, args.defaultLocale, args.isLoading, args.locale, args.mode, args.nonLocalizedPaths, args.resolvedApiKey, args.routing]);
55
+ const runScan = useCallback(async () => {
56
+ scheduledRef.current = null;
57
+ if (shouldSkip())
58
+ return;
59
+ if (!document.body)
60
+ return;
61
+ if (inFlightRef.current)
62
+ return;
63
+ const ignore = new Set([...pendingRef.current, ...seenRef.current]);
64
+ const { misses, blurred } = scanDomForMisses({ max: MISS_MAX_PER_PAGE, ignore, blur: true });
65
+ if (misses.length === 0)
66
+ return;
67
+ mergeBlurred(blurred);
68
+ misses.forEach((miss) => pendingRef.current.add(miss.source_text));
69
+ inFlightRef.current = true;
70
+ try {
71
+ const response = await args.apiRef.current.reportStringMisses(args.locale, misses, {
72
+ sourceLocale: args.defaultLocale,
73
+ locales: args.allLocales,
74
+ });
75
+ if (response?.ignored) {
76
+ for (const miss of misses) {
77
+ pendingRef.current.delete(miss.source_text);
78
+ clearBlurForText(miss.source_text);
79
+ seenRef.current.add(miss.source_text);
80
+ }
81
+ return;
82
+ }
83
+ const translations = Array.isArray(response?.translations) ? response.translations : [];
84
+ const pii = Array.isArray(response?.pii) ? response.pii : [];
85
+ const resolved = new Set();
86
+ pii.forEach((text) => resolved.add(text));
87
+ translations.forEach((row) => {
88
+ if (row?.source_text)
89
+ resolved.add(row.source_text);
90
+ });
91
+ if (translations.length > 0) {
92
+ const additions = translations
93
+ .map((row) => ({
94
+ source_text: row.source_text,
95
+ translated_text: row.translated_text,
96
+ source_locale: args.defaultLocale,
97
+ target_locale: args.locale,
98
+ }))
99
+ .filter((row) => row.source_text && row.translated_text);
100
+ if (additions.length > 0) {
101
+ addActiveTranslations(additions);
102
+ applyActiveTranslations(document.body);
103
+ }
104
+ }
105
+ for (const miss of misses) {
106
+ pendingRef.current.delete(miss.source_text);
107
+ clearBlurForText(miss.source_text);
108
+ if (resolved.has(miss.source_text)) {
109
+ seenRef.current.add(miss.source_text);
110
+ }
111
+ }
112
+ }
113
+ catch {
114
+ for (const miss of misses) {
115
+ pendingRef.current.delete(miss.source_text);
116
+ clearBlurForText(miss.source_text);
117
+ }
118
+ }
119
+ finally {
120
+ inFlightRef.current = false;
121
+ }
122
+ }, [args.apiRef, args.defaultLocale, args.locale, clearBlurForText, mergeBlurred, shouldSkip]);
123
+ const scheduleScan = useCallback(() => {
124
+ if (scheduledRef.current != null)
125
+ return;
126
+ scheduledRef.current = window.setTimeout(() => {
127
+ void runScan();
128
+ }, MISS_SCAN_THROTTLE_MS);
129
+ }, [runScan]);
130
+ useEffect(() => {
131
+ if (shouldSkip())
132
+ return;
133
+ scheduleScan();
134
+ if (!document.body)
135
+ return;
136
+ const observer = new MutationObserver(() => scheduleScan());
137
+ observer.observe(document.body, {
138
+ childList: true,
139
+ subtree: true,
140
+ characterData: true,
141
+ });
142
+ return () => {
143
+ observer.disconnect();
144
+ if (scheduledRef.current != null) {
145
+ window.clearTimeout(scheduledRef.current);
146
+ scheduledRef.current = null;
147
+ }
148
+ };
149
+ }, [scheduleScan, shouldSkip]);
150
+ }
@@ -59,6 +59,24 @@ export type BootstrapResponse = {
59
59
  };
60
60
  etag?: string;
61
61
  };
62
+ export type MissReportItem = {
63
+ source_text: string;
64
+ semantic_context?: string | null;
65
+ };
66
+ export type MissReportResponse = {
67
+ translations?: Array<{
68
+ source_text: string;
69
+ translated_text: string;
70
+ }>;
71
+ pii?: string[];
72
+ ignored?: boolean;
73
+ reason?: string;
74
+ };
75
+ export type MissReportOptions = {
76
+ pathOrUrl?: string;
77
+ sourceLocale?: string;
78
+ locales?: string[];
79
+ };
62
80
  export declare class LovalingoAPI {
63
81
  private apiKey;
64
82
  private apiBase;
@@ -78,6 +96,7 @@ export declare class LovalingoAPI {
78
96
  critical_count?: number;
79
97
  critical_hash?: string;
80
98
  }): Promise<void>;
99
+ reportStringMisses(targetLocale: string, misses: MissReportItem[], opts?: MissReportOptions): Promise<MissReportResponse | null>;
81
100
  fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
82
101
  fetchBundle(localeHint: string, pathOrUrl?: string): Promise<{
83
102
  map: Record<string, string>;
package/dist/utils/api.js CHANGED
@@ -143,6 +143,42 @@ export class LovalingoAPI {
143
143
  // Ignore tracking errors.
144
144
  }
145
145
  }
146
+ async reportStringMisses(targetLocale, misses, opts) {
147
+ try {
148
+ if (!this.hasApiKey())
149
+ return null;
150
+ if (!Array.isArray(misses) || misses.length === 0)
151
+ return null;
152
+ const pathParam = this.buildPathParam(opts?.pathOrUrl);
153
+ const response = await fetch(`${this.apiBase}/functions/v1/misses`, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({
157
+ key: this.apiKey,
158
+ locale: targetLocale,
159
+ path: pathParam,
160
+ source_locale: opts?.sourceLocale,
161
+ locales: Array.isArray(opts?.locales) ? opts?.locales : undefined,
162
+ misses,
163
+ }),
164
+ });
165
+ if (this.isActivationRequiredResponse(response)) {
166
+ this.logActivationRequired("reportStringMisses", response);
167
+ return null;
168
+ }
169
+ if (!response.ok)
170
+ return null;
171
+ const data = await response.json();
172
+ if (this.isActivationRequiredResponse(response, data)) {
173
+ this.logActivationRequired("reportStringMisses", response);
174
+ return null;
175
+ }
176
+ return data;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
146
182
  async fetchTranslations(sourceLocale, targetLocale) {
147
183
  try {
148
184
  if (!this.hasApiKey()) {
@@ -39,6 +39,14 @@ export type DomScanResult = {
39
39
  };
40
40
  truncated: boolean;
41
41
  };
42
+ export type DomMiss = {
43
+ source_text: string;
44
+ semantic_context: string;
45
+ };
46
+ export type DomMissScanResult = {
47
+ misses: DomMiss[];
48
+ blurred: Map<string, Set<HTMLElement>>;
49
+ };
42
50
  export type CriticalFingerprint = {
43
51
  critical_count: number;
44
52
  critical_hash: string;
@@ -53,6 +61,13 @@ export declare function stopMarkerEngine(): void;
53
61
  export declare function getMarkerStats(): MarkerStats;
54
62
  export declare function setMarkerEngineExclusions(exclusions: Exclusion[] | null): void;
55
63
  export declare function setActiveTranslations(translations: Translation[] | null): void;
64
+ export declare function addActiveTranslations(translations: Translation[] | Record<string, string> | null): number;
56
65
  export declare function applyActiveTranslations(root?: ParentNode | null): number;
66
+ export declare function clearMissBlur(elements: Iterable<HTMLElement>): void;
67
+ export declare function scanDomForMisses(opts: {
68
+ max: number;
69
+ ignore?: Set<string>;
70
+ blur?: boolean;
71
+ }): DomMissScanResult;
57
72
  export declare function restoreDom(root?: ParentNode | null): void;
58
73
  export {};
@@ -10,6 +10,8 @@ const ATTRIBUTE_MARKS = [
10
10
  { attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
11
11
  { attr: "placeholder", marker: "data-lovalingo-placeholder-original" },
12
12
  ];
13
+ const MISS_BLUR_ATTR = "data-lovalingo-miss-blur";
14
+ const MISS_BLUR_STYLE_ID = "lovalingo-miss-blur-style";
13
15
  const unsafeSelector = Array.from(UNSAFE_CONTAINER_TAGS).join(",");
14
16
  let observer = null;
15
17
  let scheduled = null;
@@ -176,6 +178,31 @@ function getOrInitAttrOriginal(el, attr) {
176
178
  map.set(attr, value);
177
179
  return value;
178
180
  }
181
+ // Why: avoid blurring large containers; keep the effect scoped to leaf text nodes.
182
+ function shouldBlurElement(el) {
183
+ if (!el)
184
+ return false;
185
+ if (el.tagName === "HTML" || el.tagName === "BODY")
186
+ return false;
187
+ if (el.childElementCount > 0)
188
+ return false;
189
+ return true;
190
+ }
191
+ function ensureMissBlurStyles() {
192
+ if (typeof document === "undefined")
193
+ return;
194
+ if (document.getElementById(MISS_BLUR_STYLE_ID))
195
+ return;
196
+ const style = document.createElement("style");
197
+ style.id = MISS_BLUR_STYLE_ID;
198
+ style.textContent =
199
+ `[${MISS_BLUR_ATTR}="1"] {` +
200
+ "filter: blur(2px);" +
201
+ "transition: filter 0.18s ease-out;" +
202
+ "will-change: filter;" +
203
+ "}";
204
+ document.head.appendChild(style);
205
+ }
179
206
  function isInViewport(rect, viewportHeight, bufferPx) {
180
207
  if (!rect)
181
208
  return false;
@@ -552,6 +579,38 @@ export function setActiveTranslations(translations) {
552
579
  }
553
580
  activeTranslationMap = map;
554
581
  }
582
+ export function addActiveTranslations(translations) {
583
+ if (!translations)
584
+ return 0;
585
+ const map = activeTranslationMap ?? new Map();
586
+ let added = 0;
587
+ if (Array.isArray(translations)) {
588
+ for (const t of translations) {
589
+ const source = normalizeWhitespace((t?.source_text || "").toString());
590
+ const translated = (t?.translated_text ?? "").toString();
591
+ if (!source || !translated)
592
+ continue;
593
+ if (map.get(source) === translated)
594
+ continue;
595
+ map.set(source, translated);
596
+ added += 1;
597
+ }
598
+ }
599
+ else {
600
+ for (const [keyRaw, valueRaw] of Object.entries(translations || {})) {
601
+ const source = normalizeWhitespace((keyRaw || "").toString());
602
+ const translated = (valueRaw ?? "").toString();
603
+ if (!source || !translated)
604
+ continue;
605
+ if (map.get(source) === translated)
606
+ continue;
607
+ map.set(source, translated);
608
+ added += 1;
609
+ }
610
+ }
611
+ activeTranslationMap = map;
612
+ return added;
613
+ }
555
614
  function applyTranslationMap(bundle, root) {
556
615
  if (!root)
557
616
  return 0;
@@ -641,6 +700,100 @@ export function applyActiveTranslations(root = document.body) {
641
700
  }
642
701
  return applied;
643
702
  }
703
+ export function clearMissBlur(elements) {
704
+ for (const el of elements) {
705
+ if (!el)
706
+ continue;
707
+ if (el.getAttribute(MISS_BLUR_ATTR) !== "1")
708
+ continue;
709
+ el.removeAttribute(MISS_BLUR_ATTR);
710
+ }
711
+ }
712
+ export function scanDomForMisses(opts) {
713
+ const root = document.body;
714
+ const misses = [];
715
+ const blurred = new Map();
716
+ if (!root || !activeTranslationMap || activeTranslationMap.size === 0) {
717
+ return { misses, blurred };
718
+ }
719
+ const max = Math.max(0, Math.floor(opts.max || 0));
720
+ if (max <= 0)
721
+ return { misses, blurred };
722
+ if (opts.blur)
723
+ ensureMissBlurStyles();
724
+ const ignore = opts.ignore || new Set();
725
+ const seen = new Set();
726
+ const recordMiss = (text, context, el) => {
727
+ if (!text || seen.has(text) || ignore.has(text))
728
+ return;
729
+ if (activeTranslationMap?.has(text))
730
+ return;
731
+ if (misses.length >= max)
732
+ return;
733
+ seen.add(text);
734
+ misses.push({ source_text: text, semantic_context: context });
735
+ if (opts.blur && el instanceof HTMLElement && shouldBlurElement(el)) {
736
+ el.setAttribute(MISS_BLUR_ATTR, "1");
737
+ let set = blurred.get(text);
738
+ if (!set) {
739
+ set = new Set();
740
+ blurred.set(text, set);
741
+ }
742
+ set.add(el);
743
+ }
744
+ };
745
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
746
+ let node = walker.nextNode();
747
+ while (node && misses.length < max) {
748
+ if (node.nodeType !== Node.TEXT_NODE) {
749
+ node = walker.nextNode();
750
+ continue;
751
+ }
752
+ const textNode = node;
753
+ const parent = textNode.parentElement;
754
+ if (!parent || isExcludedElement(parent) || findUnsafeContainer(parent)) {
755
+ node = walker.nextNode();
756
+ continue;
757
+ }
758
+ const raw = textNode.nodeValue || "";
759
+ const trimmed = raw.trim();
760
+ if (!trimmed || !isTranslatableText(trimmed)) {
761
+ node = walker.nextNode();
762
+ continue;
763
+ }
764
+ const original = getOrInitTextOriginal(textNode, parent);
765
+ const key = normalizeWhitespace(original.trimmed);
766
+ if (key) {
767
+ recordMiss(key, "text", parent);
768
+ }
769
+ node = walker.nextNode();
770
+ }
771
+ if (misses.length < max) {
772
+ const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
773
+ nodes.forEach((el) => {
774
+ if (misses.length >= max)
775
+ return;
776
+ if (isExcludedElement(el) || findUnsafeContainer(el))
777
+ return;
778
+ for (const { attr } of ATTRIBUTE_MARKS) {
779
+ if (misses.length >= max)
780
+ break;
781
+ const value = el.getAttribute(attr);
782
+ if (!value)
783
+ continue;
784
+ const trimmed = value.trim();
785
+ if (!trimmed || !isTranslatableText(trimmed))
786
+ continue;
787
+ const original = normalizeWhitespace(getOrInitAttrOriginal(el, attr));
788
+ if (!original)
789
+ continue;
790
+ const context = attr === "title" ? "attr:title" : attr === "aria-label" ? "attr:aria-label" : "attr:placeholder";
791
+ recordMiss(original, context);
792
+ }
793
+ });
794
+ }
795
+ return { misses, blurred };
796
+ }
644
797
  export function restoreDom(root = document.body) {
645
798
  if (!root)
646
799
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
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",