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.
Files changed (87) hide show
  1. package/README.md +26 -0
  2. package/build/clarity.js +4479 -0
  3. package/build/clarity.min.js +1 -0
  4. package/build/clarity.module.js +4473 -0
  5. package/package.json +66 -0
  6. package/rollup.config.ts +38 -0
  7. package/src/clarity.ts +54 -0
  8. package/src/core/config.ts +21 -0
  9. package/src/core/copy.ts +3 -0
  10. package/src/core/event.ts +25 -0
  11. package/src/core/hash.ts +19 -0
  12. package/src/core/history.ts +69 -0
  13. package/src/core/index.ts +79 -0
  14. package/src/core/measure.ts +17 -0
  15. package/src/core/report.ts +27 -0
  16. package/src/core/scrub.ts +102 -0
  17. package/src/core/task.ts +180 -0
  18. package/src/core/time.ts +14 -0
  19. package/src/core/timeout.ts +10 -0
  20. package/src/core/version.ts +2 -0
  21. package/src/data/baseline.ts +89 -0
  22. package/src/data/compress.ts +31 -0
  23. package/src/data/custom.ts +18 -0
  24. package/src/data/dimension.ts +42 -0
  25. package/src/data/encode.ts +109 -0
  26. package/src/data/envelope.ts +46 -0
  27. package/src/data/index.ts +43 -0
  28. package/src/data/limit.ts +42 -0
  29. package/src/data/metadata.ts +232 -0
  30. package/src/data/metric.ts +51 -0
  31. package/src/data/ping.ts +36 -0
  32. package/src/data/summary.ts +34 -0
  33. package/src/data/token.ts +39 -0
  34. package/src/data/upgrade.ts +36 -0
  35. package/src/data/upload.ts +250 -0
  36. package/src/data/variable.ts +46 -0
  37. package/src/diagnostic/encode.ts +40 -0
  38. package/src/diagnostic/image.ts +23 -0
  39. package/src/diagnostic/index.ts +14 -0
  40. package/src/diagnostic/internal.ts +41 -0
  41. package/src/diagnostic/script.ts +45 -0
  42. package/src/global.ts +22 -0
  43. package/src/index.ts +8 -0
  44. package/src/interaction/click.ts +140 -0
  45. package/src/interaction/encode.ts +140 -0
  46. package/src/interaction/index.ts +45 -0
  47. package/src/interaction/input.ts +64 -0
  48. package/src/interaction/pointer.ts +108 -0
  49. package/src/interaction/resize.ts +30 -0
  50. package/src/interaction/scroll.ts +73 -0
  51. package/src/interaction/selection.ts +66 -0
  52. package/src/interaction/timeline.ts +65 -0
  53. package/src/interaction/unload.ts +25 -0
  54. package/src/interaction/visibility.ts +24 -0
  55. package/src/layout/box.ts +83 -0
  56. package/src/layout/discover.ts +27 -0
  57. package/src/layout/document.ts +46 -0
  58. package/src/layout/dom.ts +442 -0
  59. package/src/layout/encode.ts +111 -0
  60. package/src/layout/extract.ts +75 -0
  61. package/src/layout/index.ts +25 -0
  62. package/src/layout/mutation.ts +232 -0
  63. package/src/layout/node.ts +211 -0
  64. package/src/layout/offset.ts +19 -0
  65. package/src/layout/region.ts +143 -0
  66. package/src/layout/schema.ts +66 -0
  67. package/src/layout/selector.ts +24 -0
  68. package/src/layout/target.ts +44 -0
  69. package/src/layout/traverse.ts +28 -0
  70. package/src/performance/connection.ts +37 -0
  71. package/src/performance/encode.ts +40 -0
  72. package/src/performance/index.ts +15 -0
  73. package/src/performance/navigation.ts +31 -0
  74. package/src/performance/observer.ts +87 -0
  75. package/test/core.test.ts +82 -0
  76. package/test/helper.ts +104 -0
  77. package/test/html/core.html +17 -0
  78. package/test/tsconfig.test.json +6 -0
  79. package/tsconfig.json +21 -0
  80. package/tslint.json +33 -0
  81. package/types/core.d.ts +127 -0
  82. package/types/data.d.ts +344 -0
  83. package/types/diagnostic.d.ts +24 -0
  84. package/types/index.d.ts +30 -0
  85. package/types/interaction.d.ts +110 -0
  86. package/types/layout.d.ts +200 -0
  87. 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
+ }