@lovalingo/lovalingo 0.5.16 → 0.5.17

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.
@@ -16,9 +16,73 @@ import { LanguageSwitcher } from './LanguageSwitcher';
16
16
  const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
17
17
  const LOADING_BG_STORAGE_PREFIX = "Lovalingo_loading_bg_color";
18
18
  const BRANDING_STORAGE_PREFIX = "Lovalingo_branding_enabled";
19
+ const EDIT_MODE_PARAM = "edit_mode";
20
+ const EDIT_KEY_PARAM = "edit_key";
19
21
  // Why: run initial load before first paint on the client to avoid a prehide flash; useEffect on SSR.
20
22
  const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
21
23
  const DEFAULT_PATH_NORMALIZATION = { enabled: true };
24
+ const EDIT_MODE_VALUES = new Set(["1", "true", "yes", "on"]);
25
+ const EDIT_UI_ATTR = "data-lovalingo-edit-ui";
26
+ const EDIT_HIGHLIGHT_ID = "lovalingo-edit-highlight";
27
+ const EDIT_HINT_ID = "lovalingo-edit-hint";
28
+ function readEditParams() {
29
+ if (typeof window === "undefined")
30
+ return { enabled: false, editKey: null };
31
+ const params = new URLSearchParams(window.location.search);
32
+ const rawFlag = (params.get(EDIT_MODE_PARAM) || params.get("editMode") || "").trim().toLowerCase();
33
+ const enabled = EDIT_MODE_VALUES.has(rawFlag);
34
+ const editKey = (params.get(EDIT_KEY_PARAM) || params.get("editKey") || "").trim() || null;
35
+ return { enabled, editKey };
36
+ }
37
+ function cssEscape(value) {
38
+ const esc = typeof window !== "undefined" && window?.CSS?.escape;
39
+ if (typeof esc === "function")
40
+ return esc(value);
41
+ return value.replace(/[ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, "\\$&");
42
+ }
43
+ // Why: build stable, short selectors for exclusions without requiring IDs.
44
+ function buildCssSelector(element, maxDepth = 5) {
45
+ if (!element || !element.tagName)
46
+ return null;
47
+ if (element.id)
48
+ return `#${cssEscape(element.id)}`;
49
+ const parts = [];
50
+ let node = element;
51
+ let depth = 0;
52
+ while (node && depth < maxDepth) {
53
+ const tag = node.tagName.toLowerCase();
54
+ if (!tag || tag === "html")
55
+ break;
56
+ let part = tag;
57
+ const nodeTag = node.tagName;
58
+ const classes = Array.from(node.classList || [])
59
+ .filter(Boolean)
60
+ .filter((cls) => !cls.startsWith("lovalingo-"))
61
+ .slice(0, 2);
62
+ if (classes.length > 0) {
63
+ part += `.${classes.map(cssEscape).join(".")}`;
64
+ }
65
+ const parentEl = node.parentElement;
66
+ if (parentEl) {
67
+ const siblings = Array.from(parentEl.children).filter((child) => child instanceof HTMLElement && child.tagName === nodeTag);
68
+ if (siblings.length > 1) {
69
+ part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
70
+ }
71
+ }
72
+ parts.unshift(part);
73
+ const selector = parts.join(" > ");
74
+ try {
75
+ if (document.querySelectorAll(selector).length === 1)
76
+ return selector;
77
+ }
78
+ catch {
79
+ // ignore invalid selector attempts
80
+ }
81
+ node = parentEl;
82
+ depth += 1;
83
+ }
84
+ return parts.join(" > ") || null;
85
+ }
22
86
  export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://cdn.lovalingo.com', routing = 'path', // Default to path mode (SEO-friendly, recommended)
23
87
  autoPrefixLinks = true, overlayBgColor, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = DEFAULT_PATH_NORMALIZATION, // Enable by default
24
88
  mode = 'dom', // Default to legacy DOM mode for backward compatibility
@@ -86,17 +150,20 @@ navigateRef, // For path mode routing
86
150
  }
87
151
  return defaultLocale;
88
152
  });
