@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.
- package/dist/components/LovalingoProvider.js +217 -6
- package/dist/utils/api.d.ts +10 -2
- package/dist/utils/api.js +21 -3
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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({
|
|
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