@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.18.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +860 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +504 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/debug.js +45 -0
  22. package/src/decisions.js +90 -0
  23. package/src/device-storage.js +213 -0
  24. package/src/experiments.js +220 -0
  25. package/src/ga4.js +124 -0
  26. package/src/hash.js +138 -0
  27. package/src/hp.js +135 -0
  28. package/src/index.js +840 -0
  29. package/src/preview.js +44 -0
  30. package/src/recommendation-context.js +101 -0
  31. package/src/sampling.js +19 -0
  32. package/src/segment.js +388 -0
  33. package/src/session/category.js +40 -0
  34. package/src/session/record.js +176 -0
  35. package/src/session/rules.js +195 -0
  36. package/src/session/signals.js +150 -0
  37. package/src/session-id.js +62 -0
  38. package/src/session.js +28 -0
  39. package/src/settings.js +230 -0
  40. package/src/shape.js +120 -0
  41. package/src/social-proof.js +139 -0
  42. package/src/spec-version.js +4 -0
  43. package/src/storage-scope.js +164 -0
  44. package/src/transport.js +451 -0
  45. package/src/version.js +2 -0
  46. package/types/auto.d.ts +51 -0
  47. package/types/bootstrap.d.ts +165 -0
  48. package/types/consent.d.ts +126 -0
  49. package/types/datalayer.d.ts +76 -0
  50. package/types/events.d.ts +713 -0
  51. package/types/hp.d.ts +64 -0
  52. package/types/index.d.ts +617 -0
  53. package/types/segment.d.ts +68 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Two device writes beyond the SDK's own buffers.