89
- const [editMode, setEditMode] = useState(initialEditMode);
153
+ const initialEditParams = readEditParams();
154
+ const [editMode, setEditMode] = useState(initialEditMode || initialEditParams.enabled);
155
+ const [editSecretKey] = useState(initialEditParams.editKey);
90
156
  const enhancedPathConfig = useMemo(() => (routing === "path" ? { ...stablePathNormalization, supportedLocales: allLocales } : stablePathNormalization), [allLocales, routing, stablePathNormalization]);
91
- const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig));
157
+ const apiRef = useRef(new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig, editSecretKey ?? undefined));
92
158
  useEffect(() => {
93
- apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig);
94
- }, [apiBase, enhancedPathConfig, resolvedApiKey]);
159
+ apiRef.current = new LovalingoAPI(resolvedApiKey, apiBase, enhancedPathConfig, editSecretKey ?? undefined);
160
+ }, [apiBase, editSecretKey, enhancedPathConfig, resolvedApiKey]);
95
161
  const routingConfig = useContext(LangRoutingContext);
96
162
  const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
97
163
  const { trackPageviewOnce } = usePageviewTracking({ apiRef, resolvedApiKey });
98
164
  const lastNormalizedPathRef = useRef("");
99
165
  const historyPatchedRef = useRef(false);
166
+ const editSavingRef = useRef(false);
100
167
  const originalHistoryRef = useRef(null);
101
168
  const onNavigateRef = useRef(() => undefined);
102
169
  const isInternalNavigationRef = useRef(false);
@@ -551,6 +618,11 @@ navigateRef, // For path mode routing
551
618
  useEffect(() => {
552
619
  detectLocaleRef.current = detectLocale;
553
620
  }, [detectLocale]);
621
+ useEffect(() => {
622
+ if (editMode && !editSecretKey) {
623
+ warnDebug('[Lovalingo] Edit Mode is active but no edit_key was provided in the URL.');
624
+ }
625
+ }, [editMode, editSecretKey]);
554
626
  // Initialize
555
627
  useIsomorphicLayoutEffect(() => {
556
628
  const initialLocale = detectLocaleRef.current();
@@ -616,10 +688,149 @@ navigateRef, // For path mode routing
616
688
  setEditMode(prev => !prev);
617
689
  }, []);
