@mparticle/web-sdk 3.23.0 → 3.25.0

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 (39) hide show
  1. package/dist/mparticle.common.js +1 -1688
  2. package/dist/mparticle.esm.js +1 -1688
  3. package/dist/mparticle.js +1015 -253
  4. package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +7 -0
  5. package/dist/types/src/buttonTracking/buttonDescription.d.ts +10 -3
  6. package/dist/types/src/buttonTracking/buttonTracker.d.ts +8 -0
  7. package/dist/types/src/buttonTracking/checkoutIntent.d.ts +8 -3
  8. package/dist/types/src/buttonTracking/controls.d.ts +8 -4
  9. package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
  10. package/dist/types/src/configAPIClient.d.ts +1 -0
  11. package/dist/types/src/constants.d.ts +1 -0
  12. package/dist/types/src/cookieConsentManager.d.ts +17 -3
  13. package/dist/types/src/forwarder-utils.d.ts +3 -0
  14. package/dist/types/src/pageViewTracker.d.ts +19 -3
  15. package/dist/types/src/roktLauncherOptions.d.ts +1 -0
  16. package/dist/types/src/roktManager.d.ts +13 -2
  17. package/dist/types/src/sdkRuntimeModels.d.ts +1 -0
  18. package/dist/types/src/store.d.ts +3 -0
  19. package/package.json +21 -14
  20. package/src/buttonTracking/buttonClickEvent.ts +126 -8
  21. package/src/buttonTracking/buttonDescription.ts +336 -53
  22. package/src/buttonTracking/buttonTracker.ts +231 -19
  23. package/src/buttonTracking/checkoutIntent.ts +117 -23
  24. package/src/buttonTracking/controls.ts +169 -55
  25. package/src/buttonTracking/labelMasking.ts +4 -1
  26. package/src/configAPIClient.ts +1 -0
  27. package/src/constants.ts +1 -0
  28. package/src/cookieConsentManager.ts +23 -3
  29. package/src/cookieSyncManager.ts +11 -3
  30. package/src/events.ts +9 -3
  31. package/src/forwarder-utils.ts +38 -0
  32. package/src/forwarders.ts +2 -28
  33. package/src/mp-instance.ts +41 -7
  34. package/src/pageUrl.ts +43 -2
  35. package/src/pageViewTracker.ts +193 -111
  36. package/src/roktLauncherOptions.ts +1 -0
  37. package/src/roktManager.ts +66 -11
  38. package/src/sdkRuntimeModels.ts +1 -0
  39. package/src/store.ts +17 -0
@@ -1,18 +1,20 @@
1
1
  import { isFunction } from '../utils';
2
2
  import {
3
3
  hasComposedAncestor,
4
- isUnsafeLabelSource,
5
4
  isUnsafeTextSource,
6
5
  isWalletElement,
6
+ nameWords,
7
+ someComposedAncestor,
7
8
  } from './controls';
8
- import { maskLabel } from './labelMasking';
9
+ import { maskLabel, MAX_RAW_LABEL_LENGTH } from './labelMasking';
9
10
 
10
11
  // Describes a clicked button for auto button tracking: its masked name, icon,
11
12
  // hand-written identifiers and kind. Never reads form field values.
12
13
  export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
13
14
 
14
- // The link clicks page views can't see: ones that stay on the page, leave the site or download a file.
15
- export type LinkType = 'same_page' | 'outbound' | 'download';
15
+ // A same_page link acts as a button. The others leave the page: to another page
16
+ // of the site, another site or a file.
17
+ export type LinkType = 'same_page' | 'internal' | 'outbound' | 'download';
16
18
 
17
19
  const DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
18
20
 
@@ -20,7 +22,6 @@ export type ButtonLabelSource =
20
22
  | 'labelledby'
21
23
  | 'aria'
22
24
  | 'text'
23
- | 'label'
24
25
  | 'alt'
25
26
  | 'title'
26
27
  | 'value'