3
+ *
4
+ * held store events the consent gate holds for an unanswered CMP, in sessionStorage per
5
+ * property, so a navigation before the answer (a sign-in redirect) does not lose
6
+ * them (`holdAcrossPages`, 3.5). They leave only on a grant, on whichever page it
7
+ * comes; a refusal deletes them.
8
+ * device cookie Hyper-Personalisation's device id, for a source whose settings say `hp_cookie`
9
+ * (3.6). Under strict consent it is written only once analytics is granted.
10
+ */
11
+
12
+ import { getCookie, perRequest } from './context.js';
13
+ import { realId, uuid } from './shape.js';
14
+ import { storageKey, storageName } from './storage-scope.js';
15
+
16
+ /** How long held events may wait for an answer across pages. A tab left for longer starts over. */
17
+ export const HELD_MAX_AGE_MS = 30 * 60 * 1000;
18
+
19
+ const session = () => {
20
+ try {
21
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ const local = () => {
28
+ try {
29
+ return typeof localStorage === 'undefined' ? null : localStorage;
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+ /**
36
+ * @param {string} key A property-scoped key (storageKey(storageName('held'), propertyId)).
37
+ * @param {() => number} [now]
38
+ */
39
+ export function createHeldStore(key, now = () => Date.now()) {
40
+ return {
41
+ /** @returns {{ items: object[], anonymousId: string|null }} */
42
+ read() {
43
+ try {
44
+ const raw = session()?.getItem(key);
45
+ const saved = raw ? JSON.parse(raw) : null;
46
+ if (!saved || now() - saved.savedAt > HELD_MAX_AGE_MS) {
47
+ if (saved) session()?.removeItem(key);
48
+ return { items: [], anonymousId: null };
49
+ }
50
+ return {
51
+ items: Array.isArray(saved.items) ? saved.items : [],
52
+ anonymousId:
53
+ typeof saved.anonymousId === 'string' ? saved.anonymousId : null,
54
+ };
55
+ } catch {
56
+ return { items: [], anonymousId: null };
57
+ }
58
+ },
59
+ write(items, anonymousId = null) {
60
+ try {
61
+ if (items.length === 0) session()?.removeItem(key);
62
+ else
63
+ session()?.setItem(
64
+ key,
65
+ JSON.stringify({ savedAt: now(), items, anonymousId }),
66
+ );
67
+ } catch {
68
+ /* quota or private browsing: the events are still held for this page */
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ /** 400 days, the longest a browser keeps a cookie (RFC 6265bis); refreshed on every page. */
75
+ export const DEVICE_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
76
+
77
+ export function writeCookie(name, value, maxAge, domain) {
78
+ if (typeof document === 'undefined') return false;
79
+ try {
80
+ const secure =
81
+ typeof location !== 'undefined' && location.protocol === 'https:'
82
+ ? '; Secure'
83
+ : '';
84
+ const scope = domain ? `; Domain=${domain}` : '';
85
+ // No Max-Age: a session cookie, gone when the browser closes.
86
+ const age = maxAge === null ? '' : `; Max-Age=${maxAge}`;
87
+ document.cookie =
88
+ `${name}=${encodeURIComponent(value)}; Path=/${age}; SameSite=Lax` +
89
+ scope +
90
+ secure;
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Hyper-Personalisation's own device cookie (3.6, a source set to `hp_cookie`): a first-party
99
+ * cookie the SDK mints and keeps, one per browser rather than per property. Under strict consent
100
+ * it is written only once analytics is granted; until then the id lives in memory, and in the held
101
+ * store beside the events that carry it. Every page under a grant writes it again, so it lasts 400
102
+ * days from the last visit rather than the first.
103
+ *
104
+ * A device id 3.5 minted into localStorage (`mintAnonymousId`) is adopted as the cookie's value,
105
+ * and its localStorage key removed, the first time the cookie is written.
106
+ *
107
+ * @param {object} options
108
+ * @param {string} options.name The cookie name, storageName('anonymousId').
109
+ * @param {string|null} [options.domain] As the consent cookie's (`consentCookieDomain`).
110
+ * @param {string} options.legacyKey 3.5's localStorage key for this property.
111
+ * @param {(name: string) => string|null} options.read Reads a cookie.
112
+ * @param {() => string} options.mint
113
+ * @param {() => boolean} options.mayStore Strict consent: analytics is granted.
114
+ * @param {string|null} [options.carried] An id carried in the held store from an earlier page.
115
+ */
116
+ export function createDeviceCookie({
117
+ name,
118
+ domain = null,
119
+ legacyKey,
120
+ read,
121
+ mint,
122
+ mayStore,
123
+ carried = null,
124
+ }) {
125
+ let legacy = null;
126
+ try {
127
+ legacy = local()?.getItem(legacyKey) || null;
128
+ } catch {
129
+ legacy = null;
130
+ }
131
+ let id = read(name) ?? legacy ?? carried;
132
+ let written = false;
133
+
134
+ const persist = () => {
135
+ if (written || id === null || !mayStore()) return;
136
+ written = writeCookie(name, id, DEVICE_COOKIE_MAX_AGE, domain);
137
+ if (!written || legacy === null) return;
138
+ try {
139
+ local()?.removeItem(legacyKey);
140
+ legacy = null;
141
+ } catch {
142
+ /* left for the next page to remove */
143
+ }
144
+ };
145
+
146
+ return {
147
+ get() {
148
+ id ??= mint();
149
+ persist();
150
+ return id;
151
+ },
152
+ /** The id so far, without minting one. */
153
+ peek: () => id,
154
+ /** Consent was granted: write what was minted before it. */
155
+ persist,
156
+ /**
157
+ * Consent was refused or withdrawn: the id must not outlive the permission it was kept under,
158
+ * so a later grant starts a new device, as `analytics.reset()` does.
159
+ */
160
+ forget() {
161
+ writeCookie(name, '', 0, domain);
162
+ id = null;
163
+ written = false;
164
+ try {
165
+ local()?.removeItem(legacyKey);
166
+ } catch {
167
+ /* nothing was stored */
168
+ }
169
+ legacy = null;
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017).
176
+ *
177
+ * site_cookie the cookie the settings name, or (no name) `getAnonymousId`, the integration's own.
178
+ * hp_cookie Hyper-Personalisation's cookie, minted and kept here. `getAnonymousId` is ignored.
179
+ * absent settings from before 3.6: `getAnonymousId`, as before.
180
+ *
181
+ * With `orOwn`, a visitor the site has no id for (the cookie missing, empty or a placeholder) gets
182
+ * Hyper-Personalisation's cookie rather than none, as analytics.js does: an event with no device id
183
+ * belongs to nobody (3.18).
184
+ *
185
+ * Replaces `cfg.getAnonymousId` to match, and returns the device cookie it may fall back to.
186
+ */
187
+ export function deviceIdFrom(cfg, { carried = null, mayStore, orOwn = false }) {
188
+ const setting = cfg.anonymousIdSource;
189
+ const own = () =>
190
+ createDeviceCookie({
191
+ name: storageName('anonymousId'),
192
+ domain: cfg.consentCookieDomain ?? null,
193
+ legacyKey: storageKey(storageName('anonymousId'), cfg.propertyId),
194
+ read: getCookie,
195
+ mint: uuid,
196
+ mayStore,
197
+ carried,
198
+ });
199
+ if (setting?.source === 'hp_cookie') {
200
+ const device = own();
201
+ cfg.getAnonymousId = () => device.get();
202
+ return device;
203
+ }
204
+ if (setting?.source === 'site_cookie' && setting.cookie) {
205
+ const name = setting.cookie;
206
+ cfg.getAnonymousId = () => getCookie(name);
207
+ }
208
+ if (!orOwn) return null;
209
+ const site = cfg.getAnonymousId;
210
+ const device = own();
211
+ cfg.getAnonymousId = () => realId(perRequest(site, null)) ?? device.get();
212
+ return device;
213
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Deterministic experiment assignment for recommendation rails, and the fingerprint that
3
+ * identifies the candidate set a rail was drawn from.
4
+ *
5
+ * The arm is decided in the browser before the rail is requested (a control must not call the
6
+ * recommender) and recorded on the impression. It is a hash of a stable unit rather than a stored
7
+ * coin flip, so a visitor never moves between arms when storage is cleared. The holdout is carved
8
+ * off the top of the bucket space before the variants, as DecisionSlot.holdout is. Which
9
+ * experiments exist and who is eligible is the host's business.
10
+ */
11
+
12
+ /** Bucket resolution. 10,000 makes a 0.01% share expressible, which is finer than any real split. */
13
+ const DEFAULT_BUCKETS = 10000;
14
+
15
+ /** The arm name recorded for held-out traffic when the caller does not choose one. */
16
+ const DEFAULT_HOLDOUT_NAME = 'holdout';
17
+
18
+ /**
19
+ * 32-bit hash of a string: FNV-1a, then MurmurHash3's finaliser (fmix32), which FNV-1a needs to
20
+ * avalanche on short, similar ids. Not cryptographic; synchronous because assignment happens
21
+ * before the first render, and crypto.subtle is async.
22
+ */
23
+ export function hash32(input) {
24
+ let h = 0x811c9dc5;
25
+ const s = String(input);
26
+ for (let i = 0; i < s.length; i += 1) {
27
+ h ^= s.charCodeAt(i);
28
+ // FNV prime, via shifts: `h * 16777619` overflows the double's integer range and loses bits.
29
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
30
+ }
31
+ h ^= h >>> 16;
32
+ h = Math.imul(h, 0x85ebca6b) >>> 0;
33
+ h ^= h >>> 13;
34
+ h = Math.imul(h, 0xc2b2ae35) >>> 0;
35
+ h ^= h >>> 16;
36
+ return h >>> 0;
37
+ }
38
+
39
+ /**
40
+ * Which bucket a unit falls in for a given salt. Salted per experiment so concurrent experiments
41
+ * are independent permutations of the population.
42
+ */
43
+ export function hashBucket(unit, salt, buckets = DEFAULT_BUCKETS) {
44
+ // A NUL separator prevents `ab|c` colliding with `a|bc`. Keep it an escape: a literal NUL byte
45
+ // makes git treat the file as binary.
46
+ return hash32(`${salt}\0${unit}`) % buckets;
47
+ }
48
+
49
+ /**
50
+ * Assign a visitor to an arm of a recommendation experiment.
51
+ *
52
+ * @param {object} options
53
+ * @param {string|null} options.unit The thing being bucketed — usually the anonymous id.
54
+ * @param {string} options.experiment Experiment name; also the default hash salt.
55
+ * @param {Array<{name: string, share: number}>} options.variants Arms and their shares of the
56
+ * NON-held-out traffic. Shares are normalised, so [1, 1] and [0.5, 0.5] mean the same thing.
57
+ * @param {number} [options.holdoutShare] Fraction of ALL traffic withheld from personalisation.
58
+ * @param {string} [options.holdoutName] Arm name recorded for held-out traffic.
59
+ * @param {string} [options.salt] Override the salt to keep an arm stable across a rename.
60
+ * @param {number} [options.buckets] Bucket resolution.
61
+ * @param {'anonymous_id'|'customer_id'|'session_id'|'other'} [options.unitKind] What `unit` is.
62
+ * @returns {{experiment: string, variant: string, holdout: boolean, assignment_bucket: number,
63
+ * assignment_unit_kind: string}|null} Shaped to spread straight onto the impression event.
64
+ * Null when there is no unit to bucket.
65
+ */
66
+ export function assignRecommendationVariant(options) {
67
+ const {
68
+ unit,
69
+ experiment,
70
+ variants,
71
+ holdoutShare = 0,
72
+ holdoutName = DEFAULT_HOLDOUT_NAME,
73
+ salt,
74
+ buckets = DEFAULT_BUCKETS,
75
+ unitKind = 'anonymous_id',
76
+ } = options ?? {};
77
+
78
+ if (typeof experiment !== 'string' || experiment === '') {
79
+ throw new Error('assignRecommendationVariant: experiment is required');
80
+ }
81
+ if (!Array.isArray(variants) || variants.length === 0) {
82
+ throw new Error(
83
+ 'assignRecommendationVariant: variants must be a non-empty array',
84
+ );
85
+ }
86
+
87
+ const total = variants.reduce((sum, v) => sum + (Number(v?.share) || 0), 0);
88
+ if (!(total > 0)) {
89
+ throw new Error(
90
+ 'assignRecommendationVariant: variant shares must sum to more than zero',
91
+ );
92
+ }
93
+
94
+ // No unit, no assignment, and specifically not the holdout, which would fill with every
95
+ // unidentifiable visitor. The caller serves the rail normally and records `variant: null`.
96
+ if (unit == null || unit === '') return null;
97
+
98
+ const bucket = hashBucket(unit, salt ?? experiment, buckets);
99
+
100
+ // Whole buckets, so a boundary cannot land either side of a bucket depending on float rounding.
101
+ const holdoutCut = Math.round(
102
+ Math.min(Math.max(holdoutShare, 0), 1) * buckets,
103
+ );
104
+ if (bucket < holdoutCut) {
105
+ return {
106
+ experiment,
107
+ variant: holdoutName,
108
+ holdout: true,
109
+ assignment_bucket: bucket,
110
+ assignment_unit_kind: unitKind,
111
+ };
112
+ }
113
+
114
+ const treated = buckets - holdoutCut;
115
+ let edge = holdoutCut;
116
+ for (let i = 0; i < variants.length; i += 1) {
117
+ edge += Math.round((treated * (Number(variants[i].share) || 0)) / total);
118
+ // The last arm takes whatever rounding left over, so no bucket is unassignable.
119
+ if (bucket < edge || i === variants.length - 1) {
120
+ return {
121
+ experiment,
122
+ variant: String(variants[i].name),
123
+ holdout: false,
124
+ assignment_bucket: bucket,
125
+ assignment_unit_kind: unitKind,
126
+ };
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * A property holdout's split as a serving response states it: the share held out and the share of
135
+ * ALL shoppers personalised (the ramp, capped by what the holdout leaves; everyone else without
136
+ * one). The public API's `personalisedShare` computes the same.
137
+ *
138
+ * @param {{share: number, ramp?: number|null}} holdout The property's holdout.
139
+ * @returns {{holdout_share: number, personalised_share: number}}
140
+ */
141
+ export function holdoutShares(holdout) {
142
+ const clamp = (share) => Math.min(Math.max(Number(share) || 0, 0), 1);
143
+ const share = clamp(holdout.share);
144
+ const rest = 1 - share;
145
+ return {
146
+ holdout_share: share,
147
+ personalised_share:
148
+ holdout.ramp == null ? rest : Math.min(clamp(holdout.ramp), rest),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * A shopper's group for a property's personalisation: the same answer the activation edge and the
154
+ * public API give, from the same hash and the same fixed ranges (spec/assignment/holdout.json holds
155
+ * the vectors). Below `share` the shopper is held out; below `share + ramp` personalised; above it
156
+ * excluded, seeing the default and counted in neither group. A null ramp personalises everyone
157
+ * outside the holdout. Ramping widens only the personalised range, so nobody ever moves into or out
158
+ * of the holdout. No unit is never held out; under a ramp it is excluded, since it could only ever
159
+ * count on the treated side.
160
+ *
161
+ * @param {{share: number, ramp?: number|null, salt: string}|null|undefined} holdout The property's holdout.
162
+ * @param {string|null|undefined} unit The anonymous id.
163
+ * @returns {{holdout: boolean, bucket: number|null, group: 'holdout'|'personalised'|'excluded'}}
164
+ */
165
+ export function holdoutAssignment(holdout, unit) {
166
+ if (!holdout || typeof holdout.salt !== 'string')
167
+ return { holdout: false, bucket: null, group: 'personalised' };
168
+ const ramped = holdout.ramp != null;
169
+ if (unit == null || unit === '')
170
+ return {
171
+ holdout: false,
172
+ bucket: null,
173
+ group: ramped ? 'excluded' : 'personalised',
174
+ };
175
+ const { holdout_share: share, personalised_share: personalised } =
176
+ holdoutShares(holdout);
177
+ const bucket = hashBucket(unit, holdout.salt);
178
+ const cut = (s) => Math.round(s * DEFAULT_BUCKETS);
179
+ const group =
180
+ share > 0 && bucket < cut(share)
181
+ ? 'holdout'
182
+ : !ramped || bucket < cut(share + personalised)
183
+ ? 'personalised'
184
+ : 'excluded';
185
+ return { holdout: group === 'holdout', bucket, group };
186
+ }
187
+
188
+ /**
189
+ * A visitor's side of a readout slot's bandit-vs-default 50/50, as the public API decides it
190
+ * (spec/assignment/bandit-split.json): the holdout's hash over the same anonymous id, salted with
191
+ * the experiment name `bandit-vs-default:<slot>`, so it is independent of the holdout and of every
192
+ * other slot. Buckets below 5000 are the slot's default; the rest get the bandit. Null without a
193
+ * slot or a unit: such a visitor is in no split.
194
+ *
195
+ * @param {string} slot The slot key.
196
+ * @param {string|null|undefined} unit The anonymous id.
197
+ * @returns {'bandit'|'default'|null}
198
+ */
199
+ export function banditSplit(slot, unit) {
200
+ if (typeof slot !== 'string' || slot === '' || unit == null || unit === '')
201
+ return null;
202
+ return hashBucket(unit, `bandit-vs-default:${slot}`) < DEFAULT_BUCKETS / 2
203
+ ? 'default'
204
+ : 'bandit';
205
+ }
206
+
207
+ /**
208
+ * Fingerprint an ordered candidate set: the candidates the serving response carried, not the
209
+ * tiles rendered. Order is part of the identity. Prefixed with the count (`24-3f9a1c07`) so a
210
+ * truncated set is visible without a lookup.
211
+ */
212
+ export function candidateSetFingerprint(productIds) {
213
+ const ids = (productIds ?? [])
214
+ .map((id) => String(id ?? ''))
215
+ .filter((id) => id !== '');
216
+ if (ids.length === 0) return null;
217
+ // Same separator, same reason: joining on a byte that cannot appear in an id keeps
218
+ // ['ab', 'c'] and ['a', 'bc'] distinct fingerprints.
219
+ return `${ids.length}-${hash32(ids.join('\0')).toString(16).padStart(8, '0')}`;
220
+ }
package/src/ga4.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * GA4 items -> spec products. Shared by the client and the dataLayer adapter.
3
+ */
4
+
5
+ /**
6
+ * Normalise a category path to the spec's '/'-joined form, so `A / B` and `A/B` are one category.
7
+ */
8
+ function categoryPath(value) {
9
+ if (value == null) return null;
10
+ const path = String(value)
11
+ .split('/')
12
+ .map((level) => level.trim())
13
+ .filter(Boolean)
14
+ .join('/');
15
+ return path === '' ? null : path;
16
+ }
17
+
18
+ /** GA4 splits a path over item_category..item_category5; some sites send it pre-joined in item_category. */
19
+ function ga4Category(item) {
20
+ const first = item.item_category;
21
+ if (first != null && String(first).includes('/')) return categoryPath(first);
22
+ const levels = [
23
+ first,
24
+ item.item_category2,
25
+ item.item_category3,
26
+ item.item_category4,
27
+ item.item_category5,
28
+ ]
29
+ .map((level) => (level == null ? '' : String(level).trim()))
30
+ .filter(Boolean);
31
+ return categoryPath(levels.join('/'));
32
+ }
33
+
34
+ const text = (v) => {
35
+ // An object is never a name or an id, however a tracker happens to fill the field.
36
+ if (v == null || typeof v === 'object') return null;
37
+ const trimmed = String(v).trim();
38
+ return trimmed === '' ? null : trimmed;
39
+ };
40
+
41
+ /**
42
+ * GA4's parent id is `item_group_id` (the Merchant Center attribute GA4 items accept); `item_id`
43
+ * is the purchasable variant. The spec's product_id is the parent, so they cross over.
44
+ */
45
+ /**
46
+ * The first usable id among candidates: a platform that sends `item_group_id: ''` has sent no
47
+ * parent, and letting the blank win would drop the product rather than fall back to the item.
48
+ */
49
+ export function firstId(...candidates) {
50
+ for (const value of candidates) {
51
+ if (typeof value === 'number' && Number.isFinite(value))
52
+ return String(value);
53
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export const defaultProductId = (item) =>
59
+ firstId(item.item_group_id, item.item_id);
60
+
61
+ /**
62
+ * Map a GA4 item onto a spec product.
63
+ *
64
+ * @param {object} item
65
+ * @param {(item: object) => unknown} [productId] Resolves the parent product id; a platform whose
66
+ * items name it differently passes its own, usually through a preset.
67
+ */
68
+ export function fromGa4Item(item, productId = defaultProductId) {
69
+ if (!item) return null;
70
+ const num = (v) =>
71
+ v == null || v === '' || Number.isNaN(Number(v)) ? null : Number(v);
72
+ // Six places clears float noise (32.99 - 16.49 = 16.500000000000004) without rounding any currency.
73
+ const money = (v) => {
74
+ const n = num(v);
75
+ return n == null ? null : Math.round(n * 1e6) / 1e6;
76
+ };
77
+ const nonNegative = (v) => {
78
+ const n = money(v);
79
+ return n != null && n >= 0 ? n : null;
80
+ };
81
+ const count = (v) => {
82
+ const n = num(v);
83
+ return Number.isInteger(n) && n >= 0 ? n : null;
84
+ };
85
+ // Trimmed: 'Acme' and 'Acme ' would be two brands that render identically.
86
+ const str = text;
87
+
88
+ return {
89
+ product_id: str(productId(item)),
90
+ sku: str(item.item_id),
91
+ name: str(item.item_name),
92
+ brand: str(item.item_brand),
93
+ variant: str(item.item_variant) ?? str(item.variant),
94
+ category: ga4Category(item),
95
+ price: money(item.price),
96
+ discount: nonNegative(item.discount),
97
+ // No per-line `currency`: a basket cannot mix currencies, so it is context.currency,
98
+ // set once per event rather than repeated on every line of every commerce event.
99
+ quantity: count(item.quantity),
100
+ // Line-level, and independent of any order-level code — GA4 says so explicitly, and only the
101
+ // line knows which line a promotion actually landed on.
102
+ coupon: str(item.coupon),
103
+ // Rank in a list, which only the caller knows. Never the offset in `products`: that would
104
+ // stamp position 1 on every single-product event and bias the position metric.
105
+ position: count(item.index) != null ? count(item.index) + 1 : null,
106
+ // `image_link` is the product feeds' (Google, Meta) name for `image_url`. Both are sent even
107
+ // though the catalogue has them: products absent from the feed have no other source.
108
+ url: str(item.url) ?? str(item.item_url) ?? null,
109
+ image_url: str(item.image_url) ?? str(item.image_link) ?? null,
110
+ // The variant a tile rendered when the shopper has chosen nothing, kept apart from `sku` so an
111
+ // impression is never mistaken for a selection.
112
+ displayed_sku: str(item.displayed_sku) ?? str(item.displayed_variant_sku),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Map GA4 items to spec products, dropping any that carry no identity: validation is per event,
118
+ * so one id-less row would quarantine the whole impression.
119
+ */
120
+ export function toProducts(items, productId = defaultProductId) {
121
+ return (items ?? [])
122
+ .map((item) => fromGa4Item(item, productId))
123
+ .filter((p) => p?.product_id);
124
+ }
package/src/hash.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SHA-256, synchronously, so a page can hash an address in the same call that reports it and never
3
+ * awaits the SDK. SubtleCrypto is asynchronous only, and absent outside a secure context.
4
+ */
5
+
6
+ const primes = [];
7
+ for (let n = 2; primes.length < 64; n++) {
8
+ if (primes.every((p) => n % p !== 0)) primes.push(n);
9
+ }
10
+ const fraction = (x) => ((x - Math.floor(x)) * 2 ** 32) >>> 0;
11
+ /** FIPS 180-4: the first 32 bits of the fractional parts of the primes' square and cube roots. */
12
+ const INITIAL = primes.slice(0, 8).map((p) => fraction(Math.sqrt(p)));
13
+ const ROUND = primes.map((p) => fraction(Math.cbrt(p)));
14
+
15
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
16
+
17
+ /** SHA-256 of a string's UTF-8 bytes, as lowercase hex. */
18
+ export function sha256Hex(text) {
19
+ const bytes = new TextEncoder().encode(String(text));
20
+ const words = new Uint32Array(((bytes.length + 8) >> 6) * 16 + 16);
21
+ bytes.forEach((byte, i) => {
22
+ words[i >> 2] |= byte << (24 - (i % 4) * 8);
23
+ });
24
+ words[bytes.length >> 2] |= 0x80 << (24 - (bytes.length % 4) * 8);
25
+ words[words.length - 2] = Math.floor(bytes.length / 2 ** 29);
26
+ words[words.length - 1] = bytes.length * 8;
27
+
28
+ const hash = Uint32Array.from(INITIAL);
29
+ const w = new Uint32Array(64);
30
+ for (let block = 0; block < words.length; block += 16) {
31
+ for (let t = 0; t < 64; t++) {
32
+ if (t < 16) {
33
+ w[t] = words[block + t];
34
+ continue;
35
+ }
36
+ const a = w[t - 15];
37
+ const b = w[t - 2];
38
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
39
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
40
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1;
41
+ }
42
+ let [a, b, c, d, e, f, g, h] = hash;
43
+ for (let t = 0; t < 64; t++) {
44
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
45
+ const t1 = (h + s1 + ((e & f) ^ (~e & g)) + ROUND[t] + w[t]) | 0;
46
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
47
+ const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) | 0;
48
+ [h, g, f, e, d, c, b, a] = [
49
+ g,
50
+ f,
51
+ e,
52
+ (d + t1) | 0,
53
+ c,
54
+ b,
55
+ a,
56
+ (t1 + t2) | 0,
57
+ ];
58
+ }
59
+ [a, b, c, d, e, f, g, h].forEach((value, i) => {
60
+ hash[i] += value;
61
+ });
62
+ }
63
+ return Array.from(hash, (x) => x.toString(16).padStart(8, '0')).join('');
64
+ }
65
+
66
+ /**
67
+ * `email_sha256` for an address, now: trimmed, lowercased, hashed. Null for anything that is not an
68
+ * email, so a blank form field never becomes the hash of the empty string.
69
+ */
70
+ export function emailSha256(email) {
71
+ if (typeof email !== 'string') return null;
72
+ const normalised = email.trim().toLowerCase();
73
+ return normalised.includes('@') ? sha256Hex(normalised) : null;
74
+ }
75
+
76
+ const rotl = (x, n) => (x << n) | (x >>> (32 - n));
77
+
78
+ /**
79
+ * SHA-1 of bytes, as 20 bytes, synchronously. Only for name-based UUIDs (RFC 9562 version 5), which
80
+ * are defined over SHA-1; never for anything that must resist a collision.
81
+ */
82
+ export function sha1(bytes) {
83
+ const words = new Uint32Array(((bytes.length + 8) >> 6) * 16 + 16);
84
+ bytes.forEach((byte, i) => {
85
+ words[i >> 2] |= byte << (24 - (i % 4) * 8);
86
+ });
87
+ words[bytes.length >> 2] |= 0x80 << (24 - (bytes.length % 4) * 8);
88
+ words[words.length - 2] = Math.floor(bytes.length / 2 ** 29);
89
+ words[words.length - 1] = bytes.length * 8;
90
+
91
+ const hash = Uint32Array.of(
92
+ 0x67452301,
93
+ 0xefcdab89,
94
+ 0x98badcfe,
95
+ 0x10325476,
96
+ 0xc3d2e1f0,
97
+ );
98
+ const w = new Uint32Array(80);
99
+ for (let block = 0; block < words.length; block += 16) {
100
+ for (let t = 0; t < 80; t++) {
101
+ w[t] =
102
+ t < 16
103
+ ? words[block + t]
104
+ : rotl(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1);
105
+ }
106
+ let [a, b, c, d, e] = hash;
107
+ for (let t = 0; t < 80; t++) {
108
+ let f;
109
+ let k;
110
+ if (t < 20) {
111
+ f = (b & c) | (~b & d);
112
+ k = 0x5a827999;
113
+ } else if (t < 40) {
114
+ f = b ^ c ^ d;
115
+ k = 0x6ed9eba1;
116
+ } else if (t < 60) {
117
+ f = (b & c) | (b & d) | (c & d);
118
+ k = 0x8f1bbcdc;
119
+ } else {
120
+ f = b ^ c ^ d;
121
+ k = 0xca62c1d6;
122
+ }
123
+ const next = (rotl(a, 5) + f + e + k + w[t]) | 0;
124
+ [e, d, c, b, a] = [d, c, rotl(b, 30), a, next];
125
+ }
126
+ [a, b, c, d, e].forEach((value, i) => {
127
+ hash[i] += value;
128
+ });
129
+ }
130
+ const out = new Uint8Array(20);
131
+ hash.forEach((word, i) => {
132
+ out[i * 4] = word >>> 24;
133
+ out[i * 4 + 1] = word >>> 16;
134
+ out[i * 4 + 2] = word >>> 8;
135
+ out[i * 4 + 3] = word;
136
+ });
137
+ return out;
138
+ }