clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
221 lines (196 loc) • 8.26 kB
text/typescript
import { ExtractSource, type Syntax, Type } from "@clarity-types/core";
import { Event, type ExtractData, Setting } from "@clarity-types/data";
import { Code, Constant, Severity } from "@clarity-types/data";
import { hashText } from "@src/clarity";
import hash from "@src/core/hash";
import * as internal from "@src/diagnostic/internal";
import encode from "./encode";
export const data: ExtractData = {};
export const keys: Set<number> = new Set();
const variables: { [key: number]: { [key: number]: Syntax[] } } = {};
const selectors: { [key: number]: { [key: number]: string } } = {};
const hashes: { [key: number]: { [key: number]: string } } = {};
const validation: { [key: number]: string } = {};
export function start(): void {
reset();
}
// Input string is of the following form:
// EXTRACT 101|element { "1": ".class1", "2": "~window.a.b", "3": "!abc"}
// if element is present on the page it will set up event 101 to grab the contents of the class1 selector into component 1,
// the javascript evaluated contents of window.a.b into component 2,
// and the contents of Clarity's hash abc into component 3
export function trigger(input: string): void {
try {
const parts = input && input.length > 0 ? input.split(/ (.*)/) : [Constant.Empty];
const keyparts = parts[0].split(/\|(.*)/);
const key = Number.parseInt(keyparts[0]);
const element = keyparts.length > 1 ? keyparts[1] : Constant.Empty;
const values = parts.length > 1 ? JSON.parse(parts[1]) : {};
variables[key] = {};
selectors[key] = {};
hashes[key] = {};
validation[key] = element;
for (const v in values) {
// values is a set of strings for proper JSON parsing, but it's more efficient
// to interact with them as numbers
const id = Number.parseInt(v);
const value = values[v] as string;
let source = ExtractSource.Text;
if (value.startsWith(Constant.Tilde)) {
source = ExtractSource.Javascript;
} else if (value.startsWith(Constant.Bang)) {
source = ExtractSource.Hash;
}
switch (source) {
case ExtractSource.Javascript: {
const variable = value.slice(1);
variables[key][id] = parse(variable);
break;
}
case ExtractSource.Text:
selectors[key][id] = value;
break;
case ExtractSource.Hash: {
const hash = value.slice(1);
hashes[key][id] = hash;
break;
}
}
}
} catch (e) {
internal.log(Code.Config, Severity.Warning, e ? e.name : null);
}
}
export function clone(v: Syntax[]): Syntax[] {
return JSON.parse(JSON.stringify(v));
}
export function compute(): void {
try {
for (const v in variables) {
const key = Number.parseInt(v);
if (validation[key] === Constant.Empty || document.querySelector(validation[key])) {
const variableData = variables[key];
for (const v in variableData) {
const variableKey = Number.parseInt(v);
const value = str(evaluate(clone(variableData[variableKey])));
if (value) {
update(key, variableKey, value);
}
}
const selectorData = selectors[key];
for (const s in selectorData) {
let shouldMask = false;
const selectorKey = Number.parseInt(s);
let selector = selectorData[selectorKey];
if (selector.startsWith(Constant.At)) {
shouldMask = true;
selector = selector.slice(1);
}
const nodes = document.querySelectorAll(selector) as NodeListOf<HTMLElement>;
if (nodes) {
const text = Array.from(nodes)
.map((e) => e.textContent)
.join(Constant.Seperator);
update(key, selectorKey, (shouldMask ? hash(text).trim() : text).slice(0, Setting.ExtractLimit));
}
}
const hashData = hashes[key];
for (const h in hashData) {
const hashKey = Number.parseInt(h);
const content = hashText(hashData[hashKey]).trim().slice(0, Setting.ExtractLimit);
update(key, hashKey, content);
}
}
}
if (keys.size > 0) {
encode(Event.Extract);
}
} catch (e) {
internal.log(Code.Selector, Severity.Warning, e ? e.name : null);
}
}
export function reset(): void {
keys.clear();
}
export function update(key: number, subkey: number, value: string): void {
let update = false;
if (!(key in data)) {
data[key] = {};
update = true;
}
if (!isEmpty(hashes[key]) && (!(subkey in data[key]) || data[key][subkey] !== value)) {
update = true;
}
data[key][subkey] = value;
if (update) {
keys.add(key);
}
return;
}
export function stop(): void {
reset();
}
function parse(variable: string): Syntax[] {
const syntax: Syntax[] = [];
const parts = variable.split(Constant.Dot);
while (parts.length > 0) {
const part = parts.shift();
const arrayStart = part.indexOf(Constant.ArrayStart);
const conditionStart = part.indexOf(Constant.ConditionStart);
const conditionEnd = part.indexOf(Constant.ConditionEnd);
syntax.push({
name: arrayStart > 0 ? part.slice(0, arrayStart) : conditionStart > 0 ? part.slice(0, conditionStart) : part,
type: arrayStart > 0 ? Type.Array : conditionStart > 0 ? Type.Object : Type.Simple,
condition: conditionStart > 0 ? part.slice(conditionStart + 1, conditionEnd) : null,
});
}
return syntax;
}
// The function below takes in a variable name in following format: "a.b.c" and safely evaluates its value in javascript context
// For instance, for a.b.c, it will first check window["a"]. If it exists, it will recursively look at: window["a"]["b"] and finally,
// return the value for window["a"]["b"]["c"].
// biome-ignore lint/complexity/noBannedTypes: type of base is intentionally generic
// biome-ignore lint/suspicious/noExplicitAny: type of return value isn't known
function evaluate(variable: Syntax[], base: Object = window): any {
if (variable.length === 0) {
return base;
}
const part = variable.shift();
// biome-ignore lint/suspicious/noImplicitAnyLet: type of return value isn't known
let output;
if (base?.[part.name]) {
const obj = base[part.name];
if (part.type !== Type.Array && match(obj, part.condition)) {
output = evaluate(variable, obj);
} else if (Array.isArray(obj)) {
const filtered = [];
for (const value of obj) {
if (match(value, part.condition)) {
const op = evaluate(variable, value);
if (op) {
filtered.push(op);
}
}
}
output = filtered;
}
return output;
}
return null;
}
function str(input: string): string {
// Automatically trim string to max of Setting.ExtractLimit to avoid fetching long strings
return input ? JSON.stringify(input).slice(0, Setting.ExtractLimit) : input;
}
// biome-ignore lint/complexity/noBannedTypes: type of base is intentionally generic
function match(base: Object, condition: string): boolean {
if (condition) {
const prop = condition.split(":");
return prop.length > 1 ? base[prop[0]] === prop[1] : base[prop[0]];
}
return true;
}
// biome-ignore lint/complexity/noBannedTypes: type of obj is intentionally generic
function isEmpty(obj: Object): boolean {
return Object.keys(obj).length === 0;
}