618
690
  const excludeElement = useCallback(async (selector) => {
619
- await apiRef.current.saveExclusion(selector, 'css');
691
+ if (!editSecretKey) {
692
+ warnDebug('[Lovalingo] Edit Mode is active but no edit_key was provided in the URL.');
693
+ return;
694
+ }
695
+ // Why: store exclusions on the normalized path so they apply across locales.
696
+ const pagePath = lastNormalizedPathRef.current || processPath(window.location.pathname, enhancedPathConfig);
697
+ await apiRef.current.saveExclusion({
698
+ selector,
699
+ type: 'css',
700
+ pagePath,
701
+ editKey: editSecretKey,
702
+ });
620
703
  const exclusions = await apiRef.current.fetchExclusions();
621
704
  setMarkerEngineExclusions(exclusions);
622
- }, []);
705
+ }, [editSecretKey, enhancedPathConfig]);
706
+ useEffect(() => {
707
+ if (typeof window === "undefined")
708
+ return;
709
+ const existingHighlight = document.getElementById(EDIT_HIGHLIGHT_ID);
710
+ const existingHint = document.getElementById(EDIT_HINT_ID);
711
+ if (!editMode) {
712
+ existingHighlight?.remove();
713
+ existingHint?.remove();
714
+ return;
715
+ }
716
+ const highlight = existingHighlight ||
717
+ (() => {
718
+ const node = document.createElement("div");
719
+ node.id = EDIT_HIGHLIGHT_ID;
720
+ node.setAttribute(EDIT_UI_ATTR, "true");
721
+ node.setAttribute("data-lovalingo-exclude", "true");
722
+ node.style.position = "fixed";
723
+ node.style.pointerEvents = "none";
724
+ node.style.zIndex = "2147483646";
725
+ node.style.border = "2px solid #22c55e";
726
+ node.style.background = "rgba(34, 197, 94, 0.12)";
727
+ node.style.borderRadius = "8px";
728
+ node.style.boxSizing = "border-box";
729
+ node.style.transition = "transform 80ms ease, width 80ms ease, height 80ms ease";
730
+ node.style.display = "none";
731
+ document.body.appendChild(node);
732
+ return node;
733
+ })();
734
+ const hint = existingHint ||
735
+ (() => {
736
+ const node = document.createElement("div");
737
+ node.id = EDIT_HINT_ID;
738
+ node.setAttribute(EDIT_UI_ATTR, "true");
739
+ node.setAttribute("data-lovalingo-exclude", "true");
740
+ node.style.position = "fixed";
741
+ node.style.left = "12px";
742
+ node.style.bottom = "12px";
743
+ node.style.zIndex = "2147483647";
744
+ node.style.background = "rgba(10, 10, 10, 0.85)";
745
+ node.style.color = "#ffffff";
746
+ node.style.fontSize = "12px";
747
+ node.style.lineHeight = "1.4";
748
+ node.style.padding = "8px 10px";
749
+ node.style.borderRadius = "8px";
750
+ node.style.border = "1px solid rgba(255, 255, 255, 0.15)";
751
+ node.style.pointerEvents = "none";
752
+ node.style.maxWidth = "280px";
753
+ node.textContent = "Edit Mode: click an element to exclude. Press Esc to exit.";
754
+ document.body.appendChild(node);
755
+ return node;
756
+ })();
757
+ let rafId = null;
758
+ let pendingTarget = null;
759
+ const previousCursor = document.body.style.cursor;
760
+ document.body.style.cursor = "crosshair";
761
+ const updateHighlight = () => {
762
+ rafId = null;
763
+ if (!pendingTarget) {
764
+ highlight.style.display = "none";
765
+ return;
766
+ }
767
+ const rect = pendingTarget.getBoundingClientRect();
768
+ if (rect.width <= 0 || rect.height <= 0) {
769
+ highlight.style.display = "none";
770
+ return;
771
+ }
772
+ highlight.style.display = "block";
773
+ highlight.style.width = `${rect.width}px`;
774
+ highlight.style.height = `${rect.height}px`;
775
+ highlight.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
776
+ };
777
+ const onMove = (event) => {
778
+ const rawTarget = event.target;
779
+ const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
780
+ if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`)) {
781
+ pendingTarget = null;
782
+ }
783
+ else if (target === document.body || target === document.documentElement) {
784
+ pendingTarget = null;
785
+ }
786
+ else {
787
+ pendingTarget = target;
788
+ }
789
+ if (rafId !== null)
790
+ return;
791
+ rafId = window.requestAnimationFrame(updateHighlight);
792
+ };
793
+ const onClick = async (event) => {
794
+ const rawTarget = event.target;
795
+ const target = rawTarget instanceof HTMLElement ? rawTarget : rawTarget instanceof Node ? rawTarget.parentElement : null;
796
+ if (!target || target.closest(`[${EDIT_UI_ATTR}="true"]`))
797
+ return;
798
+ event.preventDefault();
799
+ event.stopPropagation();
800
+ event.stopImmediatePropagation();
801
+ const selector = buildCssSelector(target);
802
+ if (!selector)
803
+ return;
804
+ if (editSavingRef.current)
805
+ return;
806
+ editSavingRef.current = true;
807
+ try {
808
+ await excludeElement(selector);
809
+ }
810
+ finally {
811
+ editSavingRef.current = false;
812
+ }
813
+ };
814
+ const onKeyDown = (event) => {
815
+ if (event.key === "Escape") {
816
+ event.preventDefault();
817
+ setEditMode(false);
818
+ }
819
+ };
820
+ document.addEventListener("mousemove", onMove, true);
821
+ document.addEventListener("click", onClick, true);
822
+ document.addEventListener("keydown", onKeyDown, true);
823
+ return () => {
824
+ document.removeEventListener("mousemove", onMove, true);
825
+ document.removeEventListener("click", onClick, true);
826
+ document.removeEventListener("keydown", onKeyDown, true);
827
+ if (rafId !== null)
828
+ window.cancelAnimationFrame(rafId);
829
+ highlight.remove();
830
+ hint.remove();
831
+ document.body.style.cursor = previousCursor;
832
+ };
833
+ }, [editMode, excludeElement, setEditMode]);
623
834
  const contextValue = {
624
835
  locale,
625
836
  setLocale,
@@ -81,11 +81,13 @@ export declare class LovalingoAPI {
81
81
  private apiKey;
82
82
  private apiBase;
83
83
  private pathConfig?;
84
+ private editKey?;
84
85
  private entitlements;
85
- constructor(apiKey: string, apiBase: string, pathConfig?: PathNormalizationConfig);
86
+ constructor(apiKey: string, apiBase: string, pathConfig?: PathNormalizationConfig, editKey?: string);
86
87
  private hasApiKey;
87
88
  private buildPathParam;
88
89
  private warnMissingApiKey;
90
+ private warnMissingEditKey;
89
91
  private logActivationRequired;
90
92
  private isActivationRequiredPayload;
91
93
  private isActivationRequiredResponse;
@@ -105,5 +107,11 @@ export declare class LovalingoAPI {
105
107
  fetchBootstrap(localeHint: string, pathOrUrl?: string): Promise<BootstrapResponse | null>;
106
108
  fetchExclusions(): Promise<Exclusion[]>;
107
109
  fetchDomRules(targetLocale: string): Promise<DomRule[]>;
108
- saveExclusion(selector: string, type: 'css' | 'xpath'): Promise<void>;
110
+ saveExclusion(args: {
111
+ selector: string;
112
+ type: 'css' | 'xpath';
113
+ pagePath?: string;
114
+ editKey?: string;
115
+ description?: string;
116
+ }): Promise<void>;
109
117
  }
package/dist/utils/api.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { warnDebug, errorDebug } from './logger';
2
2
  export class LovalingoAPI {
3
- constructor(apiKey, apiBase, pathConfig) {
3
+ constructor(apiKey, apiBase, pathConfig, editKey) {
4
4
  this.entitlements = null;
5
5
  this.apiKey = apiKey;
6
6
  this.apiBase = apiBase;
7
7
  this.pathConfig = pathConfig;
8
+ this.editKey = editKey;
8
9
  }
9
10
  hasApiKey() {
10
11
  return typeof this.apiKey === 'string' && this.apiKey.trim().length > 0;
@@ -30,6 +31,9 @@ export class LovalingoAPI {
30
31
  // Avoid hard-crashing apps; make the failure mode obvious.
31
32
  warnDebug(`[Lovalingo] Missing public project key: ${action} was skipped. Pass publicAnonKey to <LovalingoProvider ...> (or set VITE_LOVALINGO_PUBLIC_ANON_KEY).`);
32
33
  }
34
+ warnMissingEditKey(action) {
35
+ warnDebug(`[Lovalingo] Missing edit key: ${action} was skipped. Open the edit link from the dashboard to continue.`);
36
+ }
33
37
  logActivationRequired(context, response) {
34
38
  errorDebug(`[Lovalingo] ${context} blocked (HTTP ${response.status}). ` +
35
39
  `This project is not activated yet. ` +
@@ -338,16 +342,30 @@ export class LovalingoAPI {
338
342
  return [];
339
343
  }
340
344
  }
341
- async saveExclusion(selector, type) {
345
+ async saveExclusion(args) {
342
346
  try {
343
347
  if (!this.hasApiKey()) {
344
348
  this.warnMissingApiKey('saveExclusion');
345
349
  return;
346
350
  }
351
+ const editKey = (args.editKey || this.editKey || "").trim();
352
+ if (!editKey) {
353
+ this.warnMissingEditKey('saveExclusion');
354
+ return;
355
+ }
356
+ // Why: persist exclusions against a normalized path to keep locales in sync.
357
+ const pagePath = (args.pagePath || this.buildPathParam()).toString();
347
358
  const response = await fetch(`${this.apiBase}/functions/v1/exclusions?key=${this.apiKey}`, {
348
359
  method: 'POST',
349
360
  headers: { 'Content-Type': 'application/json' },
350
- body: JSON.stringify({ selector, type }),
361
+ body: JSON.stringify({
362
+ key: this.apiKey,
363
+ edit_key: editKey,
364
+ page_path: pagePath,
365
+ selector_type: args.type,
366
+ selector_value: args.selector,
367
+ description: args.description,
368
+ }),
351
369
  });
352
370
  if (response.status === 403) {
353
371
  this.logActivationRequired('saveExclusion', response);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
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",