This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,31 @@
export type ApplyGlobalsPayload = { globalJavascript: string; globalCSS: string; id: number };
type ApplyGlobalsFN = (payload: ApplyGlobalsPayload, body?: HTMLElement) => void;
export const applyGlobals: ApplyGlobalsFN = (payload, body = document.body) => {
const document = body.ownerDocument;
const { globalJavascript, globalCSS, id: experimentId } = payload;
try {
if (globalJavascript) {
const id = `pertento-global-javascript-${experimentId}`;
const existingScript = document.querySelector(id);
const script = document.createElement('script');
script.id = id;
script.innerHTML = `;(function(){${globalJavascript}})();`;
if (existingScript) existingScript.remove();
document.body.appendChild(script);
}
if (globalCSS) {
const id = `pertento-global-css-${experimentId}`;
const existingStyle = document.querySelector(id);
const style = document.createElement('style');
style.id = id;
style.innerHTML = globalCSS;
if (existingStyle) existingStyle.remove();
document.head.appendChild(style);
}
} catch (error) {
console.error('applyGlobals error', error);
}
};
@@ -0,0 +1,25 @@
import type { LocalChange } from 'injector/types';
export const applyHtmlChange = (element: HTMLElement, item: LocalChange) => {
try {
const parser = new DOMParser();
if (!item.value) return;
const newElement = parser.parseFromString(item.value, 'text/html').body.firstChild as HTMLElement | null;
if (!newElement) return;
if (item.action === 'replace') {
element.replaceWith(newElement);
} else {
item.undoElement = newElement;
if (item.action === 'after') {
element.after(newElement);
} else if (item.action === 'before') {
element.before(newElement);
} else if (item.action === 'append') {
element.append(newElement);
} else if (item.action === 'insert') {
element.insertBefore(newElement, element.firstChild);
}
}
} catch (error) {}
};
@@ -0,0 +1,27 @@
import type { LocalChange } from 'injector/types';
type GetElementFromChangeFn = (change: LocalChange, body?: HTMLElement) => HTMLElement | null;
export const getElementFromChange: GetElementFromChangeFn = (change, body = document.body) => {
try {
let element = !!change.selector && body.querySelector(change.selector);
if (element) {
return element as HTMLElement;
}
try {
if (!change.friendlySelector) return null;
const elements = body.querySelectorAll(change.friendlySelector);
if (elements.length === 1) {
return elements[0] as HTMLElement;
}
if (elements.length > 1) {
const index = change.friendlySelectorIndex;
if (typeof index !== 'number') return null;
if (elements[index]) {
return elements[index] as HTMLElement;
}
}
} catch (ex) {}
} catch (ex) {}
return null;
};
@@ -0,0 +1,100 @@
import type { LocalChange } from 'injector/types';
import { applyHtmlChange } from './apply-html-change';
import { getElementFromChange } from './get-element-from-change';
export { getElementFromChange } from './get-element-from-change';
export * from './apply-globals';
export const applyChanges = (changes: LocalChange[], body = document.body) => {
const unfoundChanges: LocalChange[] = [];
for (let item of changes) {
try {
if (item.selectors?.length) {
const multipleChanges: LocalChange[] = [];
const selectors = item.selectors ?? [];
const friendlySelectors = item.friendlySelectors ?? [];
const friendlySelectorIndexes = item.friendlySelectorIndexes ?? [];
for (let i = 0; i < selectors.length; i++) {
const singleChange = {
...item,
selectors: [],
friendlySelectors: [],
friendlySelectorIndexes: [],
selector: selectors[i],
friendlySelector: friendlySelectors[i],
friendlySelectorIndex: friendlySelectorIndexes[i],
} as LocalChange;
multipleChanges.push(singleChange);
}
applyChanges(multipleChanges, body);
continue;
}
if (item.selector) {
const element: HTMLElement = getElementFromChange(item, body) as HTMLElement;
if (!element) {
unfoundChanges.push(item);
continue;
}
item.element = element;
item.clone = item.clone || (element.cloneNode(true) as HTMLElement);
if (item.property === 'html') {
applyHtmlChange(element, item);
continue;
}
if (item.property === 'src' && item.value) {
(element as HTMLImageElement).src = item.value || '';
continue;
}
if (['textContent', 'text'].includes(item.property as string)) {
element.textContent = item.value || '';
continue;
}
if (item.property === 'backgroundImage') {
element.style.backgroundImage = item.value ? `url(${item.value})` : '';
continue;
}
if (item.property === 'dragPosition' && item.value) {
const parsed = JSON.parse(item.value);
if ('positionType' in parsed) {
element.style.position = parsed.positionType;
element.style.left = `${parsed.left}px`;
element.style.top = `${parsed.top}px`;
} else {
element.style.position = parsed.position;
element.style.left = parsed.left;
element.style.top = parsed.top;
}
continue;
}
if (item.property === 'domMove' && item.value) {
const { newParentSelector, newNextSiblingSelector } = JSON.parse(item.value);
const newParent = newParentSelector ? body.querySelector(newParentSelector) : null;
const newNextSibling = newNextSiblingSelector ? body.querySelector(newNextSiblingSelector) : null;
if (newParent) {
if (newNextSibling) {
newParent.insertBefore(element, newNextSibling);
} else {
newParent.appendChild(element);
}
}
continue;
}
if (typeof item.property === 'string') {
element.style[item.property as any] = item.value || '';
}
}
} catch (ex) {
return [];
}
}
return unfoundChanges;
};
+8
View File
@@ -0,0 +1,8 @@
const console = window?.console;
const isDebug = !!localStorage.getItem('PERTENTO_DEBUG');
const log = (...args: any[]) => {
isDebug && console.info('PERTENTOAI:', ...args);
};
export { log };
@@ -0,0 +1,16 @@
import type { RuntimeCookieTargeting } from 'types/runtime';
export const matchCookieTargeting = (cookie: string, cookieTargeting: RuntimeCookieTargeting[]) => {
if (!cookieTargeting || cookieTargeting.length === 0) return true;
if (!cookie) return false;
for (let targeting of cookieTargeting) {
for (let cookieValue of targeting.cookieValues) {
if (cookie.includes(cookieValue)) {
return true;
}
}
}
return false;
};
@@ -0,0 +1,129 @@
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { detectDevice } from './detect-device';
describe('detectDevice', () => {
const originalNavigator = globalThis.navigator;
const originalScreen = globalThis.screen;
const mockEnvironment = (userAgent: string, maxTouchPoints: number, screenWidth: number) => {
// @ts-expect-error - mocking navigator
globalThis.navigator = {
userAgent,
maxTouchPoints,
};
// @ts-expect-error - mocking screen
globalThis.screen = {
width: screenWidth,
};
};
afterAll(() => {
globalThis.navigator = originalNavigator;
globalThis.screen = originalScreen;
});
describe('user agent detection', () => {
test('detects iPad as Tablet', () => {
mockEnvironment('Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)', 0, 1024);
expect(detectDevice()).toBe('Tablet');
});
test('detects tablet keyword as Tablet', () => {
mockEnvironment('Mozilla/5.0 (Linux; Android 10; SM-T500) Tablet', 0, 800);
expect(detectDevice()).toBe('Tablet');
});
test('detects iPhone as Mobile', () => {
mockEnvironment('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', 0, 375);
expect(detectDevice()).toBe('Mobile');
});
test('detects iPod as Mobile', () => {
mockEnvironment('Mozilla/5.0 (iPod touch; CPU iPhone OS 14_0 like Mac OS X)', 0, 320);
expect(detectDevice()).toBe('Mobile');
});
test('detects Android mobile as Mobile', () => {
mockEnvironment('Mozilla/5.0 (Linux; Android 10; SM-G960F) Mobile', 0, 360);
expect(detectDevice()).toBe('Mobile');
});
test('detects generic mobile keyword as Mobile', () => {
mockEnvironment('Mozilla/5.0 Mobile Safari', 0, 400);
expect(detectDevice()).toBe('Mobile');
});
});
describe('touch and screen size detection', () => {
test('touch device with small screen is Mobile', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 600);
expect(detectDevice()).toBe('Mobile');
});
test('touch device with medium screen is Tablet', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 800);
expect(detectDevice()).toBe('Tablet');
});
test('touch device at 768px boundary is Tablet', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 768);
expect(detectDevice()).toBe('Tablet');
});
test('touch device at 1280px boundary is Tablet', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 1280);
expect(detectDevice()).toBe('Tablet');
});
test('touch device above 1280px is Desktop', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 1400);
expect(detectDevice()).toBe('Desktop');
});
});
describe('desktop detection', () => {
test('non-touch device with large screen is Desktop', () => {
mockEnvironment('Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 0, 1920);
expect(detectDevice()).toBe('Desktop');
});
test('non-touch device with small screen is still Desktop', () => {
// No touch and no mobile UA means desktop regardless of screen size
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 0, 600);
expect(detectDevice()).toBe('Desktop');
});
test('single touch point (mouse) is not considered touch', () => {
// maxTouchPoints = 1 means mouse, not touch screen
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 1, 800);
expect(detectDevice()).toBe('Desktop');
});
});
describe('case insensitivity', () => {
test('detects IPAD in uppercase', () => {
mockEnvironment('Mozilla/5.0 IPAD something', 0, 1024);
// UA is lowercased in the function
expect(detectDevice()).toBe('Tablet');
});
test('detects Mobile in mixed case', () => {
mockEnvironment('Mozilla/5.0 MoBiLe Safari', 0, 375);
expect(detectDevice()).toBe('Mobile');
});
});
describe('priority of detection', () => {
test('tablet UA takes priority over mobile-sized touch screen', () => {
// iPad UA should return Tablet even with small screen
mockEnvironment('Mozilla/5.0 (iPad)', 5, 500);
expect(detectDevice()).toBe('Tablet');
});
test('mobile UA takes priority over tablet-sized touch screen', () => {
// iPhone UA should return Mobile even with tablet-sized screen
mockEnvironment('Mozilla/5.0 (iPhone)', 5, 1000);
expect(detectDevice()).toBe('Mobile');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
export function detectDevice() {
const ua = navigator.userAgent.toLowerCase();
const isTouch = navigator.maxTouchPoints > 1;
const width = screen.width;
if (/tablet|ipad/.test(ua)) return 'Tablet';
if (/mobile|iphone|ipod|android/.test(ua)) return 'Mobile';
if (isTouch && width < 768) return 'Mobile';
if (isTouch && width >= 768 && width <= 1280) return 'Tablet';
return 'Desktop';
}
+55
View File
@@ -0,0 +1,55 @@
const spaceRegex = /\s+/g;
const endDotRegex = /\.$/;
const doubleDotRegex = /\.+/g;
type GetFriendlySelectorFn = (element: HTMLElement) => string;
export const getFriendlySelector: GetFriendlySelectorFn = (element) => {
if (element.tagName.toLowerCase() == 'body') return 'body';
let str = element.tagName.toLowerCase();
if (element.className && element.className.split) {
let classes = element.className.split(spaceRegex);
for (let i = 0; i < classes.length; i++) {
if (typeof classes[i] === 'string') {
const theClass = classes[i];
if (!theClass) continue;
if (theClass.trim().length > 0) {
if (theClass.indexOf(':') === -1 && theClass.indexOf('[') === -1) {
str += '.' + theClass;
}
}
}
}
str = str.replace(endDotRegex, '').replace(doubleDotRegex, '.');
}
const id = element.getAttribute('id');
str += !!id && id.indexOf('=') < 0 ? '#' + id : '';
return getFriendlySelector(element.parentNode as HTMLElement) + ' > ' + str;
};
type GetSelectorFn = (element: HTMLElement) => string;
export const getSelector: GetSelectorFn = (element) => {
if (!element || element === document.documentElement) {
return '';
}
var selector = '';
if (element.tagName) {
selector = element.tagName;
}
if (element.parentNode) {
var index = Array.from(element.parentNode.children).indexOf(element) + 1;
selector += ':nth-child(' + index + ')';
}
var parentSelector = getSelector(element.parentNode as HTMLElement);
if (parentSelector) {
selector = parentSelector + '>' + selector;
}
return selector.trim();
};
+16
View File
@@ -0,0 +1,16 @@
{
"name": "injector",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"exports": {
"./console": "./console.ts",
"./apply-changes": "./apply-changes/index.ts",
"./cookie-target-match": "./cookie-target-match.ts",
"./detect-device": "./detect-device.ts",
"./get-selector": "./get-selector.ts",
"./get-style-map": "./get-style-map.ts",
"./types": "./types/index.ts",
"./use-client": "./use-client.ts"
}
}
@@ -0,0 +1,9 @@
export const removeOutline = (elements: HTMLElement | HTMLElement[] | null) => {
if (!elements) return;
const elemArray = Array.isArray(elements) ? elements : [elements];
for (const element of elemArray) {
element.style.outline = '';
if (!element.style[0]) element.removeAttribute('style');
removeOutline(Array.from(element.children) as HTMLElement[]);
}
};
+14
View File
@@ -0,0 +1,14 @@
const setNativeValue = (element: HTMLElement, value: string | number) => {
if (!Object) return;
const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')?.set;
const prototype = Object.getPrototypeOf(element);
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (valueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter?.call(element, value);
} else {
valueSetter?.call(element, value);
}
};
export default setNativeValue;
+42
View File
@@ -0,0 +1,42 @@
export type CodeLanguage = 'html' | 'css' | 'js';
type Change = {
selector?: string;
selectors?: string[];
friendlySelector?: string;
friendlySelectors?: string[];
friendlySelectorIndex?: number;
friendlySelectorIndexes?: number[];
property?: string;
value?: string;
action?: 'replace' | 'after' | 'before' | 'append' | 'insert';
};
export type Margin = { top: number; left: number };
export type IsContextOpenType = { left: number; top: number } | false;
export type OpenContextMenuProps = { dims: Dims; clientX: number; clientY: number; isHierarchy: boolean };
export type GlobalChanges = { globalJavascript: string; globalCSS: string };
export type ChangesType = [GlobalChanges, ...LocalChange[]];
export type HTMLElementWithHierarchy = Partial<HTMLElement> & {
hierarchy?: HTMLElementWithHierarchy[];
element?: HTMLElement;
selector?: string;
friendlySelector?: string;
friendlySelectorIndex?: number;
outerHTML?: string;
computedStyles?: Record<string, string>;
};
export type Dims = Partial<DOMRect>;
export type LocalChange = Partial<Change> & {
hierarchy?: HTMLElementWithHierarchy[];
dims?: Dims;
element?: HTMLElement;
undoElement?: HTMLElement;
clone?: HTMLElement;
};
@@ -0,0 +1,45 @@
type UndoRedoItem = {
property: string;
action: string;
value: string;
clone: Node;
undoElement?: ChildNode | null;
};
export const applyUndo = (element: HTMLElement, item: UndoRedoItem) => {
if (item.property === 'html') {
if (item.action !== 'replace') {
item.undoElement?.remove();
return;
}
}
element.replaceWith(item.clone);
};
export const applyRedo = (element: HTMLElement, item: UndoRedoItem) => {
if (item.property === 'html') {
const parser = new DOMParser();
const newElement = parser.parseFromString(item.value, 'text/html').body.firstChild;
if (item.action === 'before') {
item.undoElement = newElement;
element.before(newElement as Node);
return;
}
if (item.action === 'after') {
item.undoElement = newElement;
element.after(newElement as Node);
return;
}
if (item.action === 'append') {
element.append(newElement as Node);
return;
}
if (item.action === 'insert') {
item.undoElement = newElement;
element.insertBefore(newElement as Node, element.firstChild);
return;
}
}
element.replaceWith(item.clone);
};
+38
View File
@@ -0,0 +1,38 @@
function getHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
};
}
async function get<T>(uri: string, baseUrl: string = ''): Promise<T> {
const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers });
return res.json() as Promise<T>;
}
async function post<T>(uri: string, payload?: unknown, baseUrl: string = ''): Promise<T> {
const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers,
});
return res.json() as Promise<T>;
}
interface HttpClient {
baseUrl: string;
get: <T>(url: string) => Promise<T>;
post: <T>(url: string, payload?: unknown) => Promise<T>;
}
export function createClient(baseUrl?: string): HttpClient {
const finalBaseUrl = baseUrl || '';
return {
baseUrl: finalBaseUrl,
get: <T>(url: string) => get<T>(url, finalBaseUrl),
post: <T>(url: string, payload?: unknown) => post<T>(url, payload, finalBaseUrl),
};
}