clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
105 lines (96 loc) • 4.55 kB
text/typescript
import { Character } from "../../types/data";
import { Constant, Selector, type SelectorInput } from "../../types/layout";
const excludeClassNames = Constant.ExcludeClassNames.split(Constant.Comma);
let selectorMap: { [selector: string]: number[] } = {};
export function reset(): void {
selectorMap = {};
}
export function get(input: SelectorInput, type: Selector): string {
const a = input.attributes;
let prefix = input.prefix ? input.prefix[type] : null;
const suffix = type === Selector.Alpha ? `${Constant.Tilde}${input.position - 1}` : `:nth-of-type(${input.position})`;
switch (input.tag) {
case "STYLE":
case "TITLE":
case "LINK":
case "META":
case Constant.TextTag:
case Constant.DocumentTag:
return Constant.Empty;
case "HTML":
return Constant.HTML;
default: {
if (prefix === null) {
return Constant.Empty;
}
prefix = `${prefix}${Constant.Separator}`;
input.tag = input.tag.indexOf(Constant.SvgPrefix) === 0 ? input.tag.substr(Constant.SvgPrefix.length) : input.tag;
let selector = `${prefix}${input.tag}${suffix}`;
const id = Constant.Id in a && a[Constant.Id].length > 0 ? a[Constant.Id] : null;
const classes =
input.tag !== Constant.BodyTag && Constant.Class in a && a[Constant.Class].length > 0
? a[Constant.Class]
.trim()
.split(/\s+/)
.filter((c) => filter(c))
.join(Constant.Period)
: null;
if (classes && classes.length > 0) {
if (type === Selector.Alpha) {
// In Alpha mode, update selector to use class names, with relative positioning within the parent id container.
// If the node has valid class name(s) then drop relative positioning within the parent path to keep things simple.
const key = `${getDomPath(prefix)}${input.tag}${Constant.Dot}${classes}`;
if (!(key in selectorMap)) {
selectorMap[key] = [];
}
if (selectorMap[key].indexOf(input.id) < 0) {
selectorMap[key].push(input.id);
}
selector = `${key}${Constant.Tilde}${selectorMap[key].indexOf(input.id)}`;
} else {
// In Beta mode, we continue to look at query selectors in context of the full page
selector = `${prefix}${input.tag}.${classes}${suffix}`;
}
}
// Update selector to use "id" field when available. There are two exceptions:
// (1) if "id" appears to be an auto generated string token, e.g. guid or a random id containing digits
// (2) if "id" appears inside a shadow DOM, in which case we continue to prefix up to shadow DOM to prevent conflicts
selector = id && filter(id) ? `${getDomPrefix(prefix)}${Constant.Hash}${id}` : selector;
return selector;
}
}
}
function getDomPrefix(prefix: string): string {
const shadowDomStart = prefix.lastIndexOf(Constant.ShadowDomTag);
const iframeDomStart = prefix.lastIndexOf(`${Constant.IFramePrefix}${Constant.HTML}`);
const domStart = Math.max(shadowDomStart, iframeDomStart);
if (domStart < 0) {
return Constant.Empty;
}
return prefix.substring(0, prefix.indexOf(Constant.Separator, domStart) + 1);
}
function getDomPath(input: string): string {
const parts = input.split(Constant.Separator);
for (let i = 0; i < parts.length; i++) {
const tIndex = parts[i].indexOf(Constant.Tilde);
const dIndex = parts[i].indexOf(Constant.Dot);
parts[i] = parts[i].substring(0, dIndex > 0 ? dIndex : tIndex > 0 ? tIndex : parts[i].length);
}
return parts.join(Constant.Separator);
}
// Check if the given input string has digits or excluded class names
function filter(value: string): boolean {
if (!value) {
return false;
} // Do not process empty strings
if (excludeClassNames.some((x) => value.toLowerCase().indexOf(x) >= 0)) {
return false;
}
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
if (c >= Character.Zero && c <= Character.Nine) {
return false;
}
}
return true;
}