clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
218 lines (193 loc) • 7.88 kB
text/typescript
import { ExtractSource, Syntax, Type } from "@clarity-types/core";
import { Event, Setting, ExtractData } from "@clarity-types/data";
import encode from "./encode";
import * as internal from "@src/diagnostic/internal";
import { Code, Constant, Severity } from "@clarity-types/data";
import { hashText } from "@src/clarity";
import hash from "@src/core/hash";
export let data: ExtractData = {};
export let keys: Set<number> = new Set();
let variables : { [key: number]: { [key: number]: Syntax[] }} = {};
let selectors : { [key: number]: { [key: number]: string }} = {};
let hashes : { [key: number]: { [key: number]: string }} = {};
let 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 {
var parts = input && input.length > 0 ? input.split(/ (.*)/) : [Constant.Empty];
var keyparts = parts[0].split(/\|(.*)/);
var key = parseInt(keyparts[0]);
var element = keyparts.length > 1 ? keyparts[1] : Constant.Empty;
var values = parts.length > 1 ? JSON.parse(parts[1]) : {};
variables[key] = {};
selectors[key] = {};
hashes[key] = {};
validation[key] = element;
for (var v in values) {
// values is a set of strings for proper JSON parsing, but it's more efficient
// to interact with them as numbers
let id = parseInt(v);
let 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:
let variable = value.slice(1);
variables[key][id] = parse(variable);
break;
case ExtractSource.Text:
selectors[key][id] = value;
break;
case ExtractSource.Hash:
let 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 (let v in variables) {
let key = parseInt(v);
if (validation[key] == Constant.Empty || document.querySelector(validation[key]))
{
let variableData = variables[key];
for (let v in variableData) {
let variableKey = parseInt(v);
let value = str(evaluate(clone(variableData[variableKey])));
if (value) {
update(key, variableKey, value);
}
}
let selectorData = selectors[key];
for (let s in selectorData) {
let shouldMask = false;
let selectorKey = parseInt(s);
let selector = selectorData[selectorKey];
if (selector.startsWith(Constant.At)){
shouldMask = true;
selector = selector.slice(1);
}
let nodes = document.querySelectorAll(selector) as NodeListOf<HTMLElement>;
if (nodes) {
let text = Array.from(nodes).map(e => {
if (e.tagName === "IMG") {
let img = e as HTMLImageElement;
return img.src || img.currentSrc || Constant.Empty;
}
return e.textContent;
}).join(Constant.Seperator);
update(key, selectorKey, (shouldMask ? hash(text).trim() : text).slice(0, Setting.ExtractLimit));
}
}
let hashData = hashes[key];
for (let h in hashData) {
let hashKey = parseInt(h);
let 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 {
var 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[] {
let syntax: Syntax[] = [];
let parts = variable.split(Constant.Dot);
while (parts.length > 0) {
let part = parts.shift();
let arrayStart = part.indexOf(Constant.ArrayStart);
let conditionStart = part.indexOf(Constant.ConditionStart);
let 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"].
function evaluate(variable: Syntax[], base: Object = window): any {
if (variable.length == 0) { return base; }
let part = variable.shift();
let output;
if (base && base[part.name]) {
let obj = base[part.name];
if (part.type !== Type.Array && match(obj, part.condition)) {
output = evaluate(variable, obj);
}
else if (Array.isArray(obj)) {
let filtered = [];
for (var value of obj) {
if (match(value, part.condition)) {
let 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;
}
function match(base: Object, condition: string): boolean {
if (condition) {
let prop = condition.split(":");
return prop.length > 1 ? base[prop[0]] == prop[1] : base[prop[0]]
}
return true;
}
function isEmpty(obj: Object): boolean {
return Object.keys(obj).length == 0;
}