@mparticle/web-sdk 3.17.0 → 3.19.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.
@@ -0,0 +1,5 @@
1
+ import { BaseEvent } from '../sdkRuntimeModels';
2
+ import { IButtonClick } from './buttonDescription';
3
+ export declare const AUTO_BUTTON_ATTRIBUTE = "is_auto_button_click";
4
+ export declare const BUTTON_CLICK_EVENT_NAME = "Auto Button Click";
5
+ export declare const buildButtonClickEvent: (click: IButtonClick, pathname: string) => BaseEvent;
@@ -0,0 +1,16 @@
1
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
2
+ export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
3
+ export interface IButtonClick {
4
+ label?: string;
5
+ labelSource: ButtonLabelSource;
6
+ ariaLabel?: string;
7
+ icon?: string;
8
+ elementId?: string;
9
+ elementName?: string;
10
+ testId?: string;
11
+ controls?: string;
12
+ form?: string;
13
+ element: ButtonElementKind;
14
+ type: string;
15
+ }
16
+ export declare function describeButtonClick(control: Element): IButtonClick | undefined;
@@ -0,0 +1,35 @@
1
+ import { IMParticleWebSDKInstance } from '../mp-instance';
2
+ export declare const WIN_BTN_KEY = "__mpBtn__";
3
+ export interface IButtonTrackerOptions {
4
+ hasRoktKit: boolean;
5
+ }
6
+ export declare const getActiveButtonTracker: () => ButtonTracker | undefined;
7
+ export declare const resetButtonTracking: () => void;
8
+ export declare class ButtonTracker {
9
+ private readonly mpInstance;
10
+ private active;
11
+ private hasRoktKit;
12
+ private generation;
13
+ private lastCommitClick;
14
+ private lastSent;
15
+ private pagePath;
16
+ private pageEvents;
17
+ private pending;
18
+ constructor(mpInstance: IMParticleWebSDKInstance);
19
+ get isActive(): boolean;
20
+ init(options: IButtonTrackerOptions): void;
21
+ teardown(): void;
22
+ clearPending(): void;
23
+ private reset;
24
+ private enqueue;
25
+ private readonly drain;
26
+ private readonly onHide;
27
+ private readonly onPageShow;
28
+ private readonly onEvent;
29
+ private observe;
30
+ private logClick;
31
+ private emit;
32
+ private eventsEnabled;
33
+ private isKitSuppressed;
34
+ private log;
35
+ }
@@ -0,0 +1,3 @@
1
+ export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame';
2
+ export declare function classifyIntentControl(control: Element, pathname: string): Exclude<CheckoutIntentKind, 'frame'> | undefined;
3
+ export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'frame' | undefined;
@@ -0,0 +1,6 @@
1
+ export declare const CONTROL_SELECTOR = "button, input[type=\"submit\"], input[type=\"button\"], input[type=\"image\"], [role=\"button\"]";
2
+ export declare function hasComposedAncestor(element: Element, selector: string): boolean;
3
+ export declare function isRoktElement(element: Element): boolean;
4
+ export declare function isWalletElement(element: Element): boolean;
5
+ export declare function findControl(path: Array<EventTarget | null>): Element | undefined;
6
+ export declare function isUnsafeTextSource(element: Element): boolean;
@@ -0,0 +1 @@
1
+ export declare function maskLabel(raw: string | null | undefined): string | undefined;
@@ -162,7 +162,7 @@ declare const Constants: {
162
162
  readonly CaptureIntegrationSpecificIdsV2: "captureIntegrationSpecificIds.V2";
163
163
  readonly AstBackgroundEvents: "astBackgroundEvents";
164
164
  readonly AutoLogPageView: "autoLogPageView";
165
- readonly CheckoutIntent: "checkoutIntent";
165
+ readonly AutoButtonTracking: "autoButtonTracking";
166
166
  };
167
167
  readonly DefaultInstance: "default_instance";
168
168
  readonly CCPAPurpose: "data_sale_opt_out";
@@ -22,7 +22,7 @@ import { ICookieConsentManager } from './cookieConsentManager';
22
22
  import { ErrorReportingDispatcher } from './reporting/errorReportingDispatcher';
