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,232 @@
1
+ import { Priority, Task, Timer } from "@clarity-types/core";
2
+ import { Code, Event, Metric, Severity } from "@clarity-types/data";
3
+ import { Constant, MutationHistory, MutationQueue, Setting, Source } from "@clarity-types/layout";
4
+ import { bind } from "@src/core/event";
5
+ import measure from "@src/core/measure";
6
+ import * as task from "@src/core/task";
7
+ import { time } from "@src/core/time";
8
+ import { clearTimeout, setTimeout } from "@src/core/timeout";
9
+ import { id } from "@src/data/metadata";
10
+ import * as summary from "@src/data/summary";
11
+ import * as internal from "@src/diagnostic/internal";
12
+ import * as doc from "@src/layout/document";
13
+ import * as dom from "@src/layout/dom";
14
+ import encode from "@src/layout/encode";
15
+ import * as region from "@src/layout/region";
16
+ import traverse from "@src/layout/traverse";
17
+ import processNode from "./node";
18
+
19
+ let observers: MutationObserver[] = [];
20
+ let mutations: MutationQueue[] = [];
21
+ let insertRule: (rule: string, index?: number) => number = null;
22
+ let deleteRule: (index?: number) => void = null;
23
+ let queue: Node[] = [];
24
+ let timeout: number = null;
25
+ let activePeriod = null;
26
+ let history: MutationHistory = {};
27
+
28
+
29
+ export function start(): void {
30
+ observers = [];
31
+ queue = [];
32
+ timeout = null;
33
+ activePeriod = 0;
34
+ history = {};
35
+
36
+ if (insertRule === null) { insertRule = CSSStyleSheet.prototype.insertRule; }
37
+ if (deleteRule === null) { deleteRule = CSSStyleSheet.prototype.deleteRule; }
38
+
39
+ // Some popular open source libraries, like styled-components, optimize performance
40
+ // by injecting CSS using insertRule API vs. appending text node. A side effect of
41
+ // using javascript API is that it doesn't trigger DOM mutation and therefore we
42
+ // need to override the insertRule API and listen for changes manually.
43
+ CSSStyleSheet.prototype.insertRule = function(): number {
44
+ schedule(this.ownerNode);
45
+ return insertRule.apply(this, arguments);
46
+ };
47
+
48
+ CSSStyleSheet.prototype.deleteRule = function(): void {
49
+ schedule(this.ownerNode);
50
+ return deleteRule.apply(this, arguments);
51
+ };
52
+ }
53
+
54
+ export function observe(node: Node): void {
55
+ // Create a new observer for every time a new DOM tree (e.g. root document or shadowdom root) is discovered on the page
56
+ // In the case of shadow dom, any mutations that happen within the shadow dom are not bubbled up to the host document
57
+ // For this reason, we need to wire up mutations every time we see a new shadow dom.
58
+ // Also, wrap it inside a try / catch. In certain browsers (e.g. legacy Edge), observer on shadow dom can throw errors
59
+ try {
60
+ // In an edge case, it's possible to get stuck into infinite Mutation loop within Angular applications
61
+ // This appears to be an issue with Zone.js package, see: https://github.com/angular/angular/issues/31712
62
+ // As a temporary work around, ensuring Clarity can invoke MutationObserver outside of Zone (and use native implementation instead)
63
+ let api: string = window[Constant.Zone] && Constant.Symbol in window[Constant.Zone] ? window[Constant.Zone][Constant.Symbol](Constant.MutationObserver) : Constant.MutationObserver;
64
+ let observer = api in window ? new window[api](measure(handle) as MutationCallback) : null;
65
+ if (observer) {
66
+ observer.observe(node, { attributes: true, childList: true, characterData: true, subtree: true });
67
+ observers.push(observer);
68
+ }
69
+ } catch (e) { internal.log(Code.MutationObserver, Severity.Info, e ? e.name : null); }
70
+ }
71
+
72
+ export function monitor(frame: HTMLIFrameElement): void {
73
+ // Bind to iframe's onload event so we get notified anytime there's an update to iframe content.
74
+ // This includes cases where iframe location is updated without explicitly updating src attribute
75
+ // E.g. iframe.contentWindow.location.href = "new-location";
76
+ if (dom.has(frame) === false) {
77
+ bind(frame, Constant.LoadEvent, generate.bind(this, frame, Constant.ChildList), true);
78
+ }
79
+ }
80
+
81
+ export function stop(): void {
82
+ for (let observer of observers) { if (observer) { observer.disconnect(); } }
83
+ observers = [];
84
+
85
+ // Restoring original insertRule
86
+ if (insertRule !== null) {
87
+ CSSStyleSheet.prototype.insertRule = insertRule;
88
+ insertRule = null;
89
+ }
90
+
91
+ // Restoring original deleteRule
92
+ if (deleteRule !== null) {
93
+ CSSStyleSheet.prototype.deleteRule = deleteRule;
94
+ deleteRule = null;
95
+ }
96
+
97
+ history = {};
98
+ mutations = [];
99
+ queue = [];
100
+ activePeriod = 0;
101
+ timeout = null;
102
+ }
103
+
104
+ export function active(): void {
105
+ activePeriod = time() + Setting.MutationActivePeriod;
106
+ }
107
+
108
+ function handle(m: MutationRecord[]): void {
109
+ // Queue up mutation records for asynchronous processing
110
+ let now = time();
111
+ summary.track(Event.Mutation, now);
112
+ mutations.push({ time: now, mutations: m});
113
+ task.schedule(process, Priority.High).then((): void => {
114
+ measure(doc.compute)();
115
+ measure(region.compute)();
116
+ });
117
+ }
118
+
119
+ async function process(): Promise<void> {
120
+ let timer: Timer = { id: id(), cost: Metric.LayoutCost };
121
+ task.start(timer);
122
+ while (mutations.length > 0) {
123
+ let record = mutations.shift();
124
+ for (let mutation of record.mutations) {
125
+ let state = task.state(timer);
126
+ if (state === Task.Wait) { state = await task.suspend(timer); }
127
+ if (state === Task.Stop) { break; }
128
+ let target = mutation.target;
129
+ let type = track(mutation, timer);
130
+ if (type && target && target.ownerDocument) { dom.parse(target.ownerDocument); }
131
+ switch (type) {
132
+ case Constant.Attributes:
133
+ processNode(target, Source.Attributes);
134
+ break;
135
+ case Constant.CharacterData:
136
+ processNode(target, Source.CharacterData);
137
+ break;
138
+ case Constant.ChildList:
139
+ processNodeList(mutation.addedNodes, Source.ChildListAdd, timer);
140
+ processNodeList(mutation.removedNodes, Source.ChildListRemove, timer);
141
+ break;
142
+ case Constant.Suspend:
143
+ let value = dom.get(target);
144
+ if (value) { value.data.tag = Constant.SuspendMutationTag; }
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ await encode(Event.Mutation, timer, record.time);
151
+ }
152
+ task.stop(timer);
153
+ }
154
+
155
+ function track(m: MutationRecord, timer: Timer): string {
156
+ let value = m.target ? dom.get(m.target.parentNode) : null;
157
+ // Check if the parent is already discovered and that the parent is not the document root
158
+ if (value && value.selector !== Constant.HTML) {
159
+ let inactive = time() > activePeriod;
160
+ let target = dom.get(m.target);
161
+ let element = target ? target.selector : m.target.nodeName;
162
+ // We use selector, instead of id, to determine the key (signature for the mutation) because in some cases
163
+ // repeated mutations can cause elements to be destroyed and then recreated as new DOM nodes
164
+ // In those cases, IDs will change however the selector (which is relative to DOM xPath) remains the same
165
+ let key = [value.selector, element, m.attributeName, names(m.addedNodes), names(m.removedNodes)].join();
166
+ // Initialize an entry if it doesn't already exist
167
+ history[key] = key in history ? history[key] : [0];
168
+ let h = history[key];
169
+ // Lookup any pending nodes queued up for removal, and process them now if we suspended a mutation before
170
+ if (inactive === false && h[0] >= Setting.MutationSuspendThreshold) { processNodeList(h[1], Source.ChildListRemove, timer); }
171
+ // Update the counter
172
+ h[0] = inactive ? h[0] + 1 : 1;
173
+ // Return updated mutation type based on if we have already hit the threshold or not
174
+ if (h[0] === Setting.MutationSuspendThreshold) {
175
+ // Store a reference to removedNodes so we can process them later
176
+ // when we resume mutations again on user interactions
177
+ h[1] = m.removedNodes;
178
+ return Constant.Suspend;
179
+ } else if (h[0] > Setting.MutationSuspendThreshold) { return Constant.Empty; }
180
+ }
181
+ return m.type;
182
+ }
183
+
184
+ function names(nodes: NodeList): string {
185
+ let output: string[] = [];
186
+ for (let i = 0; nodes && i < nodes.length; i++) { output.push(nodes[i].nodeName); }
187
+ return output.join();
188
+ }
189
+
190
+ async function processNodeList(list: NodeList, source: Source, timer: Timer): Promise<void> {
191
+ let length = list ? list.length : 0;
192
+ for (let i = 0; i < length; i++) {
193
+ if (source === Source.ChildListAdd) {
194
+ traverse(list[i], timer, source);
195
+ } else {
196
+ let state = task.state(timer);
197
+ if (state === Task.Wait) { state = await task.suspend(timer); }
198
+ if (state === Task.Stop) { break; }
199
+ processNode(list[i], source);
200
+ }
201
+ }
202
+ }
203
+
204
+ function schedule(node: Node): void {
205
+ // Only schedule manual trigger for this node if it's not already in the queue
206
+ if (queue.indexOf(node) < 0) { queue.push(node); }
207
+
208
+ // Cancel any previous trigger before scheduling a new one.
209
+ // It's common for a webpage to call multiple synchronous "insertRule" / "deleteRule" calls.
210
+ // And in those cases we do not wish to monitor changes multiple times for the same node.
211
+ if (timeout) { clearTimeout(timeout); }
212
+ timeout = setTimeout(trigger, Setting.LookAhead);
213
+ }
214
+
215
+ function trigger(): void {
216
+ for (let node of queue) { generate(node, Constant.CharacterData); }
217
+ queue = [];
218
+ }
219
+
220
+ function generate(target: Node, type: MutationRecordType): void {
221
+ measure(handle)([{
222
+ addedNodes: [target],
223
+ attributeName: null,
224
+ attributeNamespace: null,
225
+ nextSibling: null,
226
+ oldValue: null,
227
+ previousSibling: null,
228
+ removedNodes: [],
229
+ target,
230
+ type
231
+ }]);
232
+ }
@@ -0,0 +1,211 @@
1
+ import { Constant, Source } from "@clarity-types/layout";
2
+ import { Code, Dimension, Severity } from "@clarity-types/data";
3
+ import config from "@src/core/config";
4
+ import * as dom from "./dom";
5
+ import * as dimension from "@src/data/dimension";
6
+ import * as internal from "@src/diagnostic/internal";
7
+ import * as interaction from "@src/interaction";
8
+ import * as mutation from "@src/layout/mutation";
9
+ import * as schema from "@src/layout/schema";
10
+
11
+ const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus", "onerror"];
12
+
13
+ export default function (node: Node, source: Source): Node {
14
+ let child: Node = null;
15
+
16
+ // Do not track this change if we are attempting to remove a node before discovering it
17
+ if (source === Source.ChildListRemove && dom.has(node) === false) { return child; }
18
+
19
+ // Special handling for text nodes that belong to style nodes
20
+ if (source !== Source.Discover &&
21
+ node.nodeType === Node.TEXT_NODE &&
22
+ node.parentElement &&
23
+ node.parentElement.tagName === "STYLE") {
24
+ node = node.parentNode;
25
+ }
26
+
27
+ let add = dom.has(node) === false;
28
+ let call = add ? "add" : "update";
29
+ let parent = node.parentElement ? node.parentElement : null;
30
+ let insideFrame = node.ownerDocument !== document;
31
+ switch (node.nodeType) {
32
+ case Node.DOCUMENT_TYPE_NODE:
33
+ parent = insideFrame && node.parentNode ? dom.iframe(node.parentNode) : parent;
34
+ let docTypePrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
35
+ let doctype = node as DocumentType;
36
+ let docAttributes = { name: doctype.name, publicId: doctype.publicId, systemId: doctype.systemId };
37
+ let docData = { tag: docTypePrefix + Constant.DocumentTag, attributes: docAttributes };
38
+ dom[call](node, parent, docData, source);
39
+ break;
40
+ case Node.DOCUMENT_NODE:
41
+ // We check for regions in the beginning when discovering document and
42
+ // later whenever there are new additions or modifications to DOM (mutations)
43
+ if (node === document) dom.parse(document);
44
+ observe(node);
45
+ break;
46
+ case Node.DOCUMENT_FRAGMENT_NODE:
47
+ let shadowRoot = (node as ShadowRoot);
48
+ if (shadowRoot.host) {
49
+ let type = typeof (shadowRoot.constructor);
50
+ if (type === Constant.Function && shadowRoot.constructor.toString().indexOf(Constant.NativeCode) >= 0) {
51
+ observe(shadowRoot);
52
+ // See: https://wicg.github.io/construct-stylesheets/ for more details on adoptedStyleSheets.
53
+ // At the moment, we are only able to capture "open" shadow DOM nodes. If they are closed, they are not accessible.
54
+ // In future we may decide to proxy "attachShadow" call to gain access, but at the moment, we don't want to
55
+ // cause any unintended side effect to the page. We will re-evaluate after we gather more real world data on this.
56
+ let style = Constant.Empty as string;
57
+ let adoptedStyleSheets: CSSStyleSheet[] = "adoptedStyleSheets" in shadowRoot ? shadowRoot["adoptedStyleSheets"] : [];
58
+ for (let styleSheet of adoptedStyleSheets) { style += getCssRules(styleSheet); }
59
+ let fragementData = { tag: Constant.ShadowDomTag, attributes: { style } };
60
+ dom[call](node, shadowRoot.host, fragementData, source);
61
+ } else {
62
+ // If the browser doesn't support shadow DOM natively, we detect that, and send appropriate tag back.
63
+ // The differentiation is important because we don't have to observe pollyfill shadow DOM nodes,
64
+ // the same way we observe real shadow DOM nodes (encapsulation provided by the browser).
65
+ dom[call](node, shadowRoot.host, { tag: Constant.PolyfillShadowDomTag, attributes: {} }, source);
66
+ }
67
+ }
68
+ break;
69
+ case Node.TEXT_NODE:
70
+ // In IE11 TEXT_NODE doesn't expose a valid parentElement property. Instead we need to lookup parentNode property.
71
+ parent = parent ? parent : node.parentNode as HTMLElement;
72
+ // Account for this text node only if we are tracking the parent node
73
+ // We do not wish to track text nodes for ignored parent nodes, like script tags
74
+ // Also, we do not track text nodes for STYLE tags
75
+ // The only exception is when we receive a mutation to remove the text node, in that case
76
+ // parent will be null, but we can still process the node by checking it's an update call.
77
+ if (call === "update" || (parent && dom.has(parent) && parent.tagName !== "STYLE")) {
78
+ let textData = { tag: Constant.TextTag, value: node.nodeValue };
79
+ dom[call](node, parent, textData, source);
80
+ }
81
+ break;
82
+ case Node.ELEMENT_NODE:
83
+ let element = (node as HTMLElement);
84
+ let tag = element.tagName;
85
+ let attributes = getAttributes(element);
86
+ // In some cases, external libraries like vue-fragment, can modify parentNode property to not be in sync with the DOM
87
+ // For correctness, we first look at parentElement and if it not present then fall back to using parentNode
88
+ parent = node.parentElement ? node.parentElement : (node.parentNode ? node.parentNode as HTMLElement : null);
89
+ // If we encounter a node that is part of SVG namespace, prefix the tag with SVG_PREFIX
90
+ if (element.namespaceURI === Constant.SvgNamespace) { tag = Constant.SvgPrefix + tag; }
91
+
92
+ switch (tag) {
93
+ case "HTML":
94
+ parent = insideFrame && parent ? dom.iframe(parent) : null;
95
+ let htmlPrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
96
+ let htmlData = { tag: htmlPrefix + tag, attributes };
97
+ dom[call](node, parent, htmlData, source);
98
+ break;
99
+ case "SCRIPT":
100
+ if (Constant.Type in attributes && attributes[Constant.Type] === Constant.JsonLD) {
101
+ try {
102
+ schema.ld(JSON.parse((element as HTMLScriptElement).text));
103
+ } catch { /* do nothing */ }
104
+ }
105
+ break;
106
+ case "NOSCRIPT":
107
+ break;
108
+ case "META":
109
+ if (Constant.Property in attributes && Constant.Content in attributes) {
110
+ let content = attributes[Constant.Content]
111
+ switch(attributes[Constant.Property]) {
112
+ case Constant.ogTitle:
113
+ dimension.log(Dimension.MetaTitle, content)
114
+ break;
115
+ case Constant.ogType:
116
+ dimension.log(Dimension.MetaType, content)
117
+ break;
118
+ case Constant.Generator:
119
+ dimension.log(Dimension.Generator, content)
120
+ break;
121
+ }
122
+ }
123
+ break;
124
+ case "HEAD":
125
+ let head = { tag, attributes };
126
+ if (location) { head.attributes[Constant.Base] = location.protocol + "//" + location.hostname; }
127
+ dom[call](node, parent, head, source);
128
+ break;
129
+ case "STYLE":
130
+ let styleData = { tag, attributes, value: getStyleValue(element as HTMLStyleElement) };
131
+ dom[call](node, parent, styleData, source);
132
+ break;
133
+ case "IFRAME":
134
+ let iframe = node as HTMLIFrameElement;
135
+ let frameData = { tag, attributes };
136
+ if (dom.sameorigin(iframe)) {
137
+ mutation.monitor(iframe);
138
+ frameData.attributes[Constant.SameOrigin] = "true";
139
+ if (iframe.contentDocument && iframe.contentWindow && iframe.contentDocument.readyState !== "loading") {
140
+ child = iframe.contentDocument;
141
+ }
142
+ }
143
+ dom[call](node, parent, frameData, source);
144
+ break;
145
+ default:
146
+ let data = { tag, attributes };
147
+ if (element.shadowRoot) { child = element.shadowRoot; }
148
+ dom[call](node, parent, data, source);
149
+ break;
150
+ }
151
+ break;
152
+ default:
153
+ break;
154
+ }
155
+ return child;
156
+ }
157
+
158
+ function observe(root: Node): void {
159
+ if (dom.has(root)) { return; }
160
+ mutation.observe(root); // Observe mutations for this root node
161
+ interaction.observe(root); // Observe interactions for this root node
162
+ }
163
+
164
+ function getStyleValue(style: HTMLStyleElement): string {
165
+ // Call trim on the text content to ensure we do not process white spaces ( , \n, \r\n, \t, etc.)
166
+ // Also, check if stylesheet has any data-* attribute, if so process rules instead of looking up text
167
+ let value = style.textContent ? style.textContent.trim() : Constant.Empty;
168
+ let dataset = style.dataset ? Object.keys(style.dataset).length : 0;
169
+ if (value.length === 0 || dataset > 0 || config.cssRules) {
170
+ value = getCssRules(style.sheet as CSSStyleSheet);
171
+ }
172
+ return value;
173
+ }
174
+
175
+ function getCssRules(sheet: CSSStyleSheet): string {
176
+ let value = Constant.Empty as string;
177
+ let cssRules = null;
178
+ // Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain
179
+ try { cssRules = sheet ? sheet.cssRules : []; } catch (e) {
180
+ internal.log(Code.CssRules, Severity.Warning, e ? e.name : null);
181
+ if (e && e.name !== "SecurityError") { throw e; }
182
+ }
183
+
184
+ if (cssRules !== null) {
185
+ for (let i = 0; i < cssRules.length; i++) {
186
+ value += cssRules[i].cssText;
187
+ }
188
+ }
189
+
190
+ return value;
191
+ }
192
+
193
+ function getAttributes(element: HTMLElement): { [key: string]: string } {
194
+ let output = {};
195
+ let attributes = element.attributes;
196
+ if (attributes && attributes.length > 0) {
197
+ for (let i = 0; i < attributes.length; i++) {
198
+ let name = attributes[i].name;
199
+ if (IGNORE_ATTRIBUTES.indexOf(name) < 0) {
200
+ output[name] = attributes[i].value;
201
+ }
202
+ }
203
+ }
204
+
205
+ // For INPUT tags read the dynamic "value" property if an explicit "value" attribute is not set
206
+ if (element.tagName === Constant.InputTag && !(Constant.Value in output) && (element as HTMLInputElement).value) {
207
+ output[Constant.Value] = (element as HTMLInputElement).value;
208
+ }
209
+
210
+ return output;
211
+ }
@@ -0,0 +1,19 @@
1
+ import { OffsetDistance } from "@clarity-types/core";
2
+ import { iframe } from "@src/layout/dom";
3
+
4
+ export default function(element: HTMLElement): OffsetDistance {
5
+ let output: OffsetDistance = { x: 0, y: 0 };
6
+
7
+ // Walk up the chain to ensure we compute offset distance correctly
8
+ // In case where we may have nested IFRAMEs, we keep walking up until we get to the top most parent page
9
+ if (element && element.offsetParent) {
10
+ do {
11
+ let parent = element.offsetParent as HTMLElement;
12
+ let frame = parent === null ? iframe(element.ownerDocument) : null;
13
+ output.x += element.offsetLeft;
14
+ output.y += element.offsetTop;
15
+ element = frame ? frame : parent;
16
+ } while (element);
17
+ }
18
+ return output;
19
+ }
@@ -0,0 +1,143 @@
1
+ import { Event, Setting } from "@clarity-types/data";
2
+ import { InteractionState, RegionData, RegionState, RegionQueue } from "@clarity-types/layout";
3
+ import { time } from "@src/core/time";
4
+ import * as dom from "@src/layout/dom";
5
+ import encode from "@src/layout/encode";
6
+
7
+ export let state: RegionState[] = [];
8
+ let regionMap: WeakMap<Node, string> = null; // Maps region nodes => region name
9
+ let regions: { [key: number]: RegionData } = {};
10
+ let queue: RegionQueue[] = [];
11
+ let watch = false;
12
+ let observer: IntersectionObserver = null;
13
+
14
+ export function start(): void {
15
+ reset();
16
+ observer = null;
17
+ regionMap = new WeakMap();
18
+ regions = {};
19
+ queue = [];
20
+ watch = window["IntersectionObserver"] ? true : false;
21
+
22
+ }
23
+
24
+ export function observe(node: Node, name: string): void {
25
+ if (regionMap.has(node) === false) {
26
+ regionMap.set(node, name);
27
+ observer = observer === null && watch ? new IntersectionObserver(handler, {
28
+ // Get notified as intersection continues to change
29
+ // This allows us to process regions that get partially hidden during the lifetime of the page
30
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
31
+ // By default, intersection observers only fire an event when even a single pixel is visible and not thereafter.
32
+ threshold: [0,0.2,0.4,0.6,0.8,1]
33
+ }) : observer;
34
+ if (observer && node && node.nodeType === Node.ELEMENT_NODE) {
35
+ observer.observe(node as Element);
36
+ }
37
+ }
38
+ }
39
+
40
+ export function exists(node: Node): boolean {
41
+ // Check if regionMap is not null before looking up a node
42
+ // Since, dom module stops after region module, it's possible that we may set regionMap to be null
43
+ // and still attempt to call exists on a late coming DOM mutation (or addition), effectively causing a script error
44
+ return regionMap && regionMap.has(node);
45
+ }
46
+
47
+ export function track(id: number, event: Event): void {
48
+ let node = dom.getNode(id);
49
+ let data = id in regions ? regions[id] : { id, state: InteractionState.Rendered, name: regionMap.get(node) };
50
+
51
+ // Determine the interaction state based on incoming event
52
+ let interactionState = InteractionState.Rendered;
53
+ switch (event) {
54
+ case Event.Click: interactionState = InteractionState.Clicked; break;
55
+ case Event.Input: interactionState = InteractionState.Input; break;
56
+ }
57
+ // Process updates to this region, if applicable
58
+ process(node, data, interactionState);
59
+ }
60
+
61
+ export function compute(): void {
62
+ // Process any regions where we couldn't resolve an "id" for at the time of last intersection observer event
63
+ // This could happen in cases where elements are not yet processed by Clarity's virtual DOM but browser reports a change, regardless.
64
+ // For those cases we add them to the queue and re-process them below
65
+ let q = [];
66
+ for (let r of queue) {
67
+ let id = dom.getId(r.node);
68
+ if (!(id in regions)) {
69
+ if (id) {
70
+ r.data.id = id;
71
+ regions[id] = r.data;
72
+ state.push(clone(r.data));
73
+ } else { q.push(r); }
74
+ }
75
+ }
76
+ queue = q;
77
+
78
+ // Schedule encode only when we have at least one valid data entry
79
+ if (state.length > 0) { encode(Event.Region); }
80
+ }
81
+
82
+ function handler(entries: IntersectionObserverEntry[]): void {
83
+ for (let entry of entries) {
84
+ let target = entry.target;
85
+ let rect = entry.boundingClientRect;
86
+ let overlap = entry.intersectionRect;
87
+ let viewport = entry.rootBounds;
88
+ // Only capture regions that have non-zero width or height to avoid tracking and sending regions
89
+ // that cannot ever be seen by the user. In some cases, websites will have a multiple copy of the same region
90
+ // like search box - one for desktop, and another for mobile. In those cases, CSS media queries determine which one should be visible.
91
+ // Also, if these regions ever become non-zero width or height (through AJAX, user action or orientation change) - we will automatically start monitoring them from that point onwards
92
+ if (regionMap.has(target) && rect.width + rect.height > 0 && viewport.width > 0 && viewport.height > 0) {
93
+ let id = target ? dom.getId(target) : null;
94
+ let data = id in regions ? regions[id] : { id, name: regionMap.get(target), state: InteractionState.Rendered };
95
+
96
+ // For regions that have relatively smaller area, we look at intersection ratio and see the overlap relative to element's area
97
+ // However, for larger regions, area of regions could be bigger than viewport and therefore comparison is relative to visible area
98
+ let viewportRatio = overlap ? (overlap.width * overlap.height * 1.0) / (viewport.width * viewport.height) : 0;
99
+ let visible = viewportRatio > Setting.ViewportIntersectionRatio || entry.intersectionRatio > Setting.IntersectionRatio;
100
+
101
+ // Process updates to this region, if applicable
102
+ process(target, data, visible ? InteractionState.Visible : InteractionState.Rendered);
103
+
104
+ // Stop observing this element now that we have already received visibility signal
105
+ if (data.state >= InteractionState.Visible && observer) { observer.unobserve(target); }
106
+ }
107
+ }
108
+ if (state.length > 0) { encode(Event.Region); }
109
+ }
110
+
111
+ function process(n: Node, d: RegionData, s: InteractionState): void {
112
+ // Check if received a state that supersedes existing state
113
+ let updated = s > d.state;
114
+ d.state = updated ? s : d.state;
115
+ // If the corresponding node is already discovered, update the internal state
116
+ // Otherwise, track it in a queue to reprocess later.
117
+ if (d.id) {
118
+ if ((d.id in regions && updated) || !(d.id in regions)) {
119
+ regions[d.id] = d;
120
+ state.push(clone(d));
121
+ }
122
+ } else { queue.push({node: n, data: d}); }
123
+ }
124
+
125
+ function clone(r: RegionData): RegionState {
126
+ return { time: time(), data: { id: r.id, state: r.state, name: r.name }};
127
+ }
128
+
129
+ export function reset(): void {
130
+ state = [];
131
+ }
132
+
133
+ export function stop(): void {
134
+ reset();
135
+ regionMap = null;
136
+ regions = {};
137
+ queue = [];
138
+ if (observer) {
139
+ observer.disconnect();
140
+ observer = null;
141
+ }
142
+ watch = false;
143
+ }
@@ -0,0 +1,66 @@
1
+ import { Dimension, Metric, Setting } from "@clarity-types/data";
2
+ import { Constant, JsonLD } from "@clarity-types/layout";
3
+ import * as dimension from "@src/data/dimension";
4
+ import * as metric from "@src/data/metric";
5
+
6
+ const digitsRegex = /[^0-9\.]/g;
7
+
8
+ /* JSON+LD (Linked Data) Recursive Parser */
9
+ export function ld(json: any): void {
10
+ for (let key of Object.keys(json)) {
11
+ let value = json[key];
12
+ if (key === JsonLD.Type && typeof value === "string") {
13
+ value = value.toLowerCase();
14
+ /* Normalizations */
15
+ value = value.indexOf(JsonLD.Article) >= 0 || value.indexOf(JsonLD.Posting) >= 0 ? JsonLD.Article : value;
16
+ switch (value) {
17
+ case JsonLD.Article:
18
+ case JsonLD.Recipe:
19
+ dimension.log(Dimension.SchemaType, json[key]);
20
+ dimension.log(Dimension.AuthorName, json[JsonLD.Creator]);
21
+ dimension.log(Dimension.Headline, json[JsonLD.Headline]);
22
+ break;
23
+ case JsonLD.Product:
24
+ dimension.log(Dimension.SchemaType, json[key]);
25
+ dimension.log(Dimension.ProductName, json[JsonLD.Name]);
26
+ dimension.log(Dimension.ProductSku, json[JsonLD.Sku]);
27
+ if (json[JsonLD.Brand]) { dimension.log(Dimension.ProductBrand, json[JsonLD.Brand][JsonLD.Name]); }
28
+ break;
29
+ case JsonLD.AggregateRating:
30
+ if (json[JsonLD.RatingValue]) {
31
+ metric.max(Metric.RatingValue, num(json[JsonLD.RatingValue], Setting.RatingScale));
32
+ metric.max(Metric.BestRating, num(json[JsonLD.BestRating]));
33
+ metric.max(Metric.WorstRating, num(json[JsonLD.WorstRating]));
34
+ }
35
+ metric.max(Metric.RatingCount, num(json[JsonLD.RatingCount]));
36
+ metric.max(Metric.ReviewCount, num(json[JsonLD.ReviewCount]));
37
+ break;
38
+ case JsonLD.Author:
39
+ dimension.log(Dimension.AuthorName, json[JsonLD.Name]);
40
+ break;
41
+ case JsonLD.Offer:
42
+ dimension.log(Dimension.ProductAvailability, json[JsonLD.Availability]);
43
+ dimension.log(Dimension.ProductCondition, json[JsonLD.ItemCondition]);
44
+ dimension.log(Dimension.ProductCurrency, json[JsonLD.PriceCurrency]);
45
+ dimension.log(Dimension.ProductSku, json[JsonLD.Sku]);
46
+ metric.max(Metric.ProductPrice, num(json[JsonLD.Price]));
47
+ break;
48
+ case JsonLD.Brand:
49
+ dimension.log(Dimension.ProductBrand, json[JsonLD.Name]);
50
+ break;
51
+ }
52
+ }
53
+ // Continue parsing nested objects
54
+ if (value !== null && typeof(value) === Constant.Object) { ld(value); }
55
+ }
56
+ }
57
+
58
+ function num(input: string | number, scale: number = 1): number {
59
+ if (input !== null) {
60
+ switch (typeof input) {
61
+ case Constant.Number: return Math.round((input as number) * scale);
62
+ case Constant.String: return Math.round(parseFloat((input as string).replace(digitsRegex, Constant.Empty)) * scale);
63
+ }
64
+ }
65
+ return null;
66
+ }