@drevops/axel 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/axel.js ADDED
@@ -0,0 +1,376 @@
1
+ (() => {
2
+ // src/core/dom.js
3
+ var PARAM_PREFIX = "data-axel-param-";
4
+ var CONTACT_PROTOCOLS = ["mailto:", "tel:", "sms:"];
5
+ function listen(type, handler) {
6
+ document.addEventListener(type, handler, true);
7
+ return () => document.removeEventListener(type, handler, true);
8
+ }
9
+ function getText(node) {
10
+ if (node.nodeType === Node.TEXT_NODE) {
11
+ return node.textContent;
12
+ }
13
+ if (node.nodeType !== Node.ELEMENT_NODE || node.getAttribute("aria-hidden") === "true" || node.matches("select, textarea")) {
14
+ return "";
15
+ }
16
+ return [...node.childNodes].map(getText).join("");
17
+ }
18
+ function getLabel(element) {
19
+ const isButton = element instanceof HTMLInputElement && ["submit", "button", "reset"].includes(element.type);
20
+ const value = isButton ? element.value : getText(element);
21
+ return value.replace(/\s+/g, " ").trim() || element.getAttribute("aria-label") || element.getAttribute("title") || "";
22
+ }
23
+ function collectParams(element) {
24
+ const params = {};
25
+ for (let node = element; node; node = node.parentElement) {
26
+ [...node.attributes].filter((attribute) => attribute.name.startsWith(PARAM_PREFIX)).forEach((attribute) => {
27
+ const name = attribute.name.slice(PARAM_PREFIX.length).replace(/-/g, "_");
28
+ if (params[name] === void 0) {
29
+ params[name] = attribute.value;
30
+ }
31
+ });
32
+ }
33
+ return params;
34
+ }
35
+ function isSkipped(element) {
36
+ return element.closest("[data-axel-skip]") !== null;
37
+ }
38
+ function resolveUrl(element, attribute, fullUrl = false) {
39
+ let url;
40
+ try {
41
+ url = new URL(element.getAttribute(attribute), element.baseURI);
42
+ } catch (e) {
43
+ return void 0;
44
+ }
45
+ if (fullUrl) {
46
+ return url.href;
47
+ }
48
+ if (CONTACT_PROTOCOLS.includes(url.protocol)) {
49
+ return url.protocol;
50
+ }
51
+ url.search = "";
52
+ return url.href;
53
+ }
54
+ function formParams(form, fullUrl = false) {
55
+ const params = { form_name: form.getAttribute("data-axel-form") };
56
+ const id = form.getAttribute("id");
57
+ if (id) {
58
+ params.form_id = id;
59
+ }
60
+ const destination = form instanceof HTMLFormElement && form.hasAttribute("action") ? resolveUrl(form, "action", fullUrl) : void 0;
61
+ if (destination !== void 0) {
62
+ params.form_destination = destination;
63
+ }
64
+ return params;
65
+ }
66
+
67
+ // src/core/event-types/click.js
68
+ function findTitle(element, scope, selector) {
69
+ return [...scope.querySelectorAll(selector)].filter((title) => title.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING && !title.contains(element) && !isSkipped(title)).pop();
70
+ }
71
+ function getGroupTitle(element, kind, selectors) {
72
+ const group = element.closest(`[data-axel-${kind}]`);
73
+ if (!group) {
74
+ return void 0;
75
+ }
76
+ const scopeSelector = group.getAttribute(`data-axel-${kind}-scope`);
77
+ const scope = scopeSelector && group.closest(scopeSelector) || group;
78
+ for (const selector of selectors) {
79
+ const title = findTitle(element, scope, selector);
80
+ if (title) {
81
+ return getLabel(title);
82
+ }
83
+ }
84
+ return "";
85
+ }
86
+ function getSubmitDestination(element, fullUrl) {
87
+ if (element.type !== "submit" || !element.form) {
88
+ return void 0;
89
+ }
90
+ if (element.hasAttribute("formaction")) {
91
+ return resolveUrl(element, "formaction", fullUrl);
92
+ }
93
+ return element.form.hasAttribute("action") ? resolveUrl(element.form, "action", fullUrl) : void 0;
94
+ }
95
+ function click(axel) {
96
+ const track = (event) => {
97
+ const element = event.target instanceof Element ? event.target.closest(axel.settings.clickSelector) : null;
98
+ if (!element || isSkipped(element)) {
99
+ return;
100
+ }
101
+ const enrolled = element.closest("[data-axel-event]");
102
+ if (!enrolled) {
103
+ return;
104
+ }
105
+ const isLink = element instanceof HTMLAnchorElement;
106
+ const params = { link_text: getLabel(element), click_type: isLink ? "link" : "button" };
107
+ const { fullUrls } = axel.settings;
108
+ const linkUrl = isLink ? resolveUrl(element, "href", fullUrls) : getSubmitDestination(element, fullUrls);
109
+ if (linkUrl !== void 0) {
110
+ params.link_url = linkUrl;
111
+ }
112
+ const groupTitle = getGroupTitle(element, "group", ["[data-axel-group-title]", axel.settings.groupTitleSelector]);
113
+ if (groupTitle !== void 0) {
114
+ params.group_title = groupTitle;
115
+ }
116
+ const subgroupTitle = getGroupTitle(element, "subgroup", ["[data-axel-subgroup-title]"]);
117
+ if (subgroupTitle !== void 0) {
118
+ params.subgroup_title = subgroupTitle;
119
+ }
120
+ axel.track(enrolled.getAttribute("data-axel-event"), element, params);
121
+ };
122
+ const removers = [
123
+ listen("click", track),
124
+ // A middle click opens a link in a new tab and fires only 'auxclick'.
125
+ listen("auxclick", (event) => event.button === 1 && track(event))
126
+ ];
127
+ return () => removers.forEach((remove) => remove());
128
+ }
129
+
130
+ // src/core/event-types/field-complete.js
131
+ function getFieldLabel(field) {
132
+ var _a, _b;
133
+ const legend = ["checkbox", "radio"].includes(field.type) ? (_a = field.closest("fieldset")) == null ? void 0 : _a.querySelector(":scope > legend") : null;
134
+ const label = legend || (field.type === "radio" ? null : (_b = field.labels) == null ? void 0 : _b[0]);
135
+ return (label ? getText(label).replace(/\s+/g, " ").trim() : "") || field.getAttribute("aria-label") || "";
136
+ }
137
+ function fieldComplete(axel) {
138
+ return listen("change", (event) => {
139
+ const field = event.target;
140
+ const form = field instanceof Element ? field.closest("[data-axel-form]") : null;
141
+ if (!form || isSkipped(field)) {
142
+ return;
143
+ }
144
+ const params = formParams(form, axel.settings.fullUrls);
145
+ if (field.name) {
146
+ params.field_name = field.name;
147
+ }
148
+ params.field_label = getFieldLabel(field);
149
+ axel.track("field_complete", field, params);
150
+ });
151
+ }
152
+
153
+ // src/core/event-types/form-start.js
154
+ function formStart(axel) {
155
+ const started = /* @__PURE__ */ new WeakSet();
156
+ return listen("focusin", (event) => {
157
+ const form = event.target instanceof Element ? event.target.closest("[data-axel-form]") : null;
158
+ if (!form || started.has(form) || isSkipped(event.target)) {
159
+ return;
160
+ }
161
+ started.add(form);
162
+ axel.track("form_start", form, formParams(form, axel.settings.fullUrls));
163
+ });
164
+ }
165
+
166
+ // src/core/event-types/form-submit.js
167
+ var MARKER = "[data-axel-form-submitted]";
168
+ function formSubmit(axel) {
169
+ const seen = /* @__PURE__ */ new WeakSet();
170
+ const report = (marker) => {
171
+ if (seen.has(marker) || isSkipped(marker)) {
172
+ return;
173
+ }
174
+ seen.add(marker);
175
+ axel.track("form_submit", marker, { form_name: marker.getAttribute("data-axel-form-submitted") });
176
+ };
177
+ const scan = (root) => {
178
+ var _a;
179
+ if ((_a = root.matches) == null ? void 0 : _a.call(root, MARKER)) {
180
+ report(root);
181
+ }
182
+ root.querySelectorAll(MARKER).forEach(report);
183
+ };
184
+ const observer = new MutationObserver((records) => {
185
+ records.forEach((record) => record.addedNodes.forEach((node) => node instanceof Element && scan(node)));
186
+ });
187
+ observer.observe(document, { childList: true, subtree: true });
188
+ scan(document);
189
+ return () => observer.disconnect();
190
+ }
191
+
192
+ // src/core/core.js
193
+ var TYPES = {
194
+ click,
195
+ form_start: formStart,
196
+ field_complete: fieldComplete,
197
+ form_submit: formSubmit
198
+ };
199
+ var DEFAULT_SETTINGS = {
200
+ // Event types to track, as keys of TYPES.
201
+ types: ["click"],
202
+ // An object with send(name, params, context) and an optional
203
+ // validate(name, params), or a function used as send().
204
+ adapter: null,
205
+ // Static parameters added to every event, overridden by all others.
206
+ properties: {},
207
+ // Elements whose clicks are tracked.
208
+ clickSelector: 'a[href], button, input[type="submit"], input[type="button"], input[type="reset"]',
209
+ // Headings that title a data-axel-group when it has no marker.
210
+ groupTitleSelector: "h2, h3, h4",
211
+ // Functions named by data-axel-collector, as name => function.
212
+ collectors: {},
213
+ // Keep query strings and contact addresses in link_url and form_destination.
214
+ fullUrls: false,
215
+ debugHash: "#axel-debug",
216
+ debugStorageKey: "axelDebug",
217
+ // Used by the standalone integration only.
218
+ autoInit: true
219
+ };
220
+ var Axel = class {
221
+ /**
222
+ * @param {object} settings - Overrides for DEFAULT_SETTINGS.
223
+ */
224
+ constructor(settings = {}) {
225
+ var _a;
226
+ this.settings = { ...DEFAULT_SETTINGS, ...settings };
227
+ const { adapter } = this.settings;
228
+ this.adapter = typeof adapter === "function" ? { send: adapter } : adapter;
229
+ if (typeof ((_a = this.adapter) == null ? void 0 : _a.send) !== "function") {
230
+ throw new Error("The analytics adapter must be a function or an object with a send() function.");
231
+ }
232
+ this.settings.types.forEach((type) => {
233
+ if (!Object.keys(TYPES).includes(type)) {
234
+ throw new Error(`Unknown analytics event type "${type}".`);
235
+ }
236
+ });
237
+ Object.entries(this.settings.collectors).forEach(([name, collector]) => {
238
+ if (typeof collector !== "function") {
239
+ throw new Error(`The analytics collector "${name}" must be a function.`);
240
+ }
241
+ });
242
+ this.cleanups = null;
243
+ }
244
+ /**
245
+ * Start tracking the configured event types.
246
+ *
247
+ * Calling it again on an initialized instance does nothing.
248
+ *
249
+ * @return {Axel} The instance.
250
+ */
251
+ init() {
252
+ if (!this.cleanups) {
253
+ this.cleanups = this.settings.types.map((type) => TYPES[type](this));
254
+ }
255
+ return this;
256
+ }
257
+ /**
258
+ * Stop tracking. The instance can be initialized again afterwards.
259
+ */
260
+ destroy() {
261
+ var _a;
262
+ (_a = this.cleanups) == null ? void 0 : _a.forEach((cleanup) => cleanup());
263
+ this.cleanups = null;
264
+ if (shared === this) {
265
+ shared = null;
266
+ }
267
+ }
268
+ /**
269
+ * Send an event with the parameters of the element and its ancestors.
270
+ *
271
+ * @param {string} name - The event name.
272
+ * @param {Element} element - The element the event is about.
273
+ * @param {object} derived - Parameters derived from the element, which
274
+ * 'data-axel-param-*' attributes override.
275
+ */
276
+ track(name, element, derived = {}) {
277
+ const params = { ...derived, ...collectParams(element) };
278
+ this.send(name, { ...params, ...this.collect(name, params, element) });
279
+ }
280
+ /**
281
+ * Run the collector named by the closest 'data-axel-collector'.
282
+ *
283
+ * A failing collector adds nothing, so the event is still sent.
284
+ *
285
+ * @param {string} name - The event name.
286
+ * @param {object} params - The event parameters, passed as a copy.
287
+ * @param {Element} element - The element the event is about.
288
+ * @return {object} The parameters the collector returns.
289
+ */
290
+ collect(name, params, element) {
291
+ const source = element.closest("[data-axel-collector]");
292
+ if (!source) {
293
+ return {};
294
+ }
295
+ const collectorName = source.getAttribute("data-axel-collector");
296
+ try {
297
+ if (!Object.hasOwn(this.settings.collectors, collectorName)) {
298
+ throw new Error(`Unknown analytics collector "${collectorName}".`);
299
+ }
300
+ const collected = this.settings.collectors[collectorName](name, { ...params }, { element, source });
301
+ if (collected === void 0 || collected === null) {
302
+ return {};
303
+ }
304
+ if (typeof collected !== "object" || Array.isArray(collected)) {
305
+ throw new Error(`The analytics collector "${collectorName}" must return an object.`);
306
+ }
307
+ return collected;
308
+ } catch (error) {
309
+ console.error(`[axel] ${error.message}`);
310
+ return {};
311
+ }
312
+ }
313
+ /**
314
+ * Send a 'form_submit' event for a submission the server has confirmed.
315
+ *
316
+ * @param {string} formName - The form name.
317
+ * @param {object} params - Additional parameters.
318
+ */
319
+ formSubmitted(formName, params = {}) {
320
+ this.send("form_submit", { form_name: formName, ...params });
321
+ }
322
+ /**
323
+ * Send an event to the adapter.
324
+ *
325
+ * @param {string} name - The event name.
326
+ * @param {object} params - The event parameters.
327
+ */
328
+ send(name, params) {
329
+ var _a, _b;
330
+ const event = { ...this.settings.properties, ...params };
331
+ const debug = this.isDebug();
332
+ if (debug) {
333
+ console.log("[axel]", name, event);
334
+ (((_b = (_a = this.adapter).validate) == null ? void 0 : _b.call(_a, name, event)) || []).forEach((warning) => console.warn(`[axel] ${name}: ${warning}`));
335
+ }
336
+ this.adapter.send(name, event, { debug, settings: this.settings });
337
+ }
338
+ /**
339
+ * Check whether events are logged for debugging.
340
+ *
341
+ * The debug hash turns logging on until the browser tab closes, so it holds
342
+ * on the pages that clicks open.
343
+ *
344
+ * @return {boolean} TRUE if debugging.
345
+ */
346
+ isDebug() {
347
+ const { debugHash, debugStorageKey } = this.settings;
348
+ const requested = window.location.hash === debugHash;
349
+ try {
350
+ if (requested) {
351
+ window.sessionStorage.setItem(debugStorageKey, "1");
352
+ }
353
+ return requested || window.sessionStorage.getItem(debugStorageKey) === "1";
354
+ } catch (e) {
355
+ return requested;
356
+ }
357
+ }
358
+ };
359
+ var shared = null;
360
+ function init(settings = {}) {
361
+ shared = shared || new Axel(settings);
362
+ return shared.init();
363
+ }
364
+
365
+ // src/integrations/standalone.js
366
+ function start(adapter) {
367
+ const pageSettings = window.axelSettings;
368
+ const collectors = { ...window.axelCollectors, ...pageSettings == null ? void 0 : pageSettings.collectors };
369
+ const settings = { ...pageSettings, collectors, adapter: adapter || window.axelAdapter };
370
+ window.Axel = Axel;
371
+ window.axel = settings.autoInit === false ? new Axel(settings) : init(settings);
372
+ }
373
+
374
+ // axel.js
375
+ start();
376
+ })();
@@ -0,0 +1 @@
1
+ (()=>{var E="data-axel-param-",U=["mailto:","tel:","sms:"];function l(t,r){return document.addEventListener(t,r,!0),()=>document.removeEventListener(t,r,!0)}function p(t){return t.nodeType===Node.TEXT_NODE?t.textContent:t.nodeType!==Node.ELEMENT_NODE||t.getAttribute("aria-hidden")==="true"||t.matches("select, textarea")?"":[...t.childNodes].map(p).join("")}function g(t){return(t instanceof HTMLInputElement&&["submit","button","reset"].includes(t.type)?t.value:p(t)).replace(/\s+/g," ").trim()||t.getAttribute("aria-label")||t.getAttribute("title")||""}function y(t){let r={};for(let n=t;n;n=n.parentElement)[...n.attributes].filter(e=>e.name.startsWith(E)).forEach(e=>{let o=e.name.slice(E.length).replace(/-/g,"_");r[o]===void 0&&(r[o]=e.value)});return r}function a(t){return t.closest("[data-axel-skip]")!==null}function f(t,r,n=!1){let e;try{e=new URL(t.getAttribute(r),t.baseURI)}catch(o){return}return n?e.href:U.includes(e.protocol)?e.protocol:(e.search="",e.href)}function m(t,r=!1){let n={form_name:t.getAttribute("data-axel-form")},e=t.getAttribute("id");e&&(n.form_id=e);let o=t instanceof HTMLFormElement&&t.hasAttribute("action")?f(t,"action",r):void 0;return o!==void 0&&(n.form_destination=o),n}function I(t,r,n){return[...r.querySelectorAll(n)].filter(e=>e.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_FOLLOWING&&!e.contains(t)&&!a(e)).pop()}function S(t,r,n){let e=t.closest(`[data-axel-${r}]`);if(!e)return;let o=e.getAttribute(`data-axel-${r}-scope`),s=o&&e.closest(o)||e;for(let i of n){let c=I(t,s,i);if(c)return g(c)}return""}function P(t,r){if(!(t.type!=="submit"||!t.form))return t.hasAttribute("formaction")?f(t,"formaction",r):t.form.hasAttribute("action")?f(t.form,"action",r):void 0}function T(t){let r=e=>{let o=e.target instanceof Element?e.target.closest(t.settings.clickSelector):null;if(!o||a(o))return;let s=o.closest("[data-axel-event]");if(!s)return;let i=o instanceof HTMLAnchorElement,c={link_text:g(o),click_type:i?"link":"button"},{fullUrls:h}=t.settings,b=i?f(o,"href",h):P(o,h);b!==void 0&&(c.link_url=b);let x=S(o,"group",["[data-axel-group-title]",t.settings.groupTitleSelector]);x!==void 0&&(c.group_title=x);let w=S(o,"subgroup",["[data-axel-subgroup-title]"]);w!==void 0&&(c.subgroup_title=w),t.track(s.getAttribute("data-axel-event"),o,c)},n=[l("click",r),l("auxclick",e=>e.button===1&&r(e))];return()=>n.forEach(e=>e())}function D(t){var e,o;let n=(["checkbox","radio"].includes(t.type)?(e=t.closest("fieldset"))==null?void 0:e.querySelector(":scope > legend"):null)||(t.type==="radio"?null:(o=t.labels)==null?void 0:o[0]);return(n?p(n).replace(/\s+/g," ").trim():"")||t.getAttribute("aria-label")||""}function _(t){return l("change",r=>{let n=r.target,e=n instanceof Element?n.closest("[data-axel-form]"):null;if(!e||a(n))return;let o=m(e,t.settings.fullUrls);n.name&&(o.field_name=n.name),o.field_label=D(n),t.track("field_complete",n,o)})}function k(t){let r=new WeakSet;return l("focusin",n=>{let e=n.target instanceof Element?n.target.closest("[data-axel-form]"):null;!e||r.has(e)||a(n.target)||(r.add(e),t.track("form_start",e,m(e,t.settings.fullUrls)))})}var A="[data-axel-form-submitted]";function L(t){let r=new WeakSet,n=s=>{r.has(s)||a(s)||(r.add(s),t.track("form_submit",s,{form_name:s.getAttribute("data-axel-form-submitted")}))},e=s=>{var i;(i=s.matches)!=null&&i.call(s,A)&&n(s),s.querySelectorAll(A).forEach(n)},o=new MutationObserver(s=>{s.forEach(i=>i.addedNodes.forEach(c=>c instanceof Element&&e(c)))});return o.observe(document,{childList:!0,subtree:!0}),e(document),()=>o.disconnect()}var O={click:T,form_start:k,field_complete:_,form_submit:L},$={types:["click"],adapter:null,properties:{},clickSelector:'a[href], button, input[type="submit"], input[type="button"], input[type="reset"]',groupTitleSelector:"h2, h3, h4",collectors:{},fullUrls:!1,debugHash:"#axel-debug",debugStorageKey:"axelDebug",autoInit:!0},u=class{constructor(r={}){var e;this.settings={...$,...r};let{adapter:n}=this.settings;if(this.adapter=typeof n=="function"?{send:n}:n,typeof((e=this.adapter)==null?void 0:e.send)!="function")throw new Error("The analytics adapter must be a function or an object with a send() function.");this.settings.types.forEach(o=>{if(!Object.keys(O).includes(o))throw new Error(`Unknown analytics event type "${o}".`)}),Object.entries(this.settings.collectors).forEach(([o,s])=>{if(typeof s!="function")throw new Error(`The analytics collector "${o}" must be a function.`)}),this.cleanups=null}init(){return this.cleanups||(this.cleanups=this.settings.types.map(r=>O[r](this))),this}destroy(){var r;(r=this.cleanups)==null||r.forEach(n=>n()),this.cleanups=null,d===this&&(d=null)}track(r,n,e={}){let o={...e,...y(n)};this.send(r,{...o,...this.collect(r,o,n)})}collect(r,n,e){let o=e.closest("[data-axel-collector]");if(!o)return{};let s=o.getAttribute("data-axel-collector");try{if(!Object.hasOwn(this.settings.collectors,s))throw new Error(`Unknown analytics collector "${s}".`);let i=this.settings.collectors[s](r,{...n},{element:e,source:o});if(i==null)return{};if(typeof i!="object"||Array.isArray(i))throw new Error(`The analytics collector "${s}" must return an object.`);return i}catch(i){return console.error(`[axel] ${i.message}`),{}}}formSubmitted(r,n={}){this.send("form_submit",{form_name:r,...n})}send(r,n){var s,i;let e={...this.settings.properties,...n},o=this.isDebug();o&&(console.log("[axel]",r,e),(((i=(s=this.adapter).validate)==null?void 0:i.call(s,r,e))||[]).forEach(c=>console.warn(`[axel] ${r}: ${c}`))),this.adapter.send(r,e,{debug:o,settings:this.settings})}isDebug(){let{debugHash:r,debugStorageKey:n}=this.settings,e=window.location.hash===r;try{return e&&window.sessionStorage.setItem(n,"1"),e||window.sessionStorage.getItem(n)==="1"}catch(o){return e}}},d=null;function v(t={}){return d=d||new u(t),d.init()}function N(t){let r=window.axelSettings,n={...window.axelCollectors,...r==null?void 0:r.collectors},e={...r,collectors:n,adapter:t||window.axelAdapter};window.Axel=u,window.axel=e.autoInit===!1?new u(e):v(e)}N();})();
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
- "name": "@drevops/axel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@drevops/axel",
3
+ "version": "0.1.1",
4
+ "description": "Turns clicks and form interactions on elements marked with data-axel-* attributes into analytics events.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Alex Skrypnyk",
8
+ "email": "alex@drevops.com",
9
+ "url": "https://drevops.com"
10
+ },
11
+ "contributors": [],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/drevops/axel.git"
15
+ },
16
+ "homepage": "https://github.com/drevops/axel#readme",
17
+ "bugs": "https://github.com/drevops/axel/issues",
18
+ "type": "module",
19
+ "main": "build/axel.esm.js",
20
+ "module": "build/axel.esm.js",
21
+ "exports": {
22
+ ".": "./build/axel.esm.js",
23
+ "./build/*": "./build/*"
24
+ },
25
+ "files": [
26
+ "build",
27
+ "src"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "directories": {
33
+ "test": "tests"
34
+ },
35
+ "scripts": {
36
+ "build": "node scripts/build.js",
37
+ "lint": "eslint . && prettier --check \"src/**/*.js\" \"tests/**/*.js\" \"scripts/**/*.js\" \"*.js\"",
38
+ "lint-fix": "eslint --fix . && prettier --write \"src/**/*.js\" \"tests/**/*.js\" \"scripts/**/*.js\" \"*.js\"",
39
+ "pretest": "npm run build",
40
+ "test": "vitest run",
41
+ "pretest-coverage": "npm run build",
42
+ "test-coverage": "vitest run --coverage",
43
+ "pretest-functional": "npm run build",
44
+ "test-functional": "playwright test",
45
+ "reset": "rm -Rf node_modules build .logs"
46
+ },
47
+ "engines": {
48
+ "npm": ">=10",
49
+ "node": ">=22.12.0"
50
+ },
51
+ "devDependencies": {
52
+ "@eslint/js": "^10.0.1",
53
+ "@playwright/test": "^1.64.1",
54
+ "@vitest/coverage-v8": "^5.0.5",
55
+ "esbuild": "^0.28.2",
56
+ "eslint": "^10.13.0",
57
+ "eslint-config-prettier": "^10.1.8",
58
+ "fast-check": "^4.10.2",
59
+ "globals": "^17.13.0",
60
+ "jsdom": "^29.1.1",
61
+ "prettier": "^3.9.10",
62
+ "vitest": "^5.0.5"
63
+ },
64
+ "prettier": {
65
+ "singleQuote": true,
66
+ "printWidth": 160
67
+ }
68
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @file
3
+ * Google Analytics 4 rules shared by the Google adapters.
4
+ */
5
+
6
+ export const LIMITS = {
7
+ eventName: 40,
8
+ params: 25,
9
+ paramValue: 100,
10
+ };
11
+
12
+ /**
13
+ * Flag the parameters for GA4 DebugView in debug mode.
14
+ *
15
+ * @param {object} params - The event parameters.
16
+ * @param {boolean} debug - Whether debug mode is on.
17
+ * @return {object} The parameters, with 'debug_mode' in debug mode.
18
+ */
19
+ export function withDebugMode(params, debug) {
20
+ return debug ? { ...params, debug_mode: true } : params;
21
+ }
22
+
23
+ /**
24
+ * Check an event against the GA4 collection limits.
25
+ *
26
+ * @param {string} name - The event name.
27
+ * @param {object} params - The event parameters.
28
+ * @return {string[]} A warning per exceeded limit.
29
+ */
30
+ export function validate(name, params) {
31
+ const warnings = [];
32
+
33
+ if (name.length > LIMITS.eventName) {
34
+ warnings.push(`event name is longer than ${LIMITS.eventName} characters.`);
35
+ }
36
+
37
+ if (Object.keys(params).length > LIMITS.params) {
38
+ warnings.push(`more than ${LIMITS.params} parameters.`);
39
+ }
40
+
41
+ Object.entries(params)
42
+ .filter(([, value]) => String(value).length > LIMITS.paramValue)
43
+ .forEach(([key]) => warnings.push(`"${key}" is longer than ${LIMITS.paramValue} characters.`));
44
+
45
+ return warnings;
46
+ }
47
+
48
+ /**
49
+ * Get the data layer array, creating it when missing.
50
+ *
51
+ * @param {object} settings - The instance settings.
52
+ * @return {Array} The data layer.
53
+ */
54
+ export function getDataLayer(settings) {
55
+ const name = settings.dataLayerName || 'dataLayer';
56
+
57
+ window[name] = window[name] || [];
58
+
59
+ return window[name];
60
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file
3
+ * Adapter that sends events through the Google tag.
4
+ */
5
+
6
+ import { getDataLayer, validate, withDebugMode } from './ga4.js';
7
+
8
+ export const gtag = {
9
+ send(name, params, { debug, settings }) {
10
+ const event = withDebugMode(params, debug);
11
+
12
+ if (typeof window.gtag === 'function') {
13
+ window.gtag('event', name, event);
14
+
15
+ return;
16
+ }
17
+
18
+ const dataLayer = getDataLayer(settings);
19
+
20
+ // The Google tag reads only arguments objects from the data layer, so the
21
+ // event is queued the way its own gtag() function does.
22
+ (function queue() {
23
+ dataLayer.push(arguments);
24
+ })('event', name, event);
25
+ },
26
+ validate,
27
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file
3
+ * Adapter that pushes events to the data layer for Google Tag Manager.
4
+ */
5
+
6
+ import { getDataLayer, validate, withDebugMode } from './ga4.js';
7
+
8
+ export const gtm = {
9
+ send(name, params, { debug, settings }) {
10
+ getDataLayer(settings).push({ ...withDebugMode(params, debug), event: name });
11
+ },
12
+ validate,
13
+ };