23
23
  import { LoggingDispatcher } from './reporting/loggingDispatcher';
24
24
  import { PageViewTracker } from './pageViewTracker';
25
- import { CheckoutIntentTracker } from './checkoutIntentTracker';
25
+ import { ButtonTracker } from './buttonTracking/buttonTracker';
26
26
  export interface IErrorLogMessage {
27
27
  message?: string;
28
28
  name?: string;
@@ -48,7 +48,7 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK {
48
48
  _IntegrationCapture: IntegrationCapture;
49
49
  _NativeSdkHelpers: INativeSdkHelpers;
50
50
  _PageViewTracker?: PageViewTracker;
51
- _CheckoutIntentTracker?: CheckoutIntentTracker;
51
+ _ButtonTracker?: ButtonTracker;
52
52
  _Persistence: IPersistence;
53
53
  _CookieConsentManager: ICookieConsentManager;
54
54
  _ErrorReportingDispatcher: ErrorReportingDispatcher;
@@ -8,7 +8,7 @@ import { IStore, LocalSessionAttributes } from "./store";
8
8
  import { IErrorReportingService, ILoggingService } from "./reporting/types";
9
9
  import { IRoktLauncherOptions } from "./roktLauncherOptions";
10
10
  import IntegrationCapture from "./integrationCapture";
11
- import type { CheckoutIntentKind } from "./checkoutIntentClassifier";
11
+ import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
12
12
  export type RoktAttributeValueArray = Array<string | number | boolean>;
13
13
  export type RoktAttributeValueType = string | number | boolean | undefined | null;
14
14
  export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
@@ -79,7 +79,7 @@ export interface IFeatureFlags {
79
79
  'captureIntegrationSpecificIds.V2'?: string;
80
80
  astBackgroundEvents?: boolean;
81
81
  autoLogPageView?: boolean;
82
- checkoutIntent?: boolean;
82
+ autoButtonTracking?: boolean;
83
83
  }
84
84
  export interface IStore {
85
85
  isEnabled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.17.0",
3
+ "version": "3.19.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -32,11 +32,11 @@
32
32
  },
33
33
  "repository": "https://github.com/mParticle/mparticle-web-sdk",
34
34
  "scripts": {
35
- "build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run build:types",
35
+ "build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run check:ascii:dist && npm run build:types",
36
36
  "build:types": "rm -rf dist/types && (tsc -p tsconfig.types.json || true) && test -f dist/types/src/public-types.d.ts && test -f dist/types/src/internal-types.d.ts",
37
37
  "build:cbt": "rollup --config test/cross-browser-testing/rollup.CBT.config.js",
38
38
  "build:dev": "cross-env ENVIRONMENT=dev BUILD=iife rollup --config rollup.config.js",
39
- "build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js",
39
+ "build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js && npm run check:ascii:dist -- dist/mparticle.js",
40
40
  "build:npm": "cross-env ENVIRONMENT=prod BUILD=cjs rollup --config rollup.config.js",
41
41
  "build:esm": "cross-env ENVIRONMENT=prod BUILD=esm rollup --config rollup.config.js",
42
42
  "build:stub": "cross-env ENVIRONMENT=prod BUILD=stub rollup --config rollup.config.js",
@@ -93,6 +93,7 @@
93
93
  "gzip": "gzip -c dist/mparticle.min.js > dist/mparticle.min.js.gz",
94
94
  "bundle": "npm run uglify && npm run gzip",
95
95
  "report:bundled": "ls -l dist/mparticle.min.js.gz | awk '{print $5}'",
96
+ "check:ascii:dist": "node scripts/check-ascii-dist.js",
96
97
  "report:bundled:human": "ls -lh dist/mparticle.min.js.gz | awk '{print $5}'"
97
98
  },
98
99
  "pre-commit": [
@@ -0,0 +1,48 @@
1
+ import { BaseEvent } from '../sdkRuntimeModels';
2
+ import { EventType, MessageType } from '../types';
3
+ import { IButtonClick } from './buttonDescription';
4
+
5
+ // Marks an automatically logged button click, like `is_auto_page_view`.
6
+ export const AUTO_BUTTON_ATTRIBUTE = 'is_auto_button_click';
7
+
8
+ // Specific enough not to merge with a customer's own 'Button Click' event.
9
+ export const BUTTON_CLICK_EVENT_NAME = 'Auto Button Click';
10
+
11
+ // One custom event per click, logged like logLink() with the auto marker and the
12
+ // page fields that auto page views carry. No intent: classifying clicks is left
13
+ // to whoever reads the data.
14
+ export const buildButtonClickEvent = (
15
+ click: IButtonClick,
16
+ pathname: string
17
+ ): BaseEvent => {
18
+ const data: { [key: string]: string | boolean } = {
19
+ label_source: click.labelSource,
20
+ element: click.element,
21
+ type: click.type,
22
+ path: pathname,
23
+ hostname: window.location.hostname,
24
+ [AUTO_BUTTON_ATTRIBUTE]: true,
25
+ };
26
+ // Optional fields are left out when the button has no safe value for them.
27
+ const optional: { [key: string]: string | undefined } = {
28
+ label: click.label,
29
+ aria_label: click.ariaLabel,
30
+ icon: click.icon,
31
+ element_id: click.elementId,
32
+ element_name: click.elementName,
33
+ test_id: click.testId,
34
+ controls: click.controls,
35
+ form: click.form,
36
+ };
37
+ for (const key of Object.keys(optional)) {
38
+ if (optional[key]) {
39
+ data[key] = optional[key];
40
+ }
41
+ }
42
+ return {
43
+ messageType: MessageType.PageEvent,
44
+ name: BUTTON_CLICK_EVENT_NAME,
45
+ data,
46
+ eventType: EventType.Other,
47
+ };
48
+ };
@@ -0,0 +1,259 @@
1
+ import { isFunction } from '../utils';
2
+ import {
3
+ hasComposedAncestor,
4
+ isUnsafeTextSource,
5
+ isWalletElement,
6
+ } from './controls';
7
+ import { maskLabel } from './labelMasking';
8
+
9
+ // Describes a clicked button for auto button tracking: its masked name, icon,
10
+ // hand-written identifiers and kind. Never reads form field values.
11
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
12
+
13
+ export type ButtonLabelSource =
14
+ | 'labelledby'
15
+ | 'aria'
16
+ | 'text'
17
+ | 'alt'
18
+ | 'title'
19
+ | 'value'
20
+ | 'none';
21
+
22
+ export interface IButtonClick {
23
+ label?: string;
24
+ labelSource: ButtonLabelSource;
25
+ ariaLabel?: string;
26
+ icon?: string;
27
+ elementId?: string;
28
+ elementName?: string;
29
+ testId?: string;
30
+ controls?: string;
31
+ form?: string;
32
+ element: ButtonElementKind;
33
+ type: string;
34
+ }
35
+
36
+ function elementKind(control: Element): ButtonElementKind {
37
+ if (isWalletElement(control)) {
38
+ return 'wallet';
39
+ }
40
+ const tag = control.tagName.toLowerCase();
41
+ return tag === 'button' || tag === 'input' ? tag : 'role';
42
+ }
43
+
44
+ function textOf(element: Element | null): string {
45
+ return element && !isUnsafeTextSource(element) && element.textContent
46
+ ? element.textContent.trim()
47
+ : '';
48
+ }
49
+
50
+ function nonBlank(value: string | null): string | undefined {
51
+ return value && value.trim() ? value : undefined;
52
+ }
53
+
54
+ // The button's name as a screen reader would read it, simplified: the first of
55
+ // aria-labelledby, aria-label, own text (or an inner image's alt), title, and for
56
+ // inputs their value. The text sources are skipped for controls that hold form
57
+ // fields or more than four children, so a typed value is never read.
58
+ function accessibleName(
59
+ control: Element
60
+ ): { raw?: string; source: ButtonLabelSource } {
61
+ const labelledBy = control.getAttribute('aria-labelledby');
62
+ if (labelledBy) {
63
+ const root = (isFunction(control.getRootNode)
64
+ ? control.getRootNode()
65
+ : document) as Document | ShadowRoot;
66
+ const text = labelledBy
67
+ .split(/\s+/)
68
+ .map(id =>
69
+ textOf(root.getElementById ? root.getElementById(id) : null)
70
+ )
71
+ .filter(part => !!part)
72
+ .join(' ');
73
+ if (text) {
74
+ return { raw: text, source: 'labelledby' };
75
+ }
76
+ }
77
+ const aria = nonBlank(control.getAttribute('aria-label'));
78
+ if (aria) {
79
+ return { raw: aria, source: 'aria' };
80
+ }
81
+ const readable =
82
+ control.children.length <= 4 && !isUnsafeTextSource(control);
83
+ if (control instanceof HTMLInputElement) {
84
+ // The browser's normalised type: an unknown value reads as 'text'.
85
+ const type = control.type;
86
+ if (type === 'image') {
87
+ const alt = nonBlank(control.getAttribute('alt'));
88
+ if (alt) {
89
+ return { raw: alt, source: 'alt' };
90
+ }
91
+ } else if (type === 'submit' || type === 'button' || type === 'reset') {
92
+ const value = nonBlank(control.value);
93
+ if (value) {
94
+ return { raw: value, source: 'value' };
95
+ }
96
+ if (type !== 'button') {
97
+ // What the browser shows on an input without a value.
98
+ return { raw: type, source: 'value' };
99
+ }
100
+ }
101
+ } else if (readable) {
102
+ const text = textOf(control);
103
+ if (text) {
104
+ return { raw: text, source: 'text' };
105
+ }
106
+ const image = control.querySelector('img[alt]');
107
+ const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
108
+ if (alt) {
109
+ return { raw: alt, source: 'alt' };
110
+ }
111
+ }
112
+ const title = nonBlank(control.getAttribute('title'));
113
+ if (title) {
114
+ return { raw: title, source: 'title' };
115
+ }
116
+ return { source: 'none' };
117
+ }
118
+
119
+ const ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
120
+ // Size, style and animation classes that share the icon prefixes.
121
+ const ICON_MODIFIER = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|lg|xs|sm|xl|spin|pulse|border|inverse|stack|flip(?:-[a-z]+)?|rotate(?:-[a-z]+)?|pull-[a-z]+)$/;
122
+
123
+ function iconToken(candidate: string | null): string | undefined {
124
+ if (!candidate) {
125
+ return undefined;
126
+ }
127
+ const token = candidate.toLowerCase().replace(/^icon-/, '');
128
+ return /^[a-z][a-z-]{1,31}$/.test(token) ? token : undefined;
129
+ }
130
+
131
+ // An icon name from an SVG sprite reference or an icon-font class, for buttons
132
+ // whose only content is an icon. Letters and hyphens only.
133
+ function iconHint(control: Element): string | undefined {
134
+ const allowed = (element: Element) =>
135
+ !hasComposedAncestor(element, '[data-mp-no-track]');
136
+ const use = Array.from(control.querySelectorAll('use')).filter(allowed)[0];
137
+ if (use) {
138
+ const href =
139
+ use.getAttribute('href') || use.getAttribute('xlink:href') || '';
140
+ const hash = href.indexOf('#');
141
+ const sprite = iconToken(hash === -1 ? null : href.slice(hash + 1));
142
+ if (sprite) {
143
+ return sprite;
144
+ }
145
+ }
146
+ const candidates = [control]
147
+ .concat(Array.from(control.querySelectorAll('i, span, svg')))
148
+ .filter(allowed)
149
+ .slice(0, 5);
150
+ for (const element of candidates) {
151
+ const classes = (element.getAttribute('class') || '').split(/\s+/);
152
+ for (const name of classes) {
153
+ const match = ICON_CLASS.exec(name.toLowerCase());
154
+ if (match && !ICON_MODIFIER.test(match[1])) {
155
+ return match[1];
156
+ }
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ // Words of letters, each optionally ending in up to three digits, joined by
163
+ // hyphens or underscores: `add-to-cart`, `btnCheckout`, `step_2`. Generated ids
164
+ // (`ember1234`, `a8f3e2c1`, `radix-:r1:`) and long numbers don't match.
165
+ const HAND_WRITTEN_ID = /^[A-Za-z]+[0-9]{0,3}(?:[-_](?:[A-Za-z]+[0-9]{0,3}|[0-9]{1,3}))*$/;
166
+
167
+ // A developer-written identifier, or nothing.
168
+ function stableIdentifier(value: string | null): string | undefined {
169
+ if (!value) {
170
+ return undefined;
171
+ }
172
+ const id = value.trim();
173
+ // At most three digits in all, so numbers split into groups don't pass.
174
+ const digits = id.replace(/[^0-9]/g, '').length;
175
+ return id.length <= 64 && digits <= 3 && HAND_WRITTEN_ID.test(id)
176
+ ? id
177
+ : undefined;
178
+ }
179
+
180
+ const TEST_ID_ATTRIBUTES = [
181
+ 'data-testid',
182
+ 'data-test-id',
183
+ 'data-test',
184
+ 'data-qa',
185
+ 'data-cy',
186
+ ];
187
+
188
+ // The first of these attributes that holds a developer-written identifier.
189
+ function firstIdentifier(
190
+ control: Element,
191
+ attributes: string[]
192
+ ): string | undefined {
193
+ for (const attribute of attributes) {
194
+ const id = stableIdentifier(control.getAttribute(attribute));
195
+ if (id) {
196
+ return id;
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ // What the button opens or acts on: a menu, drawer or dialog. aria-controls can
203
+ // list several ids, so only the first is used.
204
+ function controlsIdentifier(control: Element): string | undefined {
205
+ const ariaControls = control.getAttribute('aria-controls');
206
+ return (
207
+ stableIdentifier(
208
+ ariaControls ? ariaControls.trim().split(/\s+/)[0] : null
209
+ ) || firstIdentifier(control, ['popovertarget', 'commandfor'])
210
+ );
211
+ }
212
+
213
+ // The id of the form a button or input actually submits, which the browser
214
+ // resolves from its form attribute or its owning form. getAttribute, because a
215
+ // field named "id" shadows form.id.
216
+ function formIdentifier(control: Element): string | undefined {
217
+ const form =
218
+ control instanceof HTMLButtonElement ||
219
+ control instanceof HTMLInputElement
220
+ ? control.form
221
+ : null;
222
+ return form ? stableIdentifier(form.getAttribute('id')) : undefined;
223
+ }
224
+
225
+ // The browser's normalised type: a button defaults to 'submit' and an unknown
226
+ // input type reads as 'text'. Wallet and role buttons are just 'button'.
227
+ function controlType(control: Element, element: ButtonElementKind): string {
228
+ return element === 'button' || element === 'input'
229
+ ? (control as HTMLButtonElement | HTMLInputElement).type
230
+ : 'button';
231
+ }
232
+
233
+ // Describes a clicked control for auto button tracking. Links are left to page
234
+ // views, so a link click is not described.
235
+ export function describeButtonClick(
236
+ control: Element
237
+ ): IButtonClick | undefined {
238
+ if (control.matches('a[href]:not([role="button"])')) {
239
+ return undefined;
240
+ }
241
+ const element = elementKind(control);
242
+ const name = accessibleName(control);
243
+ const label = maskLabel(name.raw);
244
+ return {
245
+ label,
246
+ labelSource: label ? name.source : 'none',
247
+ // Kept on its own as well: it often says more than the visible text,
248
+ // such as which item a remove button removes.
249
+ ariaLabel: maskLabel(control.getAttribute('aria-label')),
250
+ icon: iconHint(control),
251
+ elementId: stableIdentifier(control.getAttribute('id')),
252
+ elementName: stableIdentifier(control.getAttribute('name')),
253
+ testId: firstIdentifier(control, TEST_ID_ATTRIBUTES),
254
+ controls: controlsIdentifier(control),
255
+ form: formIdentifier(control),
256
+ element,
257
+ type: controlType(control, element),
258
+ };
259
+ }