@@ -55,21 +56,155 @@ function elementKind(control: Element): ButtonElementKind {
55
56
  return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
56
57
  }
57
58
 
58
- function textOf(element: Element | null): string {
59
- return element && !isUnsafeTextSource(element) && element.textContent
60
- ? element.textContent.trim()
59
+ const NOT_RENDERED =
60
+ 'script, style, template, [hidden], [aria-hidden="true"], [inert]';
61
+
62
+ // The most nodes read for one label source, and images looked at for one
63
+ // button. Each costs a style lookup in the click handler.
64
+ const MAX_READS = 64;
65
+
66
+ function assignedNodes(element: Element): Node[] {
67
+ const slot = element as HTMLSlotElement;
68
+ return isFunction(slot.assignedNodes) ? slot.assignedNodes() : [];
69
+ }
70
+
71
+ // Left out of what is drawn by its parent: a light child of an open shadow host
72
+ // that no slot takes, or a slot's own child while nodes are assigned to the
73
+ // slot. A closed shadow root can't be seen, so its host's children count.
74
+ function isUnslotted(node: Element | Text): boolean {
75
+ const parent = node.parentElement;
76
+ if (!parent) {
77
+ return false;
78
+ }
79
+ return parent.shadowRoot
80
+ ? !node.assignedSlot
81
+ : assignedNodes(parent).length > 0;
82
+ }
83
+
84
+ // Hides the element and all it holds, whatever their own style says.
85
+ // Screen-reader-only text is clipped rather than hidden, so it is not. An SVG
86
+ // title is never drawn but names its icon, so it is not either.
87
+ function hidesSubtree(element: Element): boolean {
88
+ if (element.matches(NOT_RENDERED) || isUnslotted(element)) {
89
+ return true;
90
+ }
91
+ return (
92
+ !(element instanceof SVGTitleElement) &&
93
+ window.getComputedStyle(element).display === 'none'
94
+ );
95
+ }
96
+
97
+ // Visibility is inherited and a child can set it back to visible, so only the
98
+ // element's own counts.
99
+ function isVisible(element: Element): boolean {
100
+ const visibility = window.getComputedStyle(element).visibility;
101
+ return visibility !== 'hidden' && visibility !== 'collapse';
102
+ }
103
+
104
+ function isShown(element: Element, until?: Element): boolean {
105
+ return !someComposedAncestor(element, hidesSubtree, until);
106
+ }
107
+
108
+ // A shadow host shows a light child only through a slot, and only while
109
+ // nothing from that slot up to the host hides it.
110
+ function isShownThroughSlot(node: Element | Text, host: Element): boolean {
111
+ const slot = node.assignedSlot;
112
+ return !!slot && isShown(slot, host);
113
+ }
114
+
115
+ // Whitespace hides nothing, so a node holding only whitespace is taken as it
116
+ // is, without a style lookup: an icon's paths cost nothing. A slot's text can
117
+ // sit in the nodes assigned to it, so a slot is never blank.
118
+ function isBlank(node: Element | Text): boolean {
119
+ return (
120
+ !/\S/.test(node.textContent || '') &&
121
+ !(
122
+ node instanceof Element &&
123
+ (node.matches('slot') || !!node.querySelector('slot'))
124
+ )
125
+ );
126
+ }
127
+
128
+ // The text a sighted user or a screen reader gets from an element, whitespace
129
+ // collapsed. Undefined when that runs past what maskLabel keeps, takes more
130
+ // than MAX_READS nodes to read, or takes in a form field through a slot, so a
131
+ // label is never cut short at a node boundary.
132
+ function renderedText(root: Element): string | undefined {
133
+ let text = '';
134
+ let reads = 0;
135
+ const append = (value: string) => {
136
+ text = (text + value).replace(/\s+/g, ' ');
137
+ };
138
+ const read = (element: Element): boolean => {
139
+ if (hidesSubtree(element)) {
140
+ return true;
141
+ }
142
+ const assigned = assignedNodes(element);
143
+ const nodes = assigned.length ? assigned : element.childNodes;
144
+ for (let i = 0; i < nodes.length; i++) {
145
+ const node = nodes[i] as Element | Text;
146
+ const isText = node.nodeType === Node.TEXT_NODE;
147
+ if (!isText && !(node instanceof Element)) {
148
+ continue;
149
+ }
150
+ if (isBlank(node)) {
151
+ append(node.textContent);
152
+ continue;
153
+ }
154
+ if (++reads > MAX_READS) {
155
+ return false;
156
+ }
157
+ if (element.shadowRoot && !isShownThroughSlot(node, element)) {
158
+ continue;
159
+ }
160
+ if (isText) {
161
+ if (isVisible(node.assignedSlot || element)) {
162
+ append(node.nodeValue);
163
+ }
164
+ } else if (
165
+ // Assigned nodes sit outside the label source, where its own
166
+ // form field check never looked.
167
+ (assigned.length && isUnsafeTextSource(node as Element)) ||
168
+ !read(node as Element)
169
+ ) {
170
+ return false;
171
+ }
172
+ if (text.trim().length > MAX_RAW_LABEL_LENGTH) {
173
+ return false;
174
+ }
175
+ }
176
+ return true;
177
+ };
178
+ return read(root) ? text.trim() : undefined;
179
+ }
180
+
181
+ // '' when the element can't name the button, undefined when its text can't be
182
+ // read in full.
183
+ function textOf(element: Element | null): string | undefined {
184
+ return element && !isUnsafeTextSource(element) && isShown(element)
185
+ ? renderedText(element)
61
186
  : '';
62
187
  }
63
188
 
189
+ // The control itself is already known to be shown.
190
+ function shownImageAlt(control: Element): string | undefined {
191
+ const images = control.querySelectorAll('img[alt]');
192
+ for (let i = 0; i < images.length && i < MAX_READS; i++) {
193
+ if (isShown(images[i], control) && isVisible(images[i])) {
194
+ return nonBlank(images[i].getAttribute('alt'));
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+
64
200
  function nonBlank(value: string | null): string | undefined {
65
201
  return value && value.trim() ? value : undefined;
66
202
  }
67
203
 
68
204
  // The button's name as a screen reader would read it, simplified: the first of
69
- // aria-labelledby, aria-label, own text (or an inner image's alt), title, for
70
- // inputs their value, and for checkboxes and radios their label's text. The text
71
- // sources are skipped for controls that hold form fields or more than four
72
- // children, so a typed value is never read.
205
+ // aria-labelledby, aria-label, own text (or an inner image's alt), title, and
206
+ // for inputs their value. The text sources are skipped for controls that hold
207
+ // form fields or more than four children, so a typed value is never read.
73
208
  function accessibleName(
74
209
  control: Element
75
210
  ): { raw?: string; source: ButtonLabelSource } {
@@ -78,13 +213,15 @@ function accessibleName(
78
213
  const root = (isFunction(control.getRootNode)
79
214
  ? control.getRootNode()
80
215
  : document) as Document | ShadowRoot;
81
- const text = labelledBy
216
+ const parts = labelledBy
82
217
  .split(/\s+/)
83
218
  .map(id =>
84
219
  textOf(root.getElementById ? root.getElementById(id) : null)
85
- )
86
- .filter(part => !!part)
87
- .join(' ');
220
+ );
221
+ if (parts.indexOf(undefined) !== -1) {
222
+ return { source: 'none' };
223
+ }
224
+ const text = parts.filter(part => !!part).join(' ');
88
225
  if (text) {
89
226
  return { raw: text, source: 'labelledby' };
90
227
  }
@@ -112,24 +249,16 @@ function accessibleName(
112
249
  // What the browser shows on an input without a value.
113
250
  return { raw: type, source: 'value' };
114
251
  }
115
- } else if (type === 'checkbox' || type === 'radio') {
116
- // The label the browser ties to the box, never the box's value.
117
- const label = control.labels ? control.labels[0] : undefined;
118
- const text =
119
- label && !isUnsafeLabelSource(label) && label.textContent
120
- ? label.textContent.trim()
121
- : '';
122
- if (text) {
123
- return { raw: text, source: 'label' };
124
- }
125
252
  }
126
- } else if (readable) {
127
- const text = textOf(control);
253
+ } else if (readable && isShown(control)) {
254
+ const text = renderedText(control);
255
+ if (text === undefined) {
256
+ return { source: 'none' };
257
+ }
128
258
  if (text) {
129
259
  return { raw: text, source: 'text' };
130
260
  }
131
- const image = control.querySelector('img[alt]');
132
- const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
261
+ const alt = shownImageAlt(control);
133
262
  if (alt) {
134
263
  return { raw: alt, source: 'alt' };
135
264
  }
@@ -248,9 +377,12 @@ function formIdentifier(control: Element): string | undefined {
248
377
  }
249
378
 
250
379
  // The browser's normalised type: a button defaults to 'submit' and an unknown
251
- // input type reads as 'text'. Wallet and role buttons are just 'button'.
252
- function controlType(control: Element, element: ButtonElementKind): string {
253
- return element === 'button' || element === 'input'
380
+ // input type reads as 'text'. A native button or input reports its own type, a
381
+ // wallet-named one included; any other control, such as a role button or a
382
+ // vendor's wallet element, reads as 'button'.
383
+ function controlType(control: Element): string {
384
+ const tag = control.tagName.toLowerCase();
385
+ return tag === 'button' || tag === 'input'
254
386
  ? (control as HTMLButtonElement | HTMLInputElement).type
255
387
  : 'button';
256
388
  }
@@ -262,8 +394,8 @@ interface ILinkDetails {
262
394
  }
263
395
 
264
396
  // Reads the link's own resolved URL parts, never its full href: only the hostname of
265
- // a link that leaves the site and the extension of a file it downloads travel. Navigation
266
- // within the site is left to page views, and mailto:, tel: and other schemes are skipped.
397
+ // a link that leaves the site and the extension of a file it downloads travel.
398
+ // mailto:, tel: and other schemes give undefined.
267
399
  function describeLink(link: Element): ILinkDetails | undefined {
268
400
  const href = (link.getAttribute('href') || '').trim();
269
401
  if (!href || href.charAt(0) === '#') {
@@ -297,13 +429,60 @@ function describeLink(link: Element): ILinkDetails | undefined {
297
429
  return link.pathname === window.location.pathname &&
298
430
  link.search === window.location.search
299
431
  ? { type: 'same_page' }
300
- : undefined;
432
+ : { type: 'internal' };
433
+ }
434
+
435
+ // Whether a described click is on a link that leaves the page, logged as a link
436
+ // click rather than a button click.
437
+ export function isLinkClick(click: IButtonClick): boolean {
438
+ return !!click.linkType && click.linkType !== 'same_page';
439
+ }
440
+
441
+ // Checkboxes and radios, and the roles that act as them. A role can list
442
+ // fallbacks, so any of its tokens counts.
443
+ const CHOICE =
444
+ 'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
445
+ '[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
446
+ const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
447
+
448
+ function boxOf(element: Element): Element | null {
449
+ const box = element instanceof HTMLLabelElement ? element.control : element;
450
+ return box && box.matches(CHOICE) ? box : null;
451
+ }
452
+
453
+ function isRadio(choice: Element | null): boolean {
454
+ return choice instanceof HTMLInputElement
455
+ ? choice.type === 'radio'
456
+ : !!choice && choice.matches(RADIO_ROLE);
457
+ }
458
+
459
+ // The box a click on this control picks. One the click passed through below
460
+ // the control comes first: querySelector can't reach a slotted box, a box in a
461
+ // shadow root or the box of a label inside the control, and a row can hold
462
+ // more than one box. Else the control itself, the box it labels, or the first
463
+ // one it wraps, as on an option row whose name repeats the option's text.
464
+ function choiceOf(
465
+ control: Element,
466
+ path: Array<EventTarget | null>
467
+ ): Element | null {
468
+ for (const target of path) {
469
+ if (target === control) {
470
+ break;
471
+ }
472
+ const box = target instanceof Element ? boxOf(target) : null;
473
+ if (box) {
474
+ return box;
475
+ }
476
+ }
477
+ return boxOf(control) || control.querySelector(CHOICE);
301
478
  }
302
479
 
303
- // Describes a clicked control for auto button tracking. A link counts only when page
304
- // views can't see the click, and carries its link type.
480
+ // Describes a clicked control for auto button and link tracking. A link carries its
481
+ // link type, and one to another scheme is skipped. A role="button" anchor carries one
482
+ // only when it leaves the page; otherwise it stays a plain button.
305
483
  export function describeButtonClick(
306
- control: Element
484
+ control: Element,
485
+ path: Array<EventTarget | null> = []
307
486
  ): IButtonClick | undefined {
308
487
  let link: ILinkDetails | undefined;
309
488
  if (control.matches('a[href]:not([role="button"])')) {
@@ -311,33 +490,137 @@ export function describeButtonClick(
311
490
  if (!link) {
312
491
  return undefined;
313
492
  }
493
+ } else if (control.matches('a[href]')) {
494
+ link = describeLink(control);
495
+ if (link && link.type === 'same_page') {
496
+ link = undefined;
497
+ }
314
498
  }
315
499
  const element = elementKind(control);
316
- const name = accessibleName(control);
317
- const label = maskLabel(name.raw);
500
+ const box =
501
+ control instanceof HTMLInputElement &&
502
+ (control.type === 'checkbox' || control.type === 'radio')
503
+ ? control
504
+ : undefined;
505
+ // A box's text sent with its state is the shopper's answer, and the text
506
+ // can be a name, an address or a sensitive choice, so a box goes by its
507
+ // hand-written identifiers alone. So does a control acting as one or
508
+ // wrapping one, and a label, whose click the browser passes on to its box
509
+ // as a second event.
510
+ const choice = box || choiceOf(control, path);
511
+ // A checkbox's identifiers name its question, which its state answers. A
512
+ // radio's id, test id and the panel it opens name the option picked, so
513
+ // only the name of its input, the question, goes with it. A wrapper's own
514
+ // name, or a role radio's, can name the option instead.
515
+ const radio = isRadio(choice);
516
+ const named = !radio
517
+ ? control
518
+ : choice instanceof HTMLInputElement
519
+ ? choice
520
+ : null;
521
+ const name = choice ? undefined : accessibleName(control);
522
+ const label = name ? maskLabel(name.raw) : undefined;
318
523
  return {
319
524
  label,
320
525
  labelSource: label ? name.source : 'none',
321
526
  // Kept on its own as well: it often says more than the visible text,
322
527
  // such as which item a remove button removes.
323
- ariaLabel: maskLabel(control.getAttribute('aria-label')),
324
- icon: iconHint(control),
325
- elementId: stableIdentifier(control.getAttribute('id')),
326
- elementName: stableIdentifier(control.getAttribute('name')),
327
- testId: firstIdentifier(control, TEST_ID_ATTRIBUTES),
328
- controls: controlsIdentifier(control),
528
+ ariaLabel: choice
529
+ ? undefined
530
+ : maskLabel(control.getAttribute('aria-label')),
531
+ icon: choice ? undefined : iconHint(control),
532
+ elementId: radio
533
+ ? undefined
534
+ : stableIdentifier(control.getAttribute('id')),
535
+ elementName: stableIdentifier(named && named.getAttribute('name')),
536
+ testId: radio
537
+ ? undefined
538
+ : firstIdentifier(control, TEST_ID_ATTRIBUTES),
539
+ controls: radio ? undefined : controlsIdentifier(control),
329
540
  form: formIdentifier(control),
330
541
  element,
331
- type: controlType(control, element),
542
+ type: controlType(control),
332
543
  linkType: link ? link.type : undefined,
333
544
  linkDomain: link ? link.domain : undefined,
334
545
  fileExtension: link ? link.fileExtension : undefined,
335
546
  // The box as the click left it: the browser toggles it before the click
336
547
  // reaches listeners, and puts it back if the click is cancelled.
337
- checked:
338
- control instanceof HTMLInputElement &&
339
- (control.type === 'checkbox' || control.type === 'radio')
340
- ? control.checked
341
- : undefined,
548
+ checked: box ? box.checked : undefined,
342
549
  };
343
550
  }
551
+
552
+ // A question about payment, read from the names its author gave it, so
553
+ // "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
554
+ // "payee" does not. A radio's input name is its question; its group or
555
+ // fieldset speaks only for a radio with no name, as a role radio usually is.
556
+ const PAYMENT_QUESTION = /(?:^| )(?:pay|payment\w*|tenders?|gateways?)(?: |$)/;
557
+ const QUESTION_GROUP = '[role="radiogroup"], fieldset';
558
+
559
+ function isPaymentQuestion(radio: Element): boolean {
560
+ const name = radio.getAttribute('name');
561
+ const group = name ? null : radio.closest(QUESTION_GROUP);
562
+ const question = group
563
+ ? [
564
+ group.getAttribute('id'),
565
+ group.getAttribute('name'),
566
+ group.getAttribute('aria-label'),
567
+ ]
568
+ : [name];
569
+ return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
570
+ }
571
+
572
+ // What names a payment option: the radio's accessible name, the text and
573
+ // image of each label, and its hand-written ids, text masked like a label.
574
+ function optionNames(radio: Element): string[] {
575
+ const names: Array<string | undefined> = [
576
+ maskLabel(accessibleName(radio).raw),
577
+ ];
578
+ const labels =
579
+ radio instanceof HTMLInputElement && radio.labels
580
+ ? Array.from(radio.labels).slice(0, 2)
581
+ : [];
582
+ labels.forEach(label => {
583
+ if (isShown(label) && !isUnsafeTextSource(label, radio)) {
584
+ names.push(maskLabel(renderedText(label)));
585
+ names.push(maskLabel(shownImageAlt(label)));
586
+ }
587
+ });
588
+ names.push(
589
+ stableIdentifier(radio.getAttribute('id')),
590
+ firstIdentifier(radio, TEST_ID_ATTRIBUTES)
591
+ );
592
+ return names.filter(
593
+ (name, index) => !!name && names.indexOf(name) === index
594
+ );
595
+ }
596
+
597
+ // The payment radio a click picks and the names of its option, for the Rokt
598
+ // kit alone to read a payment method from. A radio's option is the shopper's
599
+ // answer, so these names never go into the logged click.
600
+ export interface IPaymentChoice {
601
+ radio: Element;
602
+ names: string[];
603
+ }
604
+
605
+ export function describePaymentChoice(
606
+ control: Element,
607
+ path: Array<EventTarget | null> = []
608
+ ): IPaymentChoice | undefined {
609
+ const choice = choiceOf(control, path);
610
+ if (
611
+ !isRadio(choice) ||
612
+ hasComposedAncestor(choice, '[data-mp-no-track]') ||
613
+ !isPaymentQuestion(choice)
614
+ ) {
615
+ return undefined;
616
+ }
617
+ const names = optionNames(choice);
618
+ return names.length ? { radio: choice, names } : undefined;
619
+ }
620
+
621
+ // Read once the page has handled the click, which may have cancelled it.
622
+ export function isPicked(radio: Element): boolean {
623
+ return radio instanceof HTMLInputElement
624
+ ? radio.checked
625
+ : radio.getAttribute('aria-checked') === 'true';
626
+ }