@mparticle/web-sdk 3.26.0 → 3.27.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.
@@ -17,7 +17,17 @@ export type CheckoutIntentKind =
17
17
  | 'frame'
18
18
  | 'cart';
19
19
 
20
- const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
20
+ // A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
21
+ // Other hyphenated words make it a content slug, such as /help/checkout-faq or /products/self-checkout-kiosk.
22
+ const checkoutPath = /(?:^|\/)(?:(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?(?:-(?:v\d+|\d+|flow|external|process|step(?:-\d+)?))?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
23
+ // A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
24
+ // after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
25
+ // and /checkout/complete-registration stay checkout routes.
26
+ const confirmationPath = /(?:^|[\/_-])(?:thank[-_]?you|thanks|confirmation|confirmed|receipt|received)(?:[\/_-]|$)|(?:^|[\/_-])(?:complete|completed|success)(?:\/|$)/i;
27
+ // A bare "Register" creates an account, rather than placing the order, on a checkout's sign-in or account step or in a
28
+ // form that asks for a password.
29
+ const bareRegister = /^register(?: now)?$/;
30
+ const accountStepPath = /(?:^|\/)(?:register|registration|login|log-in|signin|sign-in|signup|sign-up|account|create-account)(?:\/|$)/i;
21
31
  // English look-alikes are whole words; the other languages stay substring
22
32
  // matches, since several of them do not separate words with spaces.
23
33
  const englishExclusions = new RegExp(
@@ -63,7 +73,7 @@ const linkCommits = new RegExp(
63
73
  ')$'
64
74
  );
65
75
  const routeGatedCommits = new RegExp(
66
- '^(?:buy now|purchase|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
76
+ '^(?:buy now|purchase|register(?: now)?|complete (?:my |your )?registration|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
67
77
  'comprar ahora|acquista ora|nu kopen|今すぐ購入|購入する|この内容で申し込む)$'
68
78
  );
69
79
  const steps = new RegExp(
@@ -156,7 +166,9 @@ const EDGE =
156
166
  const EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
157
167
 
158
168
  function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
159
- return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
169
+ return checkoutPath.test(pathname) && !confirmationPath.test(pathname)
170
+ ? 'checkout_path'
171
+ : 'other';
160
172
  }
161
173
 
162
174
  const SEPARATORS = /[-_|·•]/g;
@@ -262,16 +274,40 @@ export interface ICheckoutIntent {
262
274
  adds?: true;
263
275
  }
264
276
 
265
- // "Buy now" and "Purchase" count only on a checkout route, and never on a plain
266
- // link, which is navigation.
267
- function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean {
277
+ // Every field the browser counts as the form's own, form-attribute fields included. Read through the native getter
278
+ // where the browser has one, since a field named "elements" shadows the form's own property.
279
+ function formFields(form: HTMLFormElement): ArrayLike<Element> {
280
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, 'elements');
281
+ return descriptor && descriptor.get ? descriptor.get.call(form) : form.elements;
282
+ }
283
+
284
+ // The form a button or input submits, which its form attribute can name from outside it.
285
+ function createsAccount(control: Element, pathname: string): boolean {
286
+ const form =
287
+ control instanceof HTMLButtonElement || control instanceof HTMLInputElement
288
+ ? control.form
289
+ : control.closest('form');
290
+ return (
291
+ accountStepPath.test(pathname) ||
292
+ (!!form &&
293
+ Array.prototype.some.call(
294
+ formFields(form),
295
+ (field: Element) => field instanceof HTMLInputElement && field.type === 'password'
296
+ ))
297
+ );
298
+ }
299
+
300
+ // "Buy now", "Purchase" and "Register" count only on a checkout route that isn't a
301
+ // confirmation page, and never on a plain link, which is navigation.
302
+ function isCommitLabel(text: string, isLink: boolean, pathname: string, control: Element): boolean {
268
303
  if (isLink) {
269
304
  return linkCommits.test(text);
270
305
  }
271
306
  return (
272
307
  commits.test(text) ||
273
308
  (routeGatedCommits.test(text) &&
274
- getIntentRouteClass(pathname) === 'checkout_path')
309
+ getIntentRouteClass(pathname) === 'checkout_path' &&
310
+ !(bareRegister.test(text) && createsAccount(control, pathname)))
275
311
  );
276
312
  }
277
313
 
@@ -311,7 +347,7 @@ export function classifyIntent(
311
347
  return cart;
312
348
  }
313
349
  const isLink = control.matches('a[href]:not([role="button"])');
314
- if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
350
+ if (cores.some(text => isCommitLabel(text, isLink, pathname, control))) {
315
351
  return { kind: 'commit', source: 'label' };
316
352
  }
317
353
  if (
@@ -283,7 +283,8 @@ export function isExcludedPath(path: Array<EventTarget | null>): boolean {
283
283
  // found the path not excluded passes `included` so the walk runs once.
284
284
  export function findControl(
285
285
  path: Array<EventTarget | null>,
286
- included = false
286
+ included = false,
287
+ limit = 12
287
288
  ): Element | undefined {
288
289
  if (!included && isExcludedPath(path)) {
289
290
  return undefined;
@@ -291,7 +292,7 @@ export function findControl(
291
292
  let control: Element | undefined;
292
293
  let option: Element | undefined;
293
294
  let picked = false;
294
- for (const target of path.slice(0, 12)) {
295
+ for (const target of path.slice(0, limit)) {
295
296
  if (control) {
296
297
  break;
297
298
  }
@@ -321,6 +322,57 @@ export function findControl(
321
322
  return control;
322
323
  }
323
324
 
325
+ // Roles that act as controls without being buttons or links: pickers, tabs,
326
+ // options and links run by script. A role can list fallbacks, so any of its
327
+ // tokens counts.
328
+ const ROLE_CONTROL =
329
+ '[role~="radio"], [role~="menuitemradio"], [role~="checkbox"], [role~="menuitemcheckbox"], ' +
330
+ '[role~="switch"], [role~="option"], [role~="tab"], [role~="link"]';
331
+
332
+ const TYPING_FIELD =
333
+ 'input:not([type="submit"]):not([type="button"]):not([type="image"]):not([type="reset"]):not([type="radio"]):not([type="checkbox"]), ' +
334
+ 'textarea, select, ' +
335
+ EDITABLE;
336
+
337
+ // The nearest role control on the click path, for logging the click alone:
338
+ // checkout intent and payment choices only ever read what findControl finds.
339
+ // None when anything disabled sits on the path, so a role control around or
340
+ // inside a disabled control never logs the click it suppressed.
341
+ export function findRoleControl(
342
+ path: Array<EventTarget | null>,
343
+ limit: number
344
+ ): Element | undefined {
345
+ const entries = path
346
+ .slice(0, limit)
347
+ .filter((target): target is Element => target instanceof Element);
348
+ if (
349
+ entries.some(
350
+ target =>
351
+ target.matches(':disabled, [aria-disabled="true"]') ||
352
+ (target.classList.contains('disabled') &&
353
+ isWalletNamed(target))
354
+ )
355
+ ) {
356
+ return undefined;
357
+ }
358
+ for (const target of entries) {
359
+ if (target.matches(ROLE_CONTROL)) {
360
+ // A typing field, or anything in an editable region, takes typing
361
+ // whatever role it carries.
362
+ return target.matches(TYPING_FIELD) ||
363
+ hasComposedAncestor(target, EDITABLE)
364
+ ? undefined
365
+ : target;
366
+ }
367
+ // A click in a typing field belongs to the field, not to the role
368
+ // control around it.
369
+ if (target.matches(TYPING_FIELD)) {
370
+ return undefined;
371
+ }
372
+ }
373
+ return undefined;
374
+ }
375
+
324
376
  // Text that may hold what a shopper typed or what the page opted out: form
325
377
  // fields, editable regions, and anything under data-mp-no-track. `own` is a
326
378
  // box the element labels, which holds no typing of its own.
@@ -42,3 +42,9 @@ export function maskLabel(raw: string | null | undefined): string | undefined {
42
42
  .trim();
43
43
  return masked || undefined;
44
44
  }
45
+
46
+ // Masks numbers and amounts in any script as '#', or undefined where the browser
47
+ // can't see every script's digits.
48
+ export function maskNumbers(text: string): string | undefined {
49
+ return digitPatterns ? text.replace(digitPatterns.number, '#') : undefined;
50
+ }
package/src/pageUrl.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import { isAllowedQueryParam } from './pageViewTracker';
2
+ import { pagePath, pageUrlPath } from './pathMasking';
2
3
  import { getHref, valueof } from './utils';
3
4
 
4
5
  // The one rule for a URL that leaves the SDK, shared with kits through
5
- // `mParticle._cleanUrl`. `path` keeps scheme, host and path. `full` adds the
6
+ // `mParticle._cleanUrl`. `path` keeps scheme, host and path, with any path
7
+ // segment that can single out one shopper as `*` (see pageUrlPath). `full` adds the
6
8
  // query params an auto page view captures by default and the campaign
7
- // attribution params, then the fragment.
9
+ // attribution params, then the fragment, a hash route's path masked the same way.
8
10
  export const UrlMode = {
9
11
  Path: 'path',
10
12
  Full: 'full',
13
+ // A path for a log or an error report, masked with the click path's fuller
14
+ // rules, since no output reads it as a page.
15
+ Report: 'report',
11
16
  } as const;
12
17
 
13
18
  export type UrlMode = valueof<typeof UrlMode>;
@@ -79,6 +84,37 @@ export const EXCLUDED_URL_FRAGMENT_PARAMS: string[] = EXCLUDED_URL_QUERY_PARAMS.
79
84
  const URL_VALUED_QUERY_PARAMS: string[] = ['ref', 'referrer'];
80
85
 
81
86
  const USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
87
+ const ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
88
+
89
+ // Only a URL or a path is masked. A bare value, such as `ref=spring2024`, is a
90
+ // code rather than a page, and stays as written.
91
+ const maskPath = (
92
+ url: string,
93
+ mask: (pathname: string) => string = pageUrlPath
94
+ ): string => {
95
+ const origin = ORIGIN.exec(url);
96
+ if (origin) {
97
+ return origin[0] + mask(url.slice(origin[0].length));
98
+ }
99
+ return url.charAt(0) === '/' ? mask(url) : url;
100
+ };
101
+
102
+ // A hash route keeps its `#` or `#!` and has its path masked as the URL's own.
103
+ // The `&` pairs or flags after the path lose the OAuth/OIDC ones, as a bare
104
+ // fragment's pairs do.
105
+ const maskRoute = (route: string): string => {
106
+ const prefix = /^#!?/.exec(route)[0];
107
+ const pairsStart = route.indexOf('&');
108
+ const pathEnd = pairsStart === -1 ? route.length : pairsStart;
109
+ const pairs = route
110
+ .slice(pathEnd)
111
+ .split('&')
112
+ .slice(1)
113
+ .filter(keepsFragmentPair)
114
+ .map(pair => `&${pair}`)
115
+ .join('');
116
+ return prefix + pageUrlPath(route.slice(prefix.length, pathEnd)) + pairs;
117
+ };
82
118
 
83
119
  const decodeQueryComponent = (component: string): string =>
84
120
  decodeURIComponent(component.replace(/\+/g, ' '));
@@ -140,21 +176,31 @@ const keepsFragmentPair = (pair: string): boolean => {
140
176
  const cleanFragment = (hash: string): string => {
141
177
  const queryStart = hash.indexOf('?');
142
178
  const route = queryStart === -1 ? hash : hash.slice(0, queryStart);
179
+ // `#/…`, `#!…`, or a fragment with a `/` before any `&` or `?`, as
180
+ // `#orders/12345678` is in routers that drop the leading slash.
181
+ // A `/` counts only before any `=`, so a bare pair such as
182
+ // `#access_token=abc/def` is never read as a route.
183
+ const isHashRoute = /^#!|^#[^?&=]*\//.test(hash);
143
184
  const isRoute =
144
- /^#[/!]/.test(hash) || (queryStart !== -1 && route.indexOf('=') === -1);
185
+ isHashRoute || (queryStart !== -1 && route.indexOf('=') === -1);
145
186
 
146
187
  if (isRoute && queryStart !== -1) {
147
188
  const query = cleanQuery(hash.slice(queryStart + 1));
148
- return route + (query ? `?${query}` : '');
189
+ return maskRoute(route) + (query ? `?${query}` : '');
149
190
  }
150
191
 
192
+ // A plain anchor such as `#reviews` names a part of the page, not a route.
151
193
  if (hash.indexOf('=') === -1) {
152
- return hash;
194
+ return isHashRoute ? maskRoute(hash) : hash;
153
195
  }
154
196
 
197
+ // A hash route's path leads its pairs, as in `#/cb&tab=2`.
155
198
  const pairs = hash
156
199
  .slice(1)
157
200
  .split('&')
201
+ .map((pair, i) =>
202
+ i === 0 && isHashRoute ? maskRoute(`#${pair}`).slice(1) : pair
203
+ )
158
204
  .filter(keepsFragmentPair)
159
205
  .join('&');
160
206
  return pairs ? `#${pairs}` : '';
@@ -172,10 +218,13 @@ export const cleanUrl = (href: string, mode: UrlMode): string => {
172
218
  const hash = hashStart === -1 ? '' : href.slice(hashStart);
173
219
  const beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
174
220
  const queryStart = beforeHash.indexOf('?');
175
- const path = (queryStart === -1
176
- ? beforeHash
177
- : beforeHash.slice(0, queryStart)
178
- ).replace(USERINFO, '$1');
221
+ const path = maskPath(
222
+ (queryStart === -1
223
+ ? beforeHash
224
+ : beforeHash.slice(0, queryStart)
225
+ ).replace(USERINFO, '$1'),
226
+ mode === UrlMode.Report ? pagePath : pageUrlPath
227
+ );
179
228
 
180
229
  if (mode !== UrlMode.Full) {
181
230
  return path;
@@ -2,6 +2,7 @@ import { IMParticleWebSDKInstance } from './mp-instance';
2
2
  import { BaseEvent } from './sdkRuntimeModels';
3
3
  import { EventType, MessageType } from './types';
4
4
  import { Dictionary, getHref, hasOwnProp, queryStringParser } from './utils';
5
+ import { pagePath, pageUrlPath } from './pathMasking';
5
6
  import {
6
7
  subscribeToRouteChange,
7
8
  supportsHistoryTracking,
@@ -318,7 +319,8 @@ export const buildPageViewEvent = ({
318
319
  ...paramsToAttributes(params),
319
320
  hostname,
320
321
  title,
321
- path,
322
+ // Masked here, not in the page snapshot, whose raw path tells one page from the next.
323
+ path: pageUrlPath(path),
322
324
  ...autoPageViewAttribute(isAutoPageView),
323
325
  },
324
326
  eventType: EventType.Unknown,
@@ -422,7 +424,9 @@ const describePage = (page: IPageSnapshot | null): string => {
422
424
  }
423
425
 
424
426
  const names = page.params.map(({ name }) => name);
425
- return names.length ? `${page.path} (params: ${names.join()})` : page.path;
427
+ // A log line is read by no output, so it takes the click path's fuller rules.
428
+ const path = pagePath(page.path);
429
+ return names.length ? `${path} (params: ${names.join()})` : path;
426
430
  };
427
431
 
428
432
  // ---------------------------------------------------------------------------
@@ -0,0 +1,199 @@
1
+ // Masks the parts of a page path that can single out one shopper: pagePath for
2
+ // the path a click logs, pageUrlPath for the page URL and page views every event
3
+ // carries.
4
+
5
+ // A token of letters and digits mixed, six or more long, as session ids,
6
+ // hex and UUID parts and invite codes are: `A1B2C3D4E5F6`, `550e8400`.
7
+ const MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{6,}$/;
8
+ // Letters alone, eight or more long, switching case three times or more, as a
9
+ // random code does (`xKpQzRtV`) and a camel-case route word rarely does.
10
+ const CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{8,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
11
+ // Route words whose next segment names one person or carries a one-time code,
12
+ // as in `/profile/jane-smith` or `/reset/abcdefgh`.
13
+ const PERSONAL_ROUTE = /^(?:u|user|users|profile|profiles|member|members|people|reset|reset-password|password|verify|verification|confirm|activate|invite|invitation|token|unsubscribe|magic-link)$/i;
14
+
15
+ // Route words whose next segment is one shopper's order: any with `order` or
16
+ // `orders` as a whole word part, as in `view-order`, `order-received` or `order_id`.
17
+ const ORDER_ROUTE = /(?:^|[-_])orders?(?:[-_]|$)/i;
18
+ // Product words that name a way to buy, not an order: `pre-order`, `on-order`,
19
+ // `back-order`, `made-to-order`, as the whole route word only, so
20
+ // `return-to-order` and `pre-order-status` stay routes.
21
+ const PRODUCT_ORDER_WORD = /^(?:(?:pre|on|back)[-_]|made[-_]to[-_])orders?$/i;
22
+ // A `;` that starts a matrix parameter, plain or encoded any number of times.
23
+ const MATRIX_SEPARATOR = /;|%(?:25)*3B/i;
24
+ // A valid escape left after decoding stopped, as when a literal `%` before it
25
+ // can't be decoded again. What it hides is unknown, so it counts as sensitive.
26
+ export const LEFTOVER_ESCAPE = /%[0-9A-F]{2}/i;
27
+
28
+ export function hidesNextSegment(route: string): boolean {
29
+ return (
30
+ PERSONAL_ROUTE.test(route) ||
31
+ (ORDER_ROUTE.test(route) && !PRODUCT_ORDER_WORD.test(route))
32
+ );
33
+ }
34
+
35
+ // How many times a value is decoded at most, so one encoded twice or more reads
36
+ // as its plain text.
37
+ const MAX_DECODES = 5;
38
+
39
+ // Decodes until the text stops changing. Undefined when the first decode fails;
40
+ // a later failure keeps what decoded so far, as a literal `%` would.
41
+ export function decodeFully(text: string): string | undefined {
42
+ let current = text;
43
+ for (let i = 0; i < MAX_DECODES; i++) {
44
+ let next: string;
45
+ try {
46
+ next = decodeURIComponent(current);
47
+ } catch (e) {
48
+ return i === 0 ? undefined : current;
49
+ }
50
+ if (next === current) {
51
+ return current;
52
+ }
53
+ current = next;
54
+ }
55
+ return current;
56
+ }
57
+
58
+ // The route word in a raw path segment: its last part behind an encoded slash,
59
+ // read before any matrix parameter such as `;jsessionid=`, its `;` encoded once,
60
+ // twice or not at all, so a parameter's own value never hides it. Undefined when
61
+ // it can't be decoded, or is still encoded after MAX_DECODES.
62
+ function routeWord(segment: string): string | undefined {
63
+ let text = segment;
64
+ for (let i = 0; i < MAX_DECODES; i++) {
65
+ const route = text.split(MATRIX_SEPARATOR)[0];
66
+ let next: string;
67
+ try {
68
+ next = decodeURIComponent(route);
69
+ } catch (e) {
70
+ return i === 0 ? undefined : route.split('/').pop();
71
+ }
72
+ if (next === route) {
73
+ return route.split('/').pop();
74
+ }
75
+ text = next;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ // The last segment before index i that isn't empty, so `/orders//12345678`
81
+ // still reads the order route.
82
+ export function routeSegment(segments: string[], i: number): string {
83
+ let routeIndex = i - 1;
84
+ while (routeIndex > 0 && segments[routeIndex] === '') {
85
+ routeIndex--;
86
+ }
87
+ return segments[routeIndex];
88
+ }
89
+
90
+ // A piece with a lowercase letter right before an uppercase one, as a random
91
+ // code has and a Title-Case word doesn't.
92
+ const INNER_CASE_SWITCH = /[a-z][A-Z]/;
93
+
94
+ const isToken = (text: string): boolean =>
95
+ MIXED_TOKEN.test(text) || CASE_SWITCHING_TOKEN.test(text);
96
+
97
+ const mixesLettersAndDigits = (piece: string): boolean =>
98
+ /[0-9]/.test(piece) && /[A-Za-z]/.test(piece);
99
+
100
+ // `+` sits inside base64 tokens, so it never splits one. A token that `-` or `_`
101
+ // cuts into short pieces is still one when the pieces read as one joined and
102
+ // either a piece switches case inside it or two or more pieces mix letters and
103
+ // digits, as `a1b2-c3d4-e5f6` does. Title-Case-Words and plain slugs such as
104
+ // `iphone-15-pro` do neither.
105
+ export function isTokenSegment(segment: string): boolean {
106
+ const text = segment.replace(/\+/g, '');
107
+ const pieces = text.split(/[-_.~]/);
108
+ const joined = pieces.join('');
109
+ return (
110
+ pieces.some(isToken) ||
111
+ (pieces.some(piece => INNER_CASE_SWITCH.test(piece)) &&
112
+ isToken(joined)) ||
113
+ (pieces.filter(mixesLettersAndDigits).length >= 2 &&
114
+ MIXED_TOKEN.test(joined))
115
+ );
116
+ }
117
+
118
+ // The path a click logs. A segment that can single out one shopper
119
+ // goes as `*`: one holding an @, `;` or `=`, a token, or the one after a route
120
+ // that names a person, carries a code or opens an order. Kept segments stay
121
+ // exactly as the page has them, plain numbers included, since on a product page
122
+ // they name the product.
123
+ export function pagePath(pathname: string): string {
124
+ const segments = pathname.split('/');
125
+ return segments
126
+ .map((segment, i) => {
127
+ const text = decodeFully(segment);
128
+ const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
129
+ // A route word that can't be decoded may be any route, so the
130
+ // segment after it is hidden too.
131
+ const hiddenByRoute =
132
+ i > 0 &&
133
+ !!text &&
134
+ (route === undefined || hidesNextSegment(route));
135
+ // An encoded slash hides several segments in one, so it goes whole.
136
+ return text === undefined ||
137
+ /[@;=/]/.test(text) ||
138
+ LEFTOVER_ESCAPE.test(text) ||
139
+ isTokenSegment(text) ||
140
+ hiddenByRoute
141
+ ? '*'
142
+ : segment;
143
+ })
144
+ .join('/');
145
+ }
146
+
147
+ // A token long enough that no page or product name looks like it: 16 or more
148
+ // letters and digits mixed, as checkout tokens are, 16 or more letters
149
+ // switching case like a random code, or a UUID.
150
+ const LONG_MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{16,}$/;
151
+ const LONG_CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{16,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
152
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
153
+
154
+ // A token that `-` or `_` cuts into shorter pieces counts when two or more
155
+ // pieces mix letters and digits, as `a1b2c3d4-e5f6g7h8` does and a product slug
156
+ // such as `acmephone-15-pro-max-256gb` doesn't.
157
+ function isLongToken(segment: string): boolean {
158
+ const pieces = segment.replace(/\+/g, '').split(/[-_.~]/);
159
+ return (
160
+ UUID.test(segment) ||
161
+ pieces.some(
162
+ piece =>
163
+ LONG_MIXED_TOKEN.test(piece) ||
164
+ LONG_CASE_SWITCHING_TOKEN.test(piece)
165
+ ) ||
166
+ (pieces.filter(mixesLettersAndDigits).length >= 2 &&
167
+ LONG_MIXED_TOKEN.test(pieces.join('')))
168
+ );
169
+ }
170
+
171
+ // The path every event carries, in its page URL and page views, where a
172
+ // wrong `*` costs every output the page it names. Only what reliably singles
173
+ // out one shopper goes as `*`: a segment holding an @, `;` or `=`, one that
174
+ // can't be decoded or still holds an escape, a long token, and the one after a
175
+ // route that names a person or opens an order when it holds a digit or a token.
176
+ // Product codes and pages such as `/profile/edit` or `/orders/history` stay.
177
+ export function pageUrlPath(pathname: string): string {
178
+ const segments = pathname.split('/');
179
+ return segments
180
+ .map((segment, i) => {
181
+ const text = decodeFully(segment);
182
+ const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
183
+ const holdsId =
184
+ !!text && (/[0-9]/.test(text) || isTokenSegment(text));
185
+ // A route word that can't be decoded may be any route.
186
+ const hiddenByRoute =
187
+ i > 0 &&
188
+ !!text &&
189
+ (route === undefined || (hidesNextSegment(route) && holdsId));
190
+ return text === undefined ||
191
+ /[@;=/]/.test(text) ||
192
+ LEFTOVER_ESCAPE.test(text) ||
193
+ isLongToken(text) ||
194
+ hiddenByRoute
195
+ ? '*'
196
+ : segment;
197
+ })
198
+ .join('/');
199
+ }