clarity-js 0.8.11-beta → 0.8.11
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/.lintstagedrc.yml +3 -0
- package/biome.json +43 -0
- package/build/clarity.extended.js +1 -1
- package/build/clarity.insight.js +1 -1
- package/build/clarity.js +3182 -2963
- package/build/clarity.min.js +1 -1
- package/build/clarity.module.js +3182 -2963
- package/build/clarity.performance.js +1 -1
- package/package.json +76 -69
- package/rollup.config.ts +84 -88
- package/src/clarity.ts +34 -28
- package/src/core/config.ts +2 -2
- package/src/core/event.ts +36 -32
- package/src/core/hash.ts +5 -6
- package/src/core/history.ts +10 -11
- package/src/core/index.ts +21 -11
- package/src/core/measure.ts +9 -5
- package/src/core/report.ts +9 -5
- package/src/core/scrub.ts +29 -20
- package/src/core/task.ts +73 -45
- package/src/core/time.ts +3 -3
- package/src/core/timeout.ts +2 -2
- package/src/core/version.ts +1 -1
- package/src/data/baseline.ts +60 -55
- package/src/data/consent.ts +2 -2
- package/src/data/custom.ts +8 -13
- package/src/data/dimension.ts +11 -7
- package/src/data/encode.ts +36 -30
- package/src/data/envelope.ts +38 -38
- package/src/data/extract.ts +86 -77
- package/src/data/index.ts +10 -6
- package/src/data/limit.ts +1 -1
- package/src/data/metadata.ts +305 -266
- package/src/data/metric.ts +18 -8
- package/src/data/ping.ts +8 -4
- package/src/data/signal.ts +18 -18
- package/src/data/summary.ts +6 -4
- package/src/data/token.ts +10 -8
- package/src/data/upgrade.ts +7 -3
- package/src/data/upload.ts +100 -49
- package/src/data/variable.ts +27 -20
- package/src/diagnostic/encode.ts +2 -2
- package/src/diagnostic/fraud.ts +3 -4
- package/src/diagnostic/internal.ts +11 -5
- package/src/diagnostic/script.ts +12 -8
- package/src/global.ts +1 -1
- package/src/insight/blank.ts +4 -4
- package/src/insight/encode.ts +23 -17
- package/src/insight/snapshot.ts +57 -37
- package/src/interaction/change.ts +9 -6
- package/src/interaction/click.ts +34 -28
- package/src/interaction/clipboard.ts +2 -2
- package/src/interaction/encode.ts +35 -31
- package/src/interaction/input.ts +11 -9
- package/src/interaction/pointer.ts +42 -31
- package/src/interaction/resize.ts +5 -5
- package/src/interaction/scroll.ts +21 -18
- package/src/interaction/selection.ts +12 -8
- package/src/interaction/submit.ts +2 -2
- package/src/interaction/timeline.ts +13 -9
- package/src/interaction/unload.ts +1 -1
- package/src/interaction/visibility.ts +2 -2
- package/src/layout/animation.ts +47 -41
- package/src/layout/discover.ts +5 -5
- package/src/layout/document.ts +31 -19
- package/src/layout/dom.ts +141 -91
- package/src/layout/encode.ts +52 -37
- package/src/layout/mutation.ts +321 -318
- package/src/layout/node.ts +104 -81
- package/src/layout/offset.ts +7 -6
- package/src/layout/region.ts +67 -44
- package/src/layout/schema.ts +15 -8
- package/src/layout/selector.ts +47 -25
- package/src/layout/style.ts +44 -36
- package/src/layout/target.ts +14 -10
- package/src/layout/traverse.ts +17 -11
- package/src/performance/blank.ts +1 -1
- package/src/performance/encode.ts +4 -4
- package/src/performance/interaction.ts +58 -70
- package/src/performance/navigation.ts +2 -2
- package/src/performance/observer.ts +59 -26
- package/src/queue.ts +16 -9
- package/tsconfig.json +1 -1
- package/tslint.json +25 -32
- package/types/core.d.ts +13 -13
- package/types/data.d.ts +32 -29
- package/types/diagnostic.d.ts +1 -1
- package/types/interaction.d.ts +7 -5
- package/types/layout.d.ts +36 -21
- package/types/performance.d.ts +6 -5
package/src/layout/node.ts
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
|
-
import { Constant, Source } from "@clarity-types/layout";
|
|
2
1
|
import { Code, Dimension, Severity } from "@clarity-types/data";
|
|
3
|
-
import
|
|
2
|
+
import { Constant, Source } from "@clarity-types/layout";
|
|
4
3
|
import * as event from "@src/core/event";
|
|
5
4
|
import * as dimension from "@src/data/dimension";
|
|
5
|
+
import { electron } from "@src/data/metadata";
|
|
6
6
|
import * as internal from "@src/diagnostic/internal";
|
|
7
7
|
import * as interaction from "@src/interaction";
|
|
8
8
|
import * as mutation from "@src/layout/mutation";
|
|
9
9
|
import * as schema from "@src/layout/schema";
|
|
10
10
|
import { checkDocumentStyles } from "@src/layout/style";
|
|
11
|
-
import
|
|
11
|
+
import * as dom from "./dom";
|
|
12
12
|
|
|
13
13
|
const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus", "onerror", "data-drupal-form-submit-last", "aria-label"];
|
|
14
14
|
const newlineRegex = /[\r\n]+/g;
|
|
15
15
|
|
|
16
|
-
export default function (
|
|
16
|
+
export default function (inputNode: Node, source: Source, timestamp: number): Node {
|
|
17
|
+
let node = inputNode;
|
|
17
18
|
let child: Node = null;
|
|
18
19
|
|
|
19
20
|
// Do not track this change if we are attempting to remove a node before discovering it
|
|
20
|
-
if (source === Source.ChildListRemove && dom.has(node) === false) {
|
|
21
|
+
if (source === Source.ChildListRemove && dom.has(node) === false) {
|
|
22
|
+
return child;
|
|
23
|
+
}
|
|
21
24
|
|
|
22
25
|
// Special handling for text nodes that belong to style nodes
|
|
23
|
-
if (source !== Source.Discover &&
|
|
24
|
-
node.nodeType === Node.TEXT_NODE &&
|
|
25
|
-
node.parentElement &&
|
|
26
|
-
node.parentElement.tagName === "STYLE") {
|
|
26
|
+
if (source !== Source.Discover && node.nodeType === Node.TEXT_NODE && node.parentElement && node.parentElement.tagName === "STYLE") {
|
|
27
27
|
node = node.parentNode;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const add = dom.has(node) === false;
|
|
31
|
+
const call = add ? "add" : "update";
|
|
32
32
|
let parent = node.parentElement ? node.parentElement : null;
|
|
33
|
-
|
|
33
|
+
const insideFrame = node.ownerDocument !== document;
|
|
34
34
|
switch (node.nodeType) {
|
|
35
|
-
case Node.DOCUMENT_TYPE_NODE:
|
|
35
|
+
case Node.DOCUMENT_TYPE_NODE: {
|
|
36
36
|
parent = insideFrame && node.parentNode ? dom.iframe(node.parentNode) : parent;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
const docTypePrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
|
|
38
|
+
const doctype = node as DocumentType;
|
|
39
|
+
const docName = doctype.name ? doctype.name : Constant.HTML;
|
|
40
|
+
const docAttributes = { name: docName, publicId: doctype.publicId, systemId: doctype.systemId };
|
|
41
|
+
const docData = { tag: docTypePrefix + Constant.DocumentTag, attributes: docAttributes };
|
|
42
42
|
dom[call](node, parent, docData, source);
|
|
43
43
|
break;
|
|
44
|
+
}
|
|
44
45
|
case Node.DOCUMENT_NODE:
|
|
45
46
|
// We check for regions in the beginning when discovering document and
|
|
46
47
|
// later whenever there are new additions or modifications to DOM (mutations)
|
|
@@ -50,20 +51,20 @@ export default function (node: Node, source: Source, timestamp: number): Node {
|
|
|
50
51
|
checkDocumentStyles(node as Document, timestamp);
|
|
51
52
|
observe(node as Document);
|
|
52
53
|
break;
|
|
53
|
-
case Node.DOCUMENT_FRAGMENT_NODE:
|
|
54
|
-
|
|
54
|
+
case Node.DOCUMENT_FRAGMENT_NODE: {
|
|
55
|
+
const shadowRoot = node as ShadowRoot;
|
|
55
56
|
if (shadowRoot.host) {
|
|
56
57
|
dom.parse(shadowRoot);
|
|
57
|
-
|
|
58
|
+
const type = typeof shadowRoot.constructor;
|
|
58
59
|
if (type === Constant.Function && shadowRoot.constructor.toString().indexOf(Constant.NativeCode) >= 0) {
|
|
59
60
|
observe(shadowRoot);
|
|
60
|
-
|
|
61
|
+
|
|
61
62
|
// See: https://wicg.github.io/construct-stylesheets/ for more details on adoptedStyleSheets.
|
|
62
63
|
// At the moment, we are only able to capture "open" shadow DOM nodes. If they are closed, they are not accessible.
|
|
63
64
|
// In future we may decide to proxy "attachShadow" call to gain access, but at the moment, we don't want to
|
|
64
65
|
// cause any unintended side effect to the page. We will re-evaluate after we gather more real world data on this.
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const style = Constant.Empty as string;
|
|
67
|
+
const fragmentData = { tag: Constant.ShadowDomTag, attributes: { style } };
|
|
67
68
|
dom[call](node, shadowRoot.host, fragmentData, source);
|
|
68
69
|
} else {
|
|
69
70
|
// If the browser doesn't support shadow DOM natively, we detect that, and send appropriate tag back.
|
|
@@ -74,91 +75,100 @@ export default function (node: Node, source: Source, timestamp: number): Node {
|
|
|
74
75
|
checkDocumentStyles(node as Document, timestamp);
|
|
75
76
|
}
|
|
76
77
|
break;
|
|
78
|
+
}
|
|
77
79
|
case Node.TEXT_NODE:
|
|
78
80
|
// In IE11 TEXT_NODE doesn't expose a valid parentElement property. Instead we need to lookup parentNode property.
|
|
79
|
-
parent = parent ? parent : node.parentNode as HTMLElement;
|
|
81
|
+
parent = parent ? parent : (node.parentNode as HTMLElement);
|
|
80
82
|
// Account for this text node only if we are tracking the parent node
|
|
81
83
|
// We do not wish to track text nodes for ignored parent nodes, like script tags
|
|
82
84
|
// Also, we do not track text nodes for STYLE tags
|
|
83
85
|
// The only exception is when we receive a mutation to remove the text node, in that case
|
|
84
86
|
// parent will be null, but we can still process the node by checking it's an update call.
|
|
85
87
|
if (call === "update" || (parent && dom.has(parent) && parent.tagName !== "STYLE" && parent.tagName !== "NOSCRIPT")) {
|
|
86
|
-
|
|
88
|
+
const textData = { tag: Constant.TextTag, value: node.nodeValue };
|
|
87
89
|
dom[call](node, parent, textData, source);
|
|
88
90
|
}
|
|
89
91
|
break;
|
|
90
|
-
case Node.ELEMENT_NODE:
|
|
91
|
-
|
|
92
|
+
case Node.ELEMENT_NODE: {
|
|
93
|
+
const element = node as HTMLElement;
|
|
92
94
|
let tag = element.tagName;
|
|
93
|
-
|
|
95
|
+
const attributes = getAttributes(element);
|
|
94
96
|
// In some cases, external libraries like vue-fragment, can modify parentNode property to not be in sync with the DOM
|
|
95
97
|
// For correctness, we first look at parentElement and if it not present then fall back to using parentNode
|
|
96
|
-
parent = node.parentElement ? node.parentElement :
|
|
98
|
+
parent = node.parentElement ? node.parentElement : node.parentNode ? (node.parentNode as HTMLElement) : null;
|
|
97
99
|
// If we encounter a node that is part of SVG namespace, prefix the tag with SVG_PREFIX
|
|
98
|
-
if (element.namespaceURI === Constant.SvgNamespace) {
|
|
100
|
+
if (element.namespaceURI === Constant.SvgNamespace) {
|
|
101
|
+
tag = Constant.SvgPrefix + tag;
|
|
102
|
+
}
|
|
99
103
|
|
|
100
104
|
switch (tag) {
|
|
101
|
-
case "HTML":
|
|
105
|
+
case "HTML": {
|
|
102
106
|
parent = insideFrame && parent ? dom.iframe(parent) : parent;
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
const htmlPrefix = insideFrame ? Constant.IFramePrefix : Constant.Empty;
|
|
108
|
+
const htmlData = { tag: htmlPrefix + tag, attributes };
|
|
105
109
|
dom[call](node, parent, htmlData, source);
|
|
106
110
|
break;
|
|
111
|
+
}
|
|
107
112
|
case "SCRIPT":
|
|
108
113
|
if (Constant.Type in attributes && attributes[Constant.Type] === Constant.JsonLD) {
|
|
109
114
|
try {
|
|
110
115
|
schema.ld(JSON.parse((element as HTMLScriptElement).text.replace(newlineRegex, Constant.Empty)));
|
|
111
|
-
} catch {
|
|
116
|
+
} catch {
|
|
117
|
+
/* do nothing */
|
|
118
|
+
}
|
|
112
119
|
}
|
|
113
120
|
break;
|
|
114
|
-
case "NOSCRIPT":
|
|
121
|
+
case "NOSCRIPT": {
|
|
115
122
|
// keeping the noscript tag but ignoring its contents. Some HTML markup relies on having these tags
|
|
116
123
|
// to maintain parity with the original css view, but we don't want to execute any noscript in Clarity
|
|
117
|
-
|
|
124
|
+
const noscriptData = { tag, attributes: {}, value: "" };
|
|
118
125
|
dom[call](node, parent, noscriptData, source);
|
|
119
126
|
break;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(Constant.Name in attributes ? Constant.Name : null));
|
|
127
|
+
}
|
|
128
|
+
case "META": {
|
|
129
|
+
const key = Constant.Property in attributes ? Constant.Property : Constant.Name in attributes ? Constant.Name : null;
|
|
124
130
|
if (key && Constant.Content in attributes) {
|
|
125
|
-
|
|
126
|
-
switch(attributes[key]) {
|
|
131
|
+
const content = attributes[Constant.Content];
|
|
132
|
+
switch (attributes[key]) {
|
|
127
133
|
case Constant.ogTitle:
|
|
128
|
-
dimension.log(Dimension.MetaTitle, content)
|
|
134
|
+
dimension.log(Dimension.MetaTitle, content);
|
|
129
135
|
break;
|
|
130
136
|
case Constant.ogType:
|
|
131
|
-
dimension.log(Dimension.MetaType, content)
|
|
137
|
+
dimension.log(Dimension.MetaType, content);
|
|
132
138
|
break;
|
|
133
139
|
case Constant.Generator:
|
|
134
|
-
dimension.log(Dimension.Generator, content)
|
|
140
|
+
dimension.log(Dimension.Generator, content);
|
|
135
141
|
break;
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
break;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
}
|
|
146
|
+
case "HEAD": {
|
|
147
|
+
const head = { tag, attributes };
|
|
148
|
+
const l = insideFrame && node.ownerDocument?.location ? node.ownerDocument.location : location;
|
|
149
|
+
head.attributes[Constant.Base] = `${l.protocol}//${l.host}${l.pathname}`;
|
|
143
150
|
dom[call](node, parent, head, source);
|
|
144
151
|
break;
|
|
145
|
-
|
|
152
|
+
}
|
|
153
|
+
case "BASE": {
|
|
146
154
|
// Override the auto detected base path to explicit value specified in this tag
|
|
147
|
-
|
|
155
|
+
const baseHead = dom.get(node.parentElement);
|
|
148
156
|
if (baseHead) {
|
|
149
157
|
// We create "a" element so we can generate protocol and hostname for relative paths like "/path/"
|
|
150
|
-
|
|
151
|
-
a.href = attributes
|
|
152
|
-
baseHead.data.attributes[Constant.Base] = a.protocol
|
|
158
|
+
const a = document.createElement("a");
|
|
159
|
+
a.href = attributes.href;
|
|
160
|
+
baseHead.data.attributes[Constant.Base] = `${a.protocol}//${a.host}${a.pathname}`;
|
|
153
161
|
}
|
|
154
162
|
break;
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
}
|
|
164
|
+
case "STYLE": {
|
|
165
|
+
const styleData = { tag, attributes, value: getStyleValue(element as HTMLStyleElement) };
|
|
157
166
|
dom[call](node, parent, styleData, source);
|
|
158
167
|
break;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
}
|
|
169
|
+
case "IFRAME": {
|
|
170
|
+
const iframe = node as HTMLIFrameElement;
|
|
171
|
+
const frameData = { tag, attributes };
|
|
162
172
|
if (dom.sameorigin(iframe)) {
|
|
163
173
|
mutation.monitor(iframe);
|
|
164
174
|
frameData.attributes[Constant.SameOrigin] = "true";
|
|
@@ -171,14 +181,15 @@ export default function (node: Node, source: Source, timestamp: number): Node {
|
|
|
171
181
|
}
|
|
172
182
|
dom[call](node, parent, frameData, source);
|
|
173
183
|
break;
|
|
174
|
-
|
|
184
|
+
}
|
|
185
|
+
case "LINK": {
|
|
175
186
|
// electron stylesheets reference the local file system - translating those
|
|
176
187
|
// to inline styles so playback can work
|
|
177
|
-
if (electron && attributes
|
|
178
|
-
for (
|
|
179
|
-
|
|
180
|
-
if (currentStyleSheet.ownerNode
|
|
181
|
-
|
|
188
|
+
if (electron && attributes.rel === Constant.StyleSheet) {
|
|
189
|
+
for (const styleSheetIndex in Object.keys(document.styleSheets)) {
|
|
190
|
+
const currentStyleSheet = document.styleSheets[styleSheetIndex];
|
|
191
|
+
if (currentStyleSheet.ownerNode === element) {
|
|
192
|
+
const syntheticStyleData = { tag: "STYLE", attributes, value: getCssRules(currentStyleSheet) };
|
|
182
193
|
dom[call](node, parent, syntheticStyleData, source);
|
|
183
194
|
break;
|
|
184
195
|
}
|
|
@@ -186,26 +197,32 @@ export default function (node: Node, source: Source, timestamp: number): Node {
|
|
|
186
197
|
break;
|
|
187
198
|
}
|
|
188
199
|
// for links that aren't electron style sheets we can process them normally
|
|
189
|
-
|
|
200
|
+
const linkData = { tag, attributes };
|
|
190
201
|
dom[call](node, parent, linkData, source);
|
|
191
202
|
break;
|
|
203
|
+
}
|
|
192
204
|
case "VIDEO":
|
|
193
205
|
case "AUDIO":
|
|
194
|
-
case "SOURCE":
|
|
206
|
+
case "SOURCE": {
|
|
195
207
|
// Ignoring any base64 src attribute for media elements to prevent big unused tokens to be sent and shock the network
|
|
196
208
|
if (Constant.Src in attributes && attributes[Constant.Src].startsWith("data:")) {
|
|
197
209
|
attributes[Constant.Src] = "";
|
|
198
210
|
}
|
|
199
|
-
|
|
211
|
+
const mediaTag = { tag, attributes };
|
|
200
212
|
dom[call](node, parent, mediaTag, source);
|
|
201
213
|
break;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
}
|
|
215
|
+
default: {
|
|
216
|
+
const data = { tag, attributes };
|
|
217
|
+
if (element.shadowRoot) {
|
|
218
|
+
child = element.shadowRoot;
|
|
219
|
+
}
|
|
205
220
|
dom[call](node, parent, data, source);
|
|
206
221
|
break;
|
|
222
|
+
}
|
|
207
223
|
}
|
|
208
224
|
break;
|
|
225
|
+
}
|
|
209
226
|
default:
|
|
210
227
|
break;
|
|
211
228
|
}
|
|
@@ -213,7 +230,9 @@ export default function (node: Node, source: Source, timestamp: number): Node {
|
|
|
213
230
|
}
|
|
214
231
|
|
|
215
232
|
function observe(root: Document | ShadowRoot): void {
|
|
216
|
-
if (dom.has(root) || event.has(root)) {
|
|
233
|
+
if (dom.has(root) || event.has(root)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
217
236
|
mutation.observe(root); // Observe mutations for this root node
|
|
218
237
|
interaction.observe(root); // Observe interactions for this root node
|
|
219
238
|
}
|
|
@@ -228,13 +247,13 @@ export function removeObserver(root: HTMLIFrameElement): void {
|
|
|
228
247
|
// For iframes, scroll event is observed on content window and this needs to be removed as well
|
|
229
248
|
event.unbind(win);
|
|
230
249
|
}
|
|
231
|
-
|
|
250
|
+
|
|
232
251
|
if (doc) {
|
|
233
252
|
// When an iframe is removed, we should also remove all listeners attached to its document
|
|
234
253
|
// to avoid memory leaks.
|
|
235
254
|
event.unbind(doc);
|
|
236
255
|
mutation.disconnect(doc);
|
|
237
|
-
|
|
256
|
+
|
|
238
257
|
// Remove iframe and content document from maps tracking them
|
|
239
258
|
dom.removeIFrame(root, doc);
|
|
240
259
|
}
|
|
@@ -246,7 +265,7 @@ function getStyleValue(style: HTMLStyleElement): string {
|
|
|
246
265
|
// Additionally, check if style node has an id - if so it's at a high risk to have experienced dynamic
|
|
247
266
|
// style updates which would make the textContent out of date with its true style contribution.
|
|
248
267
|
let value = style.textContent ? style.textContent.trim() : Constant.Empty;
|
|
249
|
-
|
|
268
|
+
const dataset = style.dataset ? Object.keys(style.dataset).length : 0;
|
|
250
269
|
if (value.length === 0 || dataset > 0 || style.id.length > 0) {
|
|
251
270
|
value = getCssRules(style.sheet as CSSStyleSheet);
|
|
252
271
|
}
|
|
@@ -257,9 +276,13 @@ export function getCssRules(sheet: CSSStyleSheet): string {
|
|
|
257
276
|
let value = Constant.Empty as string;
|
|
258
277
|
let cssRules = null;
|
|
259
278
|
// Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain
|
|
260
|
-
try {
|
|
279
|
+
try {
|
|
280
|
+
cssRules = sheet ? sheet.cssRules : [];
|
|
281
|
+
} catch (e) {
|
|
261
282
|
internal.log(Code.CssRules, Severity.Warning, e ? e.name : null);
|
|
262
|
-
if (e && e.name !== "SecurityError") {
|
|
283
|
+
if (e && e.name !== "SecurityError") {
|
|
284
|
+
throw e;
|
|
285
|
+
}
|
|
263
286
|
}
|
|
264
287
|
|
|
265
288
|
if (cssRules !== null) {
|
|
@@ -272,11 +295,11 @@ export function getCssRules(sheet: CSSStyleSheet): string {
|
|
|
272
295
|
}
|
|
273
296
|
|
|
274
297
|
function getAttributes(element: HTMLElement): { [key: string]: string } {
|
|
275
|
-
|
|
276
|
-
|
|
298
|
+
const output = {};
|
|
299
|
+
const attributes = element.attributes;
|
|
277
300
|
if (attributes && attributes.length > 0) {
|
|
278
301
|
for (let i = 0; i < attributes.length; i++) {
|
|
279
|
-
|
|
302
|
+
const name = attributes[i].name;
|
|
280
303
|
if (IGNORE_ATTRIBUTES.indexOf(name) < 0) {
|
|
281
304
|
output[name] = attributes[i].value;
|
|
282
305
|
}
|
package/src/layout/offset.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { OffsetDistance } from "@clarity-types/core";
|
|
1
|
+
import type { OffsetDistance } from "@clarity-types/core";
|
|
2
2
|
import { iframe } from "@src/layout/dom";
|
|
3
3
|
|
|
4
|
-
export function offset(
|
|
5
|
-
let
|
|
4
|
+
export function offset(inputElement: HTMLElement): OffsetDistance {
|
|
5
|
+
let element = inputElement;
|
|
6
|
+
const output: OffsetDistance = { x: 0, y: 0 };
|
|
6
7
|
|
|
7
8
|
// Walk up the chain to ensure we compute offset distance correctly
|
|
8
9
|
// In case where we may have nested IFRAMEs, we keep walking up until we get to the top most parent page
|
|
9
|
-
if (element
|
|
10
|
+
if (element?.offsetParent) {
|
|
10
11
|
do {
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const parent = element.offsetParent as HTMLElement;
|
|
13
|
+
const frame = parent === null ? iframe(element.ownerDocument) : null;
|
|
13
14
|
output.x += element.offsetLeft;
|
|
14
15
|
output.y += element.offsetTop;
|
|
15
16
|
element = frame ? frame : parent;
|
package/src/layout/region.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Event, Setting } from "@clarity-types/data";
|
|
2
|
-
import { InteractionState, RegionData,
|
|
2
|
+
import { InteractionState, type RegionData, type RegionQueue, type RegionState, RegionVisibility } from "@clarity-types/layout";
|
|
3
3
|
import { FunctionNames } from "@clarity-types/performance";
|
|
4
4
|
import { time } from "@src/core/time";
|
|
5
5
|
import * as dom from "@src/layout/dom";
|
|
@@ -18,20 +18,22 @@ export function start(): void {
|
|
|
18
18
|
regionMap = new WeakMap();
|
|
19
19
|
regions = {};
|
|
20
20
|
queue = [];
|
|
21
|
-
watch = window
|
|
22
|
-
|
|
21
|
+
watch = !!window.IntersectionObserver;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
export function observe(node: Node, name: string): void {
|
|
26
25
|
if (regionMap.has(node) === false) {
|
|
27
26
|
regionMap.set(node, name);
|
|
28
|
-
observer =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
observer =
|
|
28
|
+
observer === null && watch
|
|
29
|
+
? new IntersectionObserver(handler, {
|
|
30
|
+
// Get notified as intersection continues to change
|
|
31
|
+
// This allows us to process regions that get partially hidden during the lifetime of the page
|
|
32
|
+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
|
|
33
|
+
// By default, intersection observers only fire an event when even a single pixel is visible and not thereafter.
|
|
34
|
+
threshold: [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
|
|
35
|
+
})
|
|
36
|
+
: observer;
|
|
35
37
|
if (observer && node && node.nodeType === Node.ELEMENT_NODE) {
|
|
36
38
|
observer.observe(node as Element);
|
|
37
39
|
}
|
|
@@ -42,18 +44,25 @@ export function exists(node: Node): boolean {
|
|
|
42
44
|
// Check if regionMap is not null before looking up a node
|
|
43
45
|
// Since, dom module stops after region module, it's possible that we may set regionMap to be null
|
|
44
46
|
// and still attempt to call exists on a late coming DOM mutation (or addition), effectively causing a script error
|
|
45
|
-
return regionMap
|
|
47
|
+
return regionMap?.has(node);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export function track(id: number, event: Event): void {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
const node = dom.getNode(id);
|
|
52
|
+
const data =
|
|
53
|
+
id in regions
|
|
54
|
+
? regions[id]
|
|
55
|
+
: { id, visibility: RegionVisibility.Rendered, interaction: InteractionState.None, name: regionMap.get(node) };
|
|
56
|
+
|
|
52
57
|
// Determine the interaction state based on incoming event
|
|
53
58
|
let interaction = InteractionState.None;
|
|
54
59
|
switch (event) {
|
|
55
|
-
case Event.Click:
|
|
56
|
-
|
|
60
|
+
case Event.Click:
|
|
61
|
+
interaction = InteractionState.Clicked;
|
|
62
|
+
break;
|
|
63
|
+
case Event.Input:
|
|
64
|
+
interaction = InteractionState.Input;
|
|
65
|
+
break;
|
|
57
66
|
}
|
|
58
67
|
// Process updates to this region, if applicable
|
|
59
68
|
process(node, data, interaction, data.visibility);
|
|
@@ -64,59 +73,73 @@ export function compute(): void {
|
|
|
64
73
|
// Process any regions where we couldn't resolve an "id" for at the time of last intersection observer event
|
|
65
74
|
// This could happen in cases where elements are not yet processed by Clarity's virtual DOM but browser reports a change, regardless.
|
|
66
75
|
// For those cases we add them to the queue and re-process them below
|
|
67
|
-
|
|
68
|
-
for (
|
|
69
|
-
|
|
76
|
+
const q = [];
|
|
77
|
+
for (const r of queue) {
|
|
78
|
+
const id = dom.getId(r.node);
|
|
70
79
|
if (id) {
|
|
71
80
|
r.state.data.id = id;
|
|
72
81
|
regions[id] = r.state.data;
|
|
73
82
|
state.push(r.state);
|
|
74
|
-
} else {
|
|
83
|
+
} else {
|
|
84
|
+
q.push(r);
|
|
85
|
+
}
|
|
75
86
|
}
|
|
76
87
|
queue = q;
|
|
77
88
|
|
|
78
89
|
// Schedule encode only when we have at least one valid data entry
|
|
79
|
-
if (state.length > 0) {
|
|
90
|
+
if (state.length > 0) {
|
|
91
|
+
encode(Event.Region);
|
|
92
|
+
}
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
function handler(entries: IntersectionObserverEntry[]): void {
|
|
83
|
-
for (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const target = entry.target;
|
|
98
|
+
const rect = entry.boundingClientRect;
|
|
99
|
+
const overlap = entry.intersectionRect;
|
|
100
|
+
const viewport = entry.rootBounds;
|
|
88
101
|
// Only capture regions that have non-zero width or height to avoid tracking and sending regions
|
|
89
102
|
// that cannot ever be seen by the user. In some cases, websites will have a multiple copy of the same region
|
|
90
103
|
// like search box - one for desktop, and another for mobile. In those cases, CSS media queries determine which one should be visible.
|
|
91
104
|
// 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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
if (regionMap.has(target) && rect.width + rect.height > 0 && viewport && viewport.width > 0 && viewport.height > 0) {
|
|
106
|
+
const id = target ? dom.getId(target) : null;
|
|
107
|
+
const data =
|
|
108
|
+
id in regions
|
|
109
|
+
? regions[id]
|
|
110
|
+
: { id, name: regionMap.get(target), interaction: InteractionState.None, visibility: RegionVisibility.Rendered };
|
|
111
|
+
|
|
96
112
|
// For regions that have relatively smaller area, we look at intersection ratio and see the overlap relative to element's area
|
|
97
113
|
// However, for larger regions, area of regions could be bigger than viewport and therefore comparison is relative to visible area
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
const viewportRatio = overlap ? (overlap.width * overlap.height * 1.0) / (viewport.width * viewport.height) : 0;
|
|
115
|
+
const visible = viewportRatio > Setting.ViewportIntersectionRatio || entry.intersectionRatio > Setting.IntersectionRatio;
|
|
100
116
|
// If an element is either visible or was visible and has been scrolled to the end
|
|
101
|
-
// i.e. Scrolled to end is determined by if the starting position of the element + the window height is more than the total element height.
|
|
117
|
+
// i.e. Scrolled to end is determined by if the starting position of the element + the window height is more than the total element height.
|
|
102
118
|
// starting position is relative to the viewport - so Intersection observer returns a negative value for rect.top to indicate that the element top is above the viewport
|
|
103
|
-
|
|
119
|
+
const scrolledToEnd =
|
|
120
|
+
(visible || data.visibility === RegionVisibility.Visible) && Math.abs(rect.top) + viewport.height > rect.height;
|
|
104
121
|
// Process updates to this region, if applicable
|
|
105
|
-
process(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
process(
|
|
123
|
+
target,
|
|
124
|
+
data,
|
|
125
|
+
data.interaction,
|
|
126
|
+
scrolledToEnd ? RegionVisibility.ScrolledToEnd : visible ? RegionVisibility.Visible : RegionVisibility.Rendered,
|
|
127
|
+
);
|
|
109
128
|
|
|
110
129
|
// Stop observing this element now that we have already received scrolled signal
|
|
111
|
-
if (data.visibility >= RegionVisibility.ScrolledToEnd && observer) {
|
|
130
|
+
if (data.visibility >= RegionVisibility.ScrolledToEnd && observer) {
|
|
131
|
+
observer.unobserve(target);
|
|
132
|
+
}
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
|
-
if (state.length > 0) {
|
|
135
|
+
if (state.length > 0) {
|
|
136
|
+
encode(Event.Region);
|
|
137
|
+
}
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
function process(n: Node, d: RegionData, s: InteractionState, v: RegionVisibility): void {
|
|
118
141
|
// Check if received a state that supersedes existing state
|
|
119
|
-
|
|
142
|
+
const updated = s > d.interaction || v > d.visibility;
|
|
120
143
|
d.interaction = s > d.interaction ? s : d.interaction;
|
|
121
144
|
d.visibility = v > d.visibility ? v : d.visibility;
|
|
122
145
|
// If the corresponding node is already discovered, update the internal state
|
|
@@ -128,16 +151,16 @@ function process(n: Node, d: RegionData, s: InteractionState, v: RegionVisibilit
|
|
|
128
151
|
}
|
|
129
152
|
} else {
|
|
130
153
|
// Get the time before adding to queue to ensure accurate event time
|
|
131
|
-
queue.push({node: n, state: clone(d)});
|
|
154
|
+
queue.push({ node: n, state: clone(d) });
|
|
132
155
|
}
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
function clone(r: RegionData): RegionState {
|
|
136
|
-
return { time: time(), data: { id: r.id, interaction: r.interaction, visibility: r.visibility, name: r.name }};
|
|
159
|
+
return { time: time(), data: { id: r.id, interaction: r.interaction, visibility: r.visibility, name: r.name } };
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
export function reset(): void {
|
|
140
|
-
state = [];
|
|
163
|
+
state = [];
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
export function stop(): void {
|
package/src/layout/schema.ts
CHANGED
|
@@ -6,8 +6,9 @@ import * as metric from "@src/data/metric";
|
|
|
6
6
|
const digitsRegex = /[^0-9\.]/g;
|
|
7
7
|
|
|
8
8
|
/* JSON+LD (Linked Data) Recursive Parser */
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: specifically parsing json with any type
|
|
9
10
|
export function ld(json: any): void {
|
|
10
|
-
for (
|
|
11
|
+
for (const key of Object.keys(json)) {
|
|
11
12
|
let value = json[key];
|
|
12
13
|
if (key === JsonLD.Type && typeof value === "string") {
|
|
13
14
|
value = value.toLowerCase();
|
|
@@ -17,14 +18,16 @@ export function ld(json: any): void {
|
|
|
17
18
|
case JsonLD.Article:
|
|
18
19
|
case JsonLD.Recipe:
|
|
19
20
|
dimension.log(Dimension.SchemaType, json[key]);
|
|
20
|
-
dimension.log(Dimension.AuthorName,
|
|
21
|
-
dimension.log(Dimension.Headline,
|
|
21
|
+
dimension.log(Dimension.AuthorName, json[JsonLD.Creator]);
|
|
22
|
+
dimension.log(Dimension.Headline, json[JsonLD.Headline]);
|
|
22
23
|
break;
|
|
23
24
|
case JsonLD.Product:
|
|
24
25
|
dimension.log(Dimension.SchemaType, json[key]);
|
|
25
26
|
dimension.log(Dimension.ProductName, json[JsonLD.Name]);
|
|
26
27
|
dimension.log(Dimension.ProductSku, json[JsonLD.Sku]);
|
|
27
|
-
if (json[JsonLD.Brand]) {
|
|
28
|
+
if (json[JsonLD.Brand]) {
|
|
29
|
+
dimension.log(Dimension.ProductBrand, json[JsonLD.Brand][JsonLD.Name]);
|
|
30
|
+
}
|
|
28
31
|
break;
|
|
29
32
|
case JsonLD.AggregateRating:
|
|
30
33
|
if (json[JsonLD.RatingValue]) {
|
|
@@ -48,15 +51,19 @@ export function ld(json: any): void {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
// Continue parsing nested objects
|
|
51
|
-
if (value !== null && typeof
|
|
54
|
+
if (value !== null && typeof value === "object") {
|
|
55
|
+
ld(value);
|
|
56
|
+
}
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
function num(input: string | number, scale
|
|
60
|
+
function num(input: string | number, scale = 1): number {
|
|
56
61
|
if (input !== null) {
|
|
57
62
|
switch (typeof input) {
|
|
58
|
-
case Constant.Number:
|
|
59
|
-
|
|
63
|
+
case Constant.Number:
|
|
64
|
+
return Math.round((input as number) * scale);
|
|
65
|
+
case Constant.String:
|
|
66
|
+
return Math.round(Number.parseFloat((input as string).replace(digitsRegex, Constant.Empty)) * scale);
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
return null;
|