clarity-js 0.6.23
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/README.md +26 -0
- package/build/clarity.js +4479 -0
- package/build/clarity.min.js +1 -0
- package/build/clarity.module.js +4473 -0
- package/package.json +66 -0
- package/rollup.config.ts +38 -0
- package/src/clarity.ts +54 -0
- package/src/core/config.ts +21 -0
- package/src/core/copy.ts +3 -0
- package/src/core/event.ts +25 -0
- package/src/core/hash.ts +19 -0
- package/src/core/history.ts +69 -0
- package/src/core/index.ts +79 -0
- package/src/core/measure.ts +17 -0
- package/src/core/report.ts +27 -0
- package/src/core/scrub.ts +102 -0
- package/src/core/task.ts +180 -0
- package/src/core/time.ts +14 -0
- package/src/core/timeout.ts +10 -0
- package/src/core/version.ts +2 -0
- package/src/data/baseline.ts +89 -0
- package/src/data/compress.ts +31 -0
- package/src/data/custom.ts +18 -0
- package/src/data/dimension.ts +42 -0
- package/src/data/encode.ts +109 -0
- package/src/data/envelope.ts +46 -0
- package/src/data/index.ts +43 -0
- package/src/data/limit.ts +42 -0
- package/src/data/metadata.ts +232 -0
- package/src/data/metric.ts +51 -0
- package/src/data/ping.ts +36 -0
- package/src/data/summary.ts +34 -0
- package/src/data/token.ts +39 -0
- package/src/data/upgrade.ts +36 -0
- package/src/data/upload.ts +250 -0
- package/src/data/variable.ts +46 -0
- package/src/diagnostic/encode.ts +40 -0
- package/src/diagnostic/image.ts +23 -0
- package/src/diagnostic/index.ts +14 -0
- package/src/diagnostic/internal.ts +41 -0
- package/src/diagnostic/script.ts +45 -0
- package/src/global.ts +22 -0
- package/src/index.ts +8 -0
- package/src/interaction/click.ts +140 -0
- package/src/interaction/encode.ts +140 -0
- package/src/interaction/index.ts +45 -0
- package/src/interaction/input.ts +64 -0
- package/src/interaction/pointer.ts +108 -0
- package/src/interaction/resize.ts +30 -0
- package/src/interaction/scroll.ts +73 -0
- package/src/interaction/selection.ts +66 -0
- package/src/interaction/timeline.ts +65 -0
- package/src/interaction/unload.ts +25 -0
- package/src/interaction/visibility.ts +24 -0
- package/src/layout/box.ts +83 -0
- package/src/layout/discover.ts +27 -0
- package/src/layout/document.ts +46 -0
- package/src/layout/dom.ts +442 -0
- package/src/layout/encode.ts +111 -0
- package/src/layout/extract.ts +75 -0
- package/src/layout/index.ts +25 -0
- package/src/layout/mutation.ts +232 -0
- package/src/layout/node.ts +211 -0
- package/src/layout/offset.ts +19 -0
- package/src/layout/region.ts +143 -0
- package/src/layout/schema.ts +66 -0
- package/src/layout/selector.ts +24 -0
- package/src/layout/target.ts +44 -0
- package/src/layout/traverse.ts +28 -0
- package/src/performance/connection.ts +37 -0
- package/src/performance/encode.ts +40 -0
- package/src/performance/index.ts +15 -0
- package/src/performance/navigation.ts +31 -0
- package/src/performance/observer.ts +87 -0
- package/test/core.test.ts +82 -0
- package/test/helper.ts +104 -0
- package/test/html/core.html +17 -0
- package/test/tsconfig.test.json +6 -0
- package/tsconfig.json +21 -0
- package/tslint.json +33 -0
- package/types/core.d.ts +127 -0
- package/types/data.d.ts +344 -0
- package/types/diagnostic.d.ts +24 -0
- package/types/index.d.ts +30 -0
- package/types/interaction.d.ts +110 -0
- package/types/layout.d.ts +200 -0
- package/types/performance.d.ts +40 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { Privacy } from "@clarity-types/core";
|
|
2
|
+
import { Code, Setting, Severity } from "@clarity-types/data";
|
|
3
|
+
import { Constant, NodeChange, NodeInfo, NodeValue, Source } from "@clarity-types/layout";
|
|
4
|
+
import config from "@src/core/config";
|
|
5
|
+
import { time } from "@src/core/time";
|
|
6
|
+
import * as internal from "@src/diagnostic/internal";
|
|
7
|
+
import * as extract from "@src/layout/extract";
|
|
8
|
+
import * as region from "@src/layout/region";
|
|
9
|
+
import selector from "@src/layout/selector";
|
|
10
|
+
|
|
11
|
+
let index: number = 1;
|
|
12
|
+
|
|
13
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#%3Cinput%3E_types
|
|
14
|
+
const DISALLOWED_TYPES = ["password", "hidden", "email", "tel"];
|
|
15
|
+
const DISALLOWED_NAMES = ["addr", "cell", "code", "dob", "email", "mob", "name", "phone", "secret", "social", "ssn", "tel", "zip", "pass"];
|
|
16
|
+
const DISALLOWED_MATCH = ["address", "password", "contact"];
|
|
17
|
+
|
|
18
|
+
let nodes: Node[] = [];
|
|
19
|
+
let values: NodeValue[] = [];
|
|
20
|
+
let changes: NodeChange[][] = [];
|
|
21
|
+
let updateMap: number[] = [];
|
|
22
|
+
let selectorMap: number[] = [];
|
|
23
|
+
|
|
24
|
+
// The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
|
|
25
|
+
let idMap: WeakMap<Node, number> = null; // Maps node => id.
|
|
26
|
+
let iframeMap: WeakMap<Document, HTMLIFrameElement> = null; // Maps iframe's contentDocument => parent iframe element
|
|
27
|
+
let privacyMap: WeakMap<Node, Privacy> = null; // Maps node => Privacy (enum)
|
|
28
|
+
|
|
29
|
+
let urlMap: { [url: string]: number } = {};
|
|
30
|
+
|
|
31
|
+
export function start(): void {
|
|
32
|
+
reset();
|
|
33
|
+
parse(document);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stop(): void {
|
|
37
|
+
reset();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reset(): void {
|
|
41
|
+
index = 1;
|
|
42
|
+
nodes = [];
|
|
43
|
+
values = [];
|
|
44
|
+
updateMap = [];
|
|
45
|
+
changes = [];
|
|
46
|
+
selectorMap = [];
|
|
47
|
+
urlMap = {};
|
|
48
|
+
idMap = new WeakMap();
|
|
49
|
+
iframeMap = new WeakMap();
|
|
50
|
+
privacyMap = new WeakMap();
|
|
51
|
+
if (Constant.DevHook in window) { window[Constant.DevHook] = { get, getNode, history }; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// We parse new root nodes for any regions or masked nodes in the beginning (document) and
|
|
55
|
+
// later whenever there are new additions or modifications to DOM (mutations)
|
|
56
|
+
export function parse(root: ParentNode): void {
|
|
57
|
+
// Wrap selectors in a try / catch block.
|
|
58
|
+
// It's possible for script to receive invalid selectors, e.g. "'#id'" with extra quotes, and cause the code below to fail
|
|
59
|
+
try {
|
|
60
|
+
// Since mutations may happen on leaf nodes too, e.g. text nodes, which may not support all selector APIs.
|
|
61
|
+
// We ensure that the root note supports querySelectorAll API before executing the code below to identify new regions.
|
|
62
|
+
if ("querySelectorAll" in root) {
|
|
63
|
+
extract.regions(root, config.regions);
|
|
64
|
+
extract.metrics(root, config.metrics);
|
|
65
|
+
config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements
|
|
66
|
+
config.unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
|
|
67
|
+
}
|
|
68
|
+
} catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getId(node: Node, autogen: boolean = false): number {
|
|
72
|
+
if (node === null) { return null; }
|
|
73
|
+
let id = idMap.get(node);
|
|
74
|
+
if (!id && autogen) {
|
|
75
|
+
id = index++;
|
|
76
|
+
idMap.set(node, id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return id ? id : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function add(node: Node, parent: Node, data: NodeInfo, source: Source): void {
|
|
83
|
+
let id = getId(node, true);
|
|
84
|
+
let parentId = parent ? getId(parent) : null;
|
|
85
|
+
let previousId = getPreviousId(node);
|
|
86
|
+
let privacy = config.content ? Privacy.Sensitive : Privacy.Text;
|
|
87
|
+
let parentValue = null;
|
|
88
|
+
let parentTag = Constant.Empty;
|
|
89
|
+
let regionId = region.exists(node) ? id : null;
|
|
90
|
+
|
|
91
|
+
if (parentId >= 0 && values[parentId]) {
|
|
92
|
+
parentValue = values[parentId];
|
|
93
|
+
parentTag = parentValue.data.tag;
|
|
94
|
+
parentValue.children.push(id);
|
|
95
|
+
regionId = regionId === null ? parentValue.region : regionId;
|
|
96
|
+
privacy = parentValue.metadata.privacy;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check to see if this particular node should be masked or not
|
|
100
|
+
privacy = getPrivacy(node, data, parentTag, privacy);
|
|
101
|
+
|
|
102
|
+
// If there's an explicit region attribute set on the element, use it to mark a region on the page
|
|
103
|
+
if (data.attributes && Constant.RegionData in data.attributes) {
|
|
104
|
+
region.observe(node, data.attributes[Constant.RegionData]);
|
|
105
|
+
regionId = id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
nodes[id] = node;
|
|
109
|
+
values[id] = {
|
|
110
|
+
id,
|
|
111
|
+
parent: parentId,
|
|
112
|
+
previous: previousId,
|
|
113
|
+
children: [],
|
|
114
|
+
position: null,
|
|
115
|
+
data,
|
|
116
|
+
selector: Constant.Empty,
|
|
117
|
+
region: regionId,
|
|
118
|
+
metadata: { active: true, privacy, size: null }
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
updateSelector(values[id]);
|
|
122
|
+
size(values[id], parentValue);
|
|
123
|
+
metadata(data.tag, id, parentId);
|
|
124
|
+
track(id, source);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function update(node: Node, parent: Node, data: NodeInfo, source: Source): void {
|
|
128
|
+
let id = getId(node);
|
|
129
|
+
let parentId = parent ? getId(parent) : null;
|
|
130
|
+
let previousId = getPreviousId(node);
|
|
131
|
+
let changed = false;
|
|
132
|
+
let parentChanged = false;
|
|
133
|
+
|
|
134
|
+
if (id in values) {
|
|
135
|
+
let value = values[id];
|
|
136
|
+
value.metadata.active = true;
|
|
137
|
+
|
|
138
|
+
// Handle case where internal ordering may have changed
|
|
139
|
+
if (value.previous !== previousId) {
|
|
140
|
+
changed = true;
|
|
141
|
+
value.previous = previousId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Handle case where parent might have been updated
|
|
145
|
+
if (value.parent !== parentId) {
|
|
146
|
+
changed = true;
|
|
147
|
+
let oldParentId = value.parent;
|
|
148
|
+
value.parent = parentId;
|
|
149
|
+
// Move this node to the right location under new parent
|
|
150
|
+
if (parentId !== null && parentId >= 0) {
|
|
151
|
+
let childIndex = previousId === null ? 0 : values[parentId].children.indexOf(previousId) + 1;
|
|
152
|
+
values[parentId].children.splice(childIndex, 0, id);
|
|
153
|
+
// Update region after the move
|
|
154
|
+
value.region = region.exists(node) ? id : values[parentId].region;
|
|
155
|
+
} else {
|
|
156
|
+
// Mark this element as deleted if the parent has been updated to null
|
|
157
|
+
remove(id, source);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Remove reference to this node from the old parent
|
|
161
|
+
if (oldParentId !== null && oldParentId >= 0) {
|
|
162
|
+
let nodeIndex = values[oldParentId].children.indexOf(id);
|
|
163
|
+
if (nodeIndex >= 0) {
|
|
164
|
+
values[oldParentId].children.splice(nodeIndex, 1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
parentChanged = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Update data
|
|
171
|
+
for (let key in data) {
|
|
172
|
+
if (diff(value["data"], data, key)) {
|
|
173
|
+
changed = true;
|
|
174
|
+
value["data"][key] = data[key];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Update selector
|
|
179
|
+
updateSelector(value);
|
|
180
|
+
metadata(data.tag, id, parentId);
|
|
181
|
+
track(id, source, changed, parentChanged);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function sameorigin(node: Node): boolean {
|
|
186
|
+
let output = false;
|
|
187
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === Constant.IFrameTag) {
|
|
188
|
+
let frame = node as HTMLIFrameElement;
|
|
189
|
+
// To determine if the iframe is same-origin or not, we try accessing it's contentDocument.
|
|
190
|
+
// If the browser throws an exception, we assume it's cross-origin and move on.
|
|
191
|
+
// However, if we do a get a valid document object back, we assume the contents are accessible and iframe is same-origin.
|
|
192
|
+
try {
|
|
193
|
+
let doc = frame.contentDocument;
|
|
194
|
+
if (doc) {
|
|
195
|
+
iframeMap.set(frame.contentDocument, frame);
|
|
196
|
+
output = true;
|
|
197
|
+
}
|
|
198
|
+
} catch { /* do nothing */ }
|
|
199
|
+
}
|
|
200
|
+
return output;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function iframe(node: Node): HTMLIFrameElement {
|
|
204
|
+
let doc = node.nodeType === Node.DOCUMENT_NODE ? node as Document : null;
|
|
205
|
+
return doc && iframeMap.has(doc) ? iframeMap.get(doc) : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getPrivacy(node: Node, data: NodeInfo, parentTag: string, privacy: Privacy): Privacy {
|
|
209
|
+
let attributes = data.attributes;
|
|
210
|
+
let tag = data.tag.toUpperCase();
|
|
211
|
+
|
|
212
|
+
// If this node was explicitly configured to contain sensitive content, use that information and return the value
|
|
213
|
+
if (privacyMap.has(node)) { return privacyMap.get(node); }
|
|
214
|
+
|
|
215
|
+
// Do not proceed if attributes are missing for the node
|
|
216
|
+
if (attributes === null || attributes === undefined) { return privacy; }
|
|
217
|
+
|
|
218
|
+
// Look up for sensitive fields
|
|
219
|
+
if (Constant.Class in attributes && privacy === Privacy.Sensitive) {
|
|
220
|
+
for (let match of DISALLOWED_MATCH) {
|
|
221
|
+
if (attributes[Constant.Class].indexOf(match) >= 0) {
|
|
222
|
+
privacy = Privacy.Text;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check for disallowed list of fields (e.g. address, phone, etc.) only if the input node is not already masked
|
|
229
|
+
if (tag === Constant.InputTag) {
|
|
230
|
+
if (privacy === Privacy.None) {
|
|
231
|
+
let field: string = Constant.Empty;
|
|
232
|
+
// Be aggressive in looking up any attribute (id, class, name, etc.) for disallowed names
|
|
233
|
+
for (const attribute of Object.keys(attributes)) { field += attributes[attribute].toLowerCase(); }
|
|
234
|
+
for (let name of DISALLOWED_NAMES) {
|
|
235
|
+
if (field.indexOf(name) >= 0) {
|
|
236
|
+
privacy = Privacy.Text;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} else if (privacy === Privacy.Sensitive) {
|
|
241
|
+
// Mask all input fields with an exception of type=submit; since they do not accept user input
|
|
242
|
+
privacy = attributes && attributes[Constant.Type] === Constant.Submit ? Privacy.None : Privacy.Text;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check for disallowed list of types (e.g. password, email, etc.) and set the masked property appropriately
|
|
247
|
+
if (Constant.Type in attributes && DISALLOWED_TYPES.indexOf(attributes[Constant.Type]) >= 0) { privacy = Privacy.Text; }
|
|
248
|
+
|
|
249
|
+
// Following two conditions supersede any of the above. If there are explicit instructions to mask / unmask a field, we honor that.
|
|
250
|
+
if (Constant.MaskData in attributes) { privacy = Privacy.TextImage; }
|
|
251
|
+
if (Constant.UnmaskData in attributes) { privacy = Privacy.None; }
|
|
252
|
+
|
|
253
|
+
// If it's a text node belonging to a STYLE or TITLE tag; then reset the privacy setting to ensure we capture the content
|
|
254
|
+
let cTag = tag === Constant.TextTag ? parentTag : tag;
|
|
255
|
+
if (cTag === Constant.StyleTag || cTag === Constant.TitleTag) { privacy = Privacy.None; }
|
|
256
|
+
|
|
257
|
+
return privacy;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function diff(a: NodeInfo, b: NodeInfo, field: string): boolean {
|
|
261
|
+
if (typeof a[field] === "object" && typeof b[field] === "object") {
|
|
262
|
+
for (let key in a[field]) { if (a[field][key] !== b[field][key]) { return true; } }
|
|
263
|
+
for (let key in b[field]) { if (b[field][key] !== a[field][key]) { return true; } }
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
return a[field] !== b[field];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function position(parent: NodeValue, child: NodeValue): number {
|
|
270
|
+
let tag = child.data.tag;
|
|
271
|
+
let hasClassName = child.data.attributes && !(Constant.Class in child.data.attributes);
|
|
272
|
+
// Find relative position of the element to generate :nth-of-type selector
|
|
273
|
+
// We restrict relative positioning to two cases:
|
|
274
|
+
// a) For specific whitelist of tags
|
|
275
|
+
// b) And, for remaining tags, only if they don't have a valid class name
|
|
276
|
+
if (parent && (["DIV", "TR", "P", "LI", "UL", "A", "BUTTON"].indexOf(tag) >= 0 || hasClassName)) {
|
|
277
|
+
child.position = 1;
|
|
278
|
+
let idx = parent ? parent.children.indexOf(child.id) : -1;
|
|
279
|
+
while (idx-- > 0) {
|
|
280
|
+
let sibling = values[parent.children[idx]];
|
|
281
|
+
if (child.data.tag === sibling.data.tag) {
|
|
282
|
+
child.position = sibling.position + 1;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return child.position;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function updateSelector(value: NodeValue): void {
|
|
291
|
+
let parent = value.parent && value.parent in values ? values[value.parent] : null;
|
|
292
|
+
let prefix = parent ? `${parent.selector}>` : null;
|
|
293
|
+
let ex = value.selector;
|
|
294
|
+
let current = selector(value.data.tag, prefix, value.data.attributes, position(parent, value));
|
|
295
|
+
if (current !== ex && selectorMap.indexOf(value.id) === -1) { selectorMap.push(value.id); }
|
|
296
|
+
value.selector = current;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getNode(id: number): Node {
|
|
300
|
+
if (id in nodes) {
|
|
301
|
+
return nodes[id];
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function getMatch(url: string): Node {
|
|
307
|
+
if (url in urlMap) {
|
|
308
|
+
return getNode(urlMap[url]);
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function getValue(id: number): NodeValue {
|
|
314
|
+
if (id in values) {
|
|
315
|
+
return values[id];
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function get(node: Node): NodeValue {
|
|
321
|
+
let id = getId(node);
|
|
322
|
+
return id in values ? values[id] : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function has(node: Node): boolean {
|
|
326
|
+
return getId(node) in nodes;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function updates(): NodeValue[] {
|
|
330
|
+
let output = [];
|
|
331
|
+
for (let id of updateMap) {
|
|
332
|
+
if (id in values) {
|
|
333
|
+
let v = values[id];
|
|
334
|
+
let p = v.parent;
|
|
335
|
+
v.data.path = p === null || updateMap.indexOf(p) >= 0 || v.selector.length === 0 ? null : values[p].selector;
|
|
336
|
+
output.push(values[id]);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
updateMap = [];
|
|
340
|
+
return output;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function remove(id: number, source: Source): void {
|
|
344
|
+
if (id in values) {
|
|
345
|
+
let value = values[id];
|
|
346
|
+
value.metadata.active = false;
|
|
347
|
+
value.parent = null;
|
|
348
|
+
track(id, source);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function size(value: NodeValue, parent: NodeValue): void {
|
|
353
|
+
let data = value.data;
|
|
354
|
+
let tag = data.tag;
|
|
355
|
+
|
|
356
|
+
// If this element is a text node, is masked, and longer than configured length, then track box model for the parent element
|
|
357
|
+
let isLongText = tag === Constant.TextTag && data.value && data.value.length > Setting.ResizeObserverThreshold;
|
|
358
|
+
let isMasked = value.metadata.privacy === Privacy.Text || value.metadata.privacy === Privacy.TextImage;
|
|
359
|
+
if (isLongText && isMasked && parent && parent.metadata.size === null) { parent.metadata.size = []; }
|
|
360
|
+
|
|
361
|
+
// If this element is a image node, and is masked, then track box model for the current element
|
|
362
|
+
if (data.tag === Constant.ImageTag && value.metadata.privacy === Privacy.TextImage) { value.metadata.size = []; }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function metadata(tag: string, id: number, parentId: number): void {
|
|
366
|
+
if (id !== null && parentId !== null) {
|
|
367
|
+
let value = values[id];
|
|
368
|
+
let attributes = "attributes" in value.data ? value.data.attributes : {};
|
|
369
|
+
switch (tag) {
|
|
370
|
+
case "VIDEO":
|
|
371
|
+
case "AUDIO":
|
|
372
|
+
case "LINK":
|
|
373
|
+
// Track mapping between URL and corresponding nodes
|
|
374
|
+
if (Constant.Href in attributes && attributes[Constant.Href].length > 0) {
|
|
375
|
+
urlMap[getFullUrl(attributes[Constant.Href])] = id;
|
|
376
|
+
}
|
|
377
|
+
if (Constant.Src in attributes && attributes[Constant.Src].length > 0) {
|
|
378
|
+
if (attributes[Constant.Src].indexOf(Constant.DataPrefix) !== 0) {
|
|
379
|
+
urlMap[getFullUrl(attributes[Constant.Src])] = id;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (Constant.Srcset in attributes && attributes[Constant.Srcset].length > 0) {
|
|
383
|
+
let srcset = attributes[Constant.Srcset];
|
|
384
|
+
let urls = srcset.split(",");
|
|
385
|
+
for (let u of urls) {
|
|
386
|
+
let parts = u.trim().split(" ");
|
|
387
|
+
if (parts.length === 2 && parts[0].length > 0) {
|
|
388
|
+
urlMap[getFullUrl(parts[0])] = id;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getFullUrl(relative: string): string {
|
|
398
|
+
let a = document.createElement("a");
|
|
399
|
+
a.href = relative;
|
|
400
|
+
return a.href;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getPreviousId(node: Node): number {
|
|
404
|
+
let id = null;
|
|
405
|
+
|
|
406
|
+
// Some nodes may not have an ID by design since Clarity skips over tags like SCRIPT, NOSCRIPT, META, COMMENTS, etc..
|
|
407
|
+
// In that case, we keep going back and check for their sibling until we find a sibling with ID or no more sibling nodes are left.
|
|
408
|
+
while (id === null && node.previousSibling) {
|
|
409
|
+
id = getId(node.previousSibling);
|
|
410
|
+
node = node.previousSibling;
|
|
411
|
+
}
|
|
412
|
+
return id;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function copy(input: NodeValue[]): NodeValue[] {
|
|
416
|
+
return JSON.parse(JSON.stringify(input));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function track(id: number, source: Source, changed: boolean = true, parentChanged: boolean = false): void {
|
|
420
|
+
// Keep track of the order in which mutations happened, they may not be sequential
|
|
421
|
+
// Edge case: If an element is added later on, and pre-discovered element is moved as a child.
|
|
422
|
+
// In that case, we need to reorder the pre-discovered element in the update list to keep visualization consistent.
|
|
423
|
+
let uIndex = updateMap.indexOf(id);
|
|
424
|
+
if (uIndex >= 0 && source === Source.ChildListAdd && parentChanged) {
|
|
425
|
+
updateMap.splice(uIndex, 1);
|
|
426
|
+
updateMap.push(id);
|
|
427
|
+
} else if (uIndex === -1 && changed) { updateMap.push(id); }
|
|
428
|
+
|
|
429
|
+
if (Constant.DevHook in window) {
|
|
430
|
+
let value = copy([values[id]])[0];
|
|
431
|
+
let change = { time: time(), source, value };
|
|
432
|
+
if (!(id in changes)) { changes[id] = []; }
|
|
433
|
+
changes[id].push(change);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function history(id: number): NodeChange[] {
|
|
438
|
+
if (id in changes) {
|
|
439
|
+
return changes[id];
|
|
440
|
+
}
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Privacy, Task, Timer } from "@clarity-types/core";
|
|
2
|
+
import { Event, Token } from "@clarity-types/data";
|
|
3
|
+
import { Constant, NodeInfo, NodeValue } from "@clarity-types/layout";
|
|
4
|
+
import config from "@src/core/config";
|
|
5
|
+
import scrub from "@src/core/scrub";
|
|
6
|
+
import * as task from "@src/core/task";
|
|
7
|
+
import { time } from "@src/core/time";
|
|
8
|
+
import tokenize from "@src/data/token";
|
|
9
|
+
import * as baseline from "@src/data/baseline";
|
|
10
|
+
import { queue } from "@src/data/upload";
|
|
11
|
+
import * as box from "./box";
|
|
12
|
+
import * as doc from "./document";
|
|
13
|
+
import * as dom from "./dom";
|
|
14
|
+
import * as region from "./region";
|
|
15
|
+
|
|
16
|
+
export default async function (type: Event, timer: Timer = null, ts: number = null): Promise<void> {
|
|
17
|
+
let eventTime = ts || time()
|
|
18
|
+
let tokens: Token[] = [eventTime, type];
|
|
19
|
+
switch (type) {
|
|
20
|
+
case Event.Document:
|
|
21
|
+
let d = doc.data;
|
|
22
|
+
tokens.push(d.width);
|
|
23
|
+
tokens.push(d.height);
|
|
24
|
+
baseline.track(type, d.width, d.height);
|
|
25
|
+
queue(tokens);
|
|
26
|
+
break;
|
|
27
|
+
case Event.Region:
|
|
28
|
+
for (let r of region.state) {
|
|
29
|
+
tokens = [r.time, Event.Region];
|
|
30
|
+
tokens.push(r.data.id);
|
|
31
|
+
tokens.push(r.data.state);
|
|
32
|
+
tokens.push(r.data.name);
|
|
33
|
+
queue(tokens);
|
|
34
|
+
}
|
|
35
|
+
region.reset();
|
|
36
|
+
break;
|
|
37
|
+
case Event.Box:
|
|
38
|
+
let b = box.data;
|
|
39
|
+
for (let entry of b) {
|
|
40
|
+
tokens.push(entry.id);
|
|
41
|
+
tokens.push(entry.width);
|
|
42
|
+
tokens.push(entry.height);
|
|
43
|
+
}
|
|
44
|
+
box.reset();
|
|
45
|
+
queue(tokens);
|
|
46
|
+
break;
|
|
47
|
+
case Event.Discover:
|
|
48
|
+
case Event.Mutation:
|
|
49
|
+
// Check if we are operating within the context of the current page
|
|
50
|
+
if (task.state(timer) === Task.Stop) { break; }
|
|
51
|
+
let values = dom.updates();
|
|
52
|
+
// Only encode and queue DOM updates if we have valid updates to report back
|
|
53
|
+
if (values.length > 0) {
|
|
54
|
+
for (let value of values) {
|
|
55
|
+
let state = task.state(timer);
|
|
56
|
+
if (state === Task.Wait) { state = await task.suspend(timer); }
|
|
57
|
+
if (state === Task.Stop) { break; }
|
|
58
|
+
let data: NodeInfo = value.data;
|
|
59
|
+
let active = value.metadata.active;
|
|
60
|
+
let privacy = value.metadata.privacy;
|
|
61
|
+
let mangle = shouldMangle(value);
|
|
62
|
+
let keys = active ? ["tag", "path", "attributes", "value"] : ["tag"];
|
|
63
|
+
box.compute(value.id);
|
|
64
|
+
for (let key of keys) {
|
|
65
|
+
if (data[key]) {
|
|
66
|
+
switch (key) {
|
|
67
|
+
case "tag":
|
|
68
|
+
let size = value.metadata.size;
|
|
69
|
+
let factor = mangle ? -1 : 1;
|
|
70
|
+
tokens.push(value.id * factor);
|
|
71
|
+
if (value.parent && active) { tokens.push(value.parent); }
|
|
72
|
+
if (value.previous && active) { tokens.push(value.previous); }
|
|
73
|
+
tokens.push(value.position ? `${data[key]}~${value.position}` : data[key]);
|
|
74
|
+
if (size && size.length === 2) { tokens.push(`${Constant.Box}${str(size[0])}.${str(size[1])}`); }
|
|
75
|
+
break;
|
|
76
|
+
case "path":
|
|
77
|
+
tokens.push(`${value.data.path}>`);
|
|
78
|
+
break;
|
|
79
|
+
case "attributes":
|
|
80
|
+
for (let attr in data[key]) {
|
|
81
|
+
if (data[key][attr] !== undefined) {
|
|
82
|
+
tokens.push(attribute(attr, data[key][attr], privacy));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
case "value":
|
|
87
|
+
tokens.push(scrub(data[key], data.tag, privacy, mangle));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (type === Event.Mutation) { baseline.activity(eventTime); }
|
|
94
|
+
queue(tokenize(tokens), !config.lean);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shouldMangle(value: NodeValue): boolean {
|
|
101
|
+
let privacy = value.metadata.privacy;
|
|
102
|
+
return value.data.tag === Constant.TextTag && !(privacy === Privacy.None || privacy === Privacy.Sensitive);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function str(input: number): string {
|
|
106
|
+
return input.toString(36);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function attribute(key: string, value: string, privacy: Privacy): string {
|
|
110
|
+
return `${key}=${scrub(value, key, privacy)}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Extract, Metric, Region, RegionFilter } from "@clarity-types/core";
|
|
2
|
+
import { Constant } from "@clarity-types/data";
|
|
3
|
+
import * as metric from "@src/data/metric";
|
|
4
|
+
import * as region from "@src/layout/region";
|
|
5
|
+
|
|
6
|
+
const formatRegex = /1/g;
|
|
7
|
+
const digitsRegex = /[^0-9\.]/g;
|
|
8
|
+
const digitsWithCommaRegex = /[^0-9\.,]/g;
|
|
9
|
+
const regexCache: {[key: string]: RegExp} = {};
|
|
10
|
+
|
|
11
|
+
export function regions(root: ParentNode, value: Region[]): void {
|
|
12
|
+
for (let v of value) {
|
|
13
|
+
const [regionId, selector, filter, match] = v;
|
|
14
|
+
let valid = true;
|
|
15
|
+
switch (filter) {
|
|
16
|
+
case RegionFilter.Url: valid = match && !!top.location.href.match(regex(match)); break;
|
|
17
|
+
case RegionFilter.Javascript: valid = match && !!evaluate(match); break;
|
|
18
|
+
}
|
|
19
|
+
if (valid) { root.querySelectorAll(selector).forEach(e => region.observe(e, regionId.toString())); }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function metrics(root: ParentNode, value: Metric[]): void {
|
|
24
|
+
for (let v of value) {
|
|
25
|
+
const [metricId, source, match, scale] = v;
|
|
26
|
+
if (match) {
|
|
27
|
+
switch (source) {
|
|
28
|
+
case Extract.Text: root.querySelectorAll(match).forEach(e => { metric.max(metricId, num((e as HTMLElement).innerText, scale)); }); break;
|
|
29
|
+
case Extract.Attribute: root.querySelectorAll(`[${match}]`).forEach(e => { metric.max(metricId, num(e.getAttribute(match), scale, false)); }); break;
|
|
30
|
+
case Extract.Javascript: metric.max(metricId, evaluate(match, Constant.Number) as number); break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function regex(match: string): RegExp {
|
|
37
|
+
regexCache[match] = match in regexCache ? regexCache[match] : new RegExp(match);
|
|
38
|
+
return regexCache[match];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The function below takes in a variable name in following format: "a.b.c" and safely evaluates its value in javascript context
|
|
42
|
+
// 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,
|
|
43
|
+
// return the value for window["a"]["b"]["c"].
|
|
44
|
+
function evaluate(variable: string, type: string = null, base: Object = window): any {
|
|
45
|
+
let parts = variable.split(Constant.Dot);
|
|
46
|
+
let first = parts.shift();
|
|
47
|
+
if (base && base[first]) {
|
|
48
|
+
if (parts.length > 0) { return evaluate(parts.join(Constant.Dot), type, base[first]); }
|
|
49
|
+
let output = type === null || type === typeof base[first] ? base[first] : null;
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function num(text: string, scale: number, localize: boolean = true): number {
|
|
56
|
+
try {
|
|
57
|
+
scale = scale || 1;
|
|
58
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
|
|
59
|
+
let lang = document.documentElement.lang;
|
|
60
|
+
if (Intl && Intl.NumberFormat && lang && localize) {
|
|
61
|
+
text = text.replace(digitsWithCommaRegex, Constant.Empty);
|
|
62
|
+
// Infer current group and decimal separator from current locale
|
|
63
|
+
let group = Intl.NumberFormat(lang).format(11111).replace(formatRegex, Constant.Empty);
|
|
64
|
+
let decimal = Intl.NumberFormat(lang).format(1.1).replace(formatRegex, Constant.Empty);
|
|
65
|
+
|
|
66
|
+
// Parse number using inferred group and decimal separators
|
|
67
|
+
return Math.round(parseFloat(text
|
|
68
|
+
.replace(new RegExp('\\' + group, 'g'), Constant.Empty)
|
|
69
|
+
.replace(new RegExp('\\' + decimal), Constant.Dot)
|
|
70
|
+
) * scale);
|
|
71
|
+
}
|
|
72
|
+
// Fallback to en locale
|
|
73
|
+
return Math.round(parseFloat(text.replace(digitsRegex, Constant.Empty)) * scale);
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as box from "@src/layout/box";
|
|
2
|
+
import * as discover from "@src/layout/discover";
|
|
3
|
+
import * as doc from "@src/layout/document";
|
|
4
|
+
import * as dom from "@src/layout/dom";
|
|
5
|
+
import * as mutation from "@src/layout/mutation";
|
|
6
|
+
import * as region from "@src/layout/region";
|
|
7
|
+
|
|
8
|
+
export function start(): void {
|
|
9
|
+
// The order below is important
|
|
10
|
+
// and is determined by interdependencies of modules
|
|
11
|
+
doc.start();
|
|
12
|
+
region.start();
|
|
13
|
+
dom.start();
|
|
14
|
+
mutation.start();
|
|
15
|
+
discover.start();
|
|
16
|
+
box.start();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function stop(): void {
|
|
20
|
+
region.stop();
|
|
21
|
+
dom.stop();
|
|
22
|
+
mutation.stop();
|
|
23
|
+
box.stop();
|
|
24
|
+
doc.end();
|
|
25
|
+
}
|