@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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @file
3
+ * Axel core.
4
+ *
5
+ * Turns interactions with elements marked with 'data-axel-*' attributes into
6
+ * events and hands them to an adapter. README.md describes the attributes and
7
+ * the adapter contract.
8
+ */
9
+
10
+ import { collectParams } from './dom.js';
11
+ import { click } from './event-types/click.js';
12
+ import { fieldComplete } from './event-types/field-complete.js';
13
+ import { formStart } from './event-types/form-start.js';
14
+ import { formSubmit } from './event-types/form-submit.js';
15
+
16
+ export const TYPES = {
17
+ click,
18
+ form_start: formStart,
19
+ field_complete: fieldComplete,
20
+ form_submit: formSubmit,
21
+ };
22
+
23
+ export const DEFAULT_SETTINGS = {
24
+ // Event types to track, as keys of TYPES.
25
+ types: ['click'],
26
+ // An object with send(name, params, context) and an optional
27
+ // validate(name, params), or a function used as send().
28
+ adapter: null,
29
+ // Static parameters added to every event, overridden by all others.
30
+ properties: {},
31
+ // Elements whose clicks are tracked.
32
+ clickSelector: 'a[href], button, input[type="submit"], input[type="button"], input[type="reset"]',
33
+ // Headings that title a data-axel-group when it has no marker.
34
+ groupTitleSelector: 'h2, h3, h4',
35
+ // Functions named by data-axel-collector, as name => function.
36
+ collectors: {},
37
+ // Keep query strings and contact addresses in link_url and form_destination.
38
+ fullUrls: false,
39
+ debugHash: '#axel-debug',
40
+ debugStorageKey: 'axelDebug',
41
+ // Used by the standalone integration only.
42
+ autoInit: true,
43
+ };
44
+
45
+ export class Axel {
46
+ /**
47
+ * @param {object} settings - Overrides for DEFAULT_SETTINGS.
48
+ */
49
+ constructor(settings = {}) {
50
+ this.settings = { ...DEFAULT_SETTINGS, ...settings };
51
+
52
+ const { adapter } = this.settings;
53
+
54
+ this.adapter = typeof adapter === 'function' ? { send: adapter } : adapter;
55
+
56
+ if (typeof this.adapter?.send !== 'function') {
57
+ throw new Error('The analytics adapter must be a function or an object with a send() function.');
58
+ }
59
+
60
+ this.settings.types.forEach((type) => {
61
+ if (!Object.keys(TYPES).includes(type)) {
62
+ throw new Error(`Unknown analytics event type "${type}".`);
63
+ }
64
+ });
65
+
66
+ Object.entries(this.settings.collectors).forEach(([name, collector]) => {
67
+ if (typeof collector !== 'function') {
68
+ throw new Error(`The analytics collector "${name}" must be a function.`);
69
+ }
70
+ });
71
+
72
+ this.cleanups = null;
73
+ }
74
+
75
+ /**
76
+ * Start tracking the configured event types.
77
+ *
78
+ * Calling it again on an initialized instance does nothing.
79
+ *
80
+ * @return {Axel} The instance.
81
+ */
82
+ init() {
83
+ if (!this.cleanups) {
84
+ this.cleanups = this.settings.types.map((type) => TYPES[type](this));
85
+ }
86
+
87
+ return this;
88
+ }
89
+
90
+ /**
91
+ * Stop tracking. The instance can be initialized again afterwards.
92
+ */
93
+ destroy() {
94
+ this.cleanups?.forEach((cleanup) => cleanup());
95
+ this.cleanups = null;
96
+
97
+ if (shared === this) {
98
+ shared = null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Send an event with the parameters of the element and its ancestors.
104
+ *
105
+ * @param {string} name - The event name.
106
+ * @param {Element} element - The element the event is about.
107
+ * @param {object} derived - Parameters derived from the element, which
108
+ * 'data-axel-param-*' attributes override.
109
+ */
110
+ track(name, element, derived = {}) {
111
+ const params = { ...derived, ...collectParams(element) };
112
+
113
+ this.send(name, { ...params, ...this.collect(name, params, element) });
114
+ }
115
+
116
+ /**
117
+ * Run the collector named by the closest 'data-axel-collector'.
118
+ *
119
+ * A failing collector adds nothing, so the event is still sent.
120
+ *
121
+ * @param {string} name - The event name.
122
+ * @param {object} params - The event parameters, passed as a copy.
123
+ * @param {Element} element - The element the event is about.
124
+ * @return {object} The parameters the collector returns.
125
+ */
126
+ collect(name, params, element) {
127
+ const source = element.closest('[data-axel-collector]');
128
+
129
+ if (!source) {
130
+ return {};
131
+ }
132
+
133
+ const collectorName = source.getAttribute('data-axel-collector');
134
+
135
+ try {
136
+ // Only own keys, so a name such as 'toString' can't reach the prototype.
137
+ if (!Object.hasOwn(this.settings.collectors, collectorName)) {
138
+ throw new Error(`Unknown analytics collector "${collectorName}".`);
139
+ }
140
+
141
+ const collected = this.settings.collectors[collectorName](name, { ...params }, { element, source });
142
+
143
+ if (collected === undefined || collected === null) {
144
+ return {};
145
+ }
146
+
147
+ if (typeof collected !== 'object' || Array.isArray(collected)) {
148
+ throw new Error(`The analytics collector "${collectorName}" must return an object.`);
149
+ }
150
+
151
+ return collected;
152
+ } catch (error) {
153
+ console.error(`[axel] ${error.message}`);
154
+
155
+ return {};
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Send a 'form_submit' event for a submission the server has confirmed.
161
+ *
162
+ * @param {string} formName - The form name.
163
+ * @param {object} params - Additional parameters.
164
+ */
165
+ formSubmitted(formName, params = {}) {
166
+ this.send('form_submit', { form_name: formName, ...params });
167
+ }
168
+
169
+ /**
170
+ * Send an event to the adapter.
171
+ *
172
+ * @param {string} name - The event name.
173
+ * @param {object} params - The event parameters.
174
+ */
175
+ send(name, params) {
176
+ const event = { ...this.settings.properties, ...params };
177
+ const debug = this.isDebug();
178
+
179
+ if (debug) {
180
+ console.log('[axel]', name, event);
181
+ (this.adapter.validate?.(name, event) || []).forEach((warning) => console.warn(`[axel] ${name}: ${warning}`));
182
+ }
183
+
184
+ this.adapter.send(name, event, { debug, settings: this.settings });
185
+ }
186
+
187
+ /**
188
+ * Check whether events are logged for debugging.
189
+ *
190
+ * The debug hash turns logging on until the browser tab closes, so it holds
191
+ * on the pages that clicks open.
192
+ *
193
+ * @return {boolean} TRUE if debugging.
194
+ */
195
+ isDebug() {
196
+ const { debugHash, debugStorageKey } = this.settings;
197
+ const requested = window.location.hash === debugHash;
198
+
199
+ try {
200
+ if (requested) {
201
+ window.sessionStorage.setItem(debugStorageKey, '1');
202
+ }
203
+
204
+ return requested || window.sessionStorage.getItem(debugStorageKey) === '1';
205
+ } catch {
206
+ // A browser that blocks storage throws on access.
207
+ return requested;
208
+ }
209
+ }
210
+ }
211
+
212
+ let shared = null;
213
+
214
+ /**
215
+ * Initialize the shared instance.
216
+ *
217
+ * The first call creates the instance from the settings; later calls return it
218
+ * and ignore their settings.
219
+ *
220
+ * @param {object} settings - Overrides for DEFAULT_SETTINGS.
221
+ * @return {Axel} The shared instance.
222
+ */
223
+ export function init(settings = {}) {
224
+ shared = shared || new Axel(settings);
225
+
226
+ return shared.init();
227
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file
3
+ * DOM helpers shared by the event types.
4
+ */
5
+
6
+ const PARAM_PREFIX = 'data-axel-param-';
7
+
8
+ // The rest of a URL with these schemes is an address or a phone number.
9
+ const CONTACT_PROTOCOLS = ['mailto:', 'tel:', 'sms:'];
10
+
11
+ /**
12
+ * Add a capture-phase listener to the document.
13
+ *
14
+ * @param {string} type - The DOM event type.
15
+ * @param {Function} handler - The listener.
16
+ * @return {Function} A function that removes the listener.
17
+ */
18
+ export function listen(type, handler) {
19
+ document.addEventListener(type, handler, true);
20
+
21
+ return () => document.removeEventListener(type, handler, true);
22
+ }
23
+
24
+ /**
25
+ * Get the text of a node, without what assistive technology skips.
26
+ *
27
+ * Form controls contribute no text, so a field value never ends up in a label.
28
+ *
29
+ * @param {Node} node - The node.
30
+ * @return {string} The text of the node and its descendants.
31
+ */
32
+ export function getText(node) {
33
+ if (node.nodeType === Node.TEXT_NODE) {
34
+ return node.textContent;
35
+ }
36
+
37
+ if (node.nodeType !== Node.ELEMENT_NODE || node.getAttribute('aria-hidden') === 'true' || node.matches('select, textarea')) {
38
+ return '';
39
+ }
40
+
41
+ return [...node.childNodes].map(getText).join('');
42
+ }
43
+
44
+ /**
45
+ * Get the text that names an element in the reports.
46
+ *
47
+ * @param {Element} element - The element.
48
+ * @return {string} The text with whitespace collapsed, or the element's label
49
+ * when it has no text.
50
+ */
51
+ export function getLabel(element) {
52
+ // Only a button input's value is its label; any other input value is typed.
53
+ const isButton = element instanceof HTMLInputElement && ['submit', 'button', 'reset'].includes(element.type);
54
+ const value = isButton ? element.value : getText(element);
55
+
56
+ return value.replace(/\s+/g, ' ').trim() || element.getAttribute('aria-label') || element.getAttribute('title') || '';
57
+ }
58
+
59
+ /**
60
+ * Collect the 'data-axel-param-*' values of the element and its ancestors.
61
+ *
62
+ * @param {Element} element - The element to start from.
63
+ * @return {object} The parameters, the closest of each name winning.
64
+ */
65
+ export function collectParams(element) {
66
+ const params = {};
67
+
68
+ for (let node = element; node; node = node.parentElement) {
69
+ [...node.attributes]
70
+ .filter((attribute) => attribute.name.startsWith(PARAM_PREFIX))
71
+ .forEach((attribute) => {
72
+ const name = attribute.name.slice(PARAM_PREFIX.length).replace(/-/g, '_');
73
+
74
+ if (params[name] === undefined) {
75
+ params[name] = attribute.value;
76
+ }
77
+ });
78
+ }
79
+
80
+ return params;
81
+ }
82
+
83
+ /**
84
+ * Check if the element or an ancestor opts out of tracking.
85
+ *
86
+ * @param {Element} element - The element.
87
+ * @return {boolean} TRUE if the element is skipped.
88
+ */
89
+ export function isSkipped(element) {
90
+ return element.closest('[data-axel-skip]') !== null;
91
+ }
92
+
93
+ /**
94
+ * Resolve a URL attribute of an element against the page.
95
+ *
96
+ * The attribute is read directly because a form field named like a form
97
+ * property, such as 'action' or 'id', shadows that property on the form.
98
+ *
99
+ * Unless 'fullUrl' is set, the query string is dropped and a 'mailto:', 'tel:'
100
+ * or 'sms:' URL is reduced to its scheme, because both often hold personal
101
+ * information.
102
+ *
103
+ * @param {Element} element - The element.
104
+ * @param {string} attribute - The attribute name.
105
+ * @param {boolean} fullUrl - Whether to keep the URL as it is.
106
+ * @return {string|undefined} The absolute URL, or undefined when the value
107
+ * isn't a valid URL.
108
+ */
109
+ export function resolveUrl(element, attribute, fullUrl = false) {
110
+ let url;
111
+
112
+ try {
113
+ url = new URL(element.getAttribute(attribute), element.baseURI);
114
+ } catch {
115
+ return undefined;
116
+ }
117
+
118
+ if (fullUrl) {
119
+ return url.href;
120
+ }
121
+
122
+ if (CONTACT_PROTOCOLS.includes(url.protocol)) {
123
+ return url.protocol;
124
+ }
125
+
126
+ url.search = '';
127
+
128
+ return url.href;
129
+ }
130
+
131
+ /**
132
+ * Get the parameters that describe a marked form.
133
+ *
134
+ * @param {Element} form - The element with 'data-axel-form'.
135
+ * @param {boolean} fullUrl - Whether to keep the destination URL as it is.
136
+ * @return {object} The form name, and the form ID and destination when known.
137
+ */
138
+ export function formParams(form, fullUrl = false) {
139
+ const params = { form_name: form.getAttribute('data-axel-form') };
140
+ const id = form.getAttribute('id');
141
+
142
+ if (id) {
143
+ params.form_id = id;
144
+ }
145
+
146
+ const destination = form instanceof HTMLFormElement && form.hasAttribute('action') ? resolveUrl(form, 'action', fullUrl) : undefined;
147
+
148
+ if (destination !== undefined) {
149
+ params.form_destination = destination;
150
+ }
151
+
152
+ return params;
153
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @file
3
+ * Click events for interactive elements inside 'data-axel-event'.
4
+ */
5
+
6
+ import { getLabel, isSkipped, listen, resolveUrl } from '../dom.js';
7
+
8
+ /**
9
+ * Find the title closest before an element within a scope.
10
+ *
11
+ * A match counts when it precedes the element in document order and is not
12
+ * one of its ancestors, so the lookup covers the element's preceding siblings
13
+ * and its ancestors' preceding siblings up to the scope.
14
+ *
15
+ * @param {Element} element - The element.
16
+ * @param {Element} scope - The element that bounds the lookup.
17
+ * @param {string} selector - The CSS selector of the titles.
18
+ * @return {Element|undefined} The last matching title, if any.
19
+ */
20
+ function findTitle(element, scope, selector) {
21
+ return [...scope.querySelectorAll(selector)]
22
+ .filter((title) => title.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING && !title.contains(element) && !isSkipped(title))
23
+ .pop();
24
+ }
25
+
26
+ /**
27
+ * Get the title of the closest group or subgroup around an element.
28
+ *
29
+ * @param {Element} element - The element.
30
+ * @param {string} kind - 'group' or 'subgroup'.
31
+ * @param {string[]} selectors - The CSS selectors of the titles, in order of
32
+ * priority.
33
+ * @return {string|undefined} The title, an empty string when none matches, or
34
+ * undefined outside any group of the kind.
35
+ */
36
+ function getGroupTitle(element, kind, selectors) {
37
+ const group = element.closest(`[data-axel-${kind}]`);
38
+
39
+ if (!group) {
40
+ return undefined;
41
+ }
42
+
43
+ const scopeSelector = group.getAttribute(`data-axel-${kind}-scope`);
44
+ const scope = (scopeSelector && group.closest(scopeSelector)) || group;
45
+
46
+ for (const selector of selectors) {
47
+ const title = findTitle(element, scope, selector);
48
+
49
+ if (title) {
50
+ return getLabel(title);
51
+ }
52
+ }
53
+
54
+ return '';
55
+ }
56
+
57
+ /**
58
+ * Get the address a submit button sends its form to.
59
+ *
60
+ * @param {Element} element - The element.
61
+ * @param {boolean} fullUrl - Whether to keep the address as it is.
62
+ * @return {string|undefined} The absolute address, if the element is a submit
63
+ * button with a valid 'formaction' or a form with a valid 'action'.
64
+ */
65
+ function getSubmitDestination(element, fullUrl) {
66
+ if (element.type !== 'submit' || !element.form) {
67
+ return undefined;
68
+ }
69
+
70
+ if (element.hasAttribute('formaction')) {
71
+ return resolveUrl(element, 'formaction', fullUrl);
72
+ }
73
+
74
+ return element.form.hasAttribute('action') ? resolveUrl(element.form, 'action', fullUrl) : undefined;
75
+ }
76
+
77
+ /**
78
+ * Track clicks and middle clicks.
79
+ *
80
+ * @param {import('../core.js').Axel} axel - The instance.
81
+ * @return {Function} A function that stops tracking.
82
+ */
83
+ export function click(axel) {
84
+ const track = (event) => {
85
+ const element = event.target instanceof Element ? event.target.closest(axel.settings.clickSelector) : null;
86
+
87
+ if (!element || isSkipped(element)) {
88
+ return;
89
+ }
90
+
91
+ const enrolled = element.closest('[data-axel-event]');
92
+
93
+ if (!enrolled) {
94
+ return;
95
+ }
96
+
97
+ const isLink = element instanceof HTMLAnchorElement;
98
+ const params = { link_text: getLabel(element), click_type: isLink ? 'link' : 'button' };
99
+ const { fullUrls } = axel.settings;
100
+ const linkUrl = isLink ? resolveUrl(element, 'href', fullUrls) : getSubmitDestination(element, fullUrls);
101
+
102
+ if (linkUrl !== undefined) {
103
+ params.link_url = linkUrl;
104
+ }
105
+
106
+ const groupTitle = getGroupTitle(element, 'group', ['[data-axel-group-title]', axel.settings.groupTitleSelector]);
107
+
108
+ if (groupTitle !== undefined) {
109
+ params.group_title = groupTitle;
110
+ }
111
+
112
+ const subgroupTitle = getGroupTitle(element, 'subgroup', ['[data-axel-subgroup-title]']);
113
+
114
+ if (subgroupTitle !== undefined) {
115
+ params.subgroup_title = subgroupTitle;
116
+ }
117
+
118
+ axel.track(enrolled.getAttribute('data-axel-event'), element, params);
119
+ };
120
+
121
+ // Capture runs before any handler on the page can stop a click from
122
+ // reaching the document.
123
+ const removers = [
124
+ listen('click', track),
125
+ // A middle click opens a link in a new tab and fires only 'auxclick'.
126
+ listen('auxclick', (event) => event.button === 1 && track(event)),
127
+ ];
128
+
129
+ return () => removers.forEach((remove) => remove());
130
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @file
3
+ * The 'field_complete' event for a changed field inside a marked form.
4
+ */
5
+
6
+ import { formParams, getText, isSkipped, listen } from '../dom.js';
7
+
8
+ /**
9
+ * Get the label of a field without its value.
10
+ *
11
+ * An option's label is the answer, so radios and checkboxes in a fieldset are
12
+ * named by the fieldset legend, and a radio never by its own label.
13
+ *
14
+ * @param {Element} field - The field.
15
+ * @return {string} The label, or an empty string.
16
+ */
17
+ function getFieldLabel(field) {
18
+ const legend = ['checkbox', 'radio'].includes(field.type) ? field.closest('fieldset')?.querySelector(':scope > legend') : null;
19
+ const label = legend || (field.type === 'radio' ? null : field.labels?.[0]);
20
+
21
+ return (label ? getText(label).replace(/\s+/g, ' ').trim() : '') || field.getAttribute('aria-label') || '';
22
+ }
23
+
24
+ /**
25
+ * Track changes to fields inside marked forms.
26
+ *
27
+ * @param {import('../core.js').Axel} axel - The instance.
28
+ * @return {Function} A function that stops tracking.
29
+ */
30
+ export function fieldComplete(axel) {
31
+ return listen('change', (event) => {
32
+ const field = event.target;
33
+ const form = field instanceof Element ? field.closest('[data-axel-form]') : null;
34
+
35
+ if (!form || isSkipped(field)) {
36
+ return;
37
+ }
38
+
39
+ const params = formParams(form, axel.settings.fullUrls);
40
+
41
+ if (field.name) {
42
+ params.field_name = field.name;
43
+ }
44
+
45
+ params.field_label = getFieldLabel(field);
46
+
47
+ axel.track('field_complete', field, params);
48
+ });
49
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file
3
+ * The 'form_start' event for the first focus inside a marked form.
4
+ */
5
+
6
+ import { formParams, isSkipped, listen } from '../dom.js';
7
+
8
+ /**
9
+ * Track the first focus inside each marked form.
10
+ *
11
+ * @param {import('../core.js').Axel} axel - The instance.
12
+ * @return {Function} A function that stops tracking.
13
+ */
14
+ export function formStart(axel) {
15
+ const started = new WeakSet();
16
+
17
+ return listen('focusin', (event) => {
18
+ const form = event.target instanceof Element ? event.target.closest('[data-axel-form]') : null;
19
+
20
+ if (!form || started.has(form) || isSkipped(event.target)) {
21
+ return;
22
+ }
23
+
24
+ started.add(form);
25
+ axel.track('form_start', form, formParams(form, axel.settings.fullUrls));
26
+ });
27
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file
3
+ * The 'form_submit' event for server-rendered confirmation markers.
4
+ */
5
+
6
+ import { isSkipped } from '../dom.js';
7
+
8
+ const MARKER = '[data-axel-form-submitted]';
9
+
10
+ /**
11
+ * Track confirmation markers present at start and added later.
12
+ *
13
+ * @param {import('../core.js').Axel} axel - The instance.
14
+ * @return {Function} A function that stops tracking.
15
+ */
16
+ export function formSubmit(axel) {
17
+ const seen = new WeakSet();
18
+
19
+ const report = (marker) => {
20
+ if (seen.has(marker) || isSkipped(marker)) {
21
+ return;
22
+ }
23
+
24
+ seen.add(marker);
25
+ axel.track('form_submit', marker, { form_name: marker.getAttribute('data-axel-form-submitted') });
26
+ };
27
+
28
+ const scan = (root) => {
29
+ if (root.matches?.(MARKER)) {
30
+ report(root);
31
+ }
32
+
33
+ root.querySelectorAll(MARKER).forEach(report);
34
+ };
35
+
36
+ // AJAX forms render the confirmation after the page has loaded.
37
+ const observer = new MutationObserver((records) => {
38
+ records.forEach((record) => record.addedNodes.forEach((node) => node instanceof Element && scan(node)));
39
+ });
40
+
41
+ observer.observe(document, { childList: true, subtree: true });
42
+ scan(document);
43
+
44
+ return () => observer.disconnect();
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @file
3
+ * ES module entry point.
4
+ */
5
+
6
+ export { Axel, DEFAULT_SETTINGS, TYPES, init } from './core/core.js';
7
+ export { gtag } from './adapters/gtag.js';
8
+ export { gtm } from './adapters/gtm.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file
3
+ * Drupal integration: starts in a behavior from 'drupalSettings'.
4
+ */
5
+
6
+ import { Axel, init } from '../core/core.js';
7
+
8
+ /**
9
+ * Register the 'axel' behavior and expose the class on 'window'.
10
+ *
11
+ * The behavior initializes the shared instance from 'drupalSettings.axel' and
12
+ * exposes it as 'window.axel'. 'drupalSettings' can't hold functions, so the
13
+ * collectors come from 'window.axelCollectors'.
14
+ *
15
+ * @param {object} [adapter] - The adapter. Defaults to 'window.axelAdapter' as
16
+ * defined when the behavior attaches.
17
+ */
18
+ export function start(adapter) {
19
+ window.Axel = Axel;
20
+
21
+ window.Drupal.behaviors.axel = {
22
+ // Listeners are delegated to the document and init() is idempotent, so
23
+ // every attach after the first, including AJAX ones, is a no-op.
24
+ attach() {
25
+ const settings = window.drupalSettings.axel;
26
+ const collectors = { ...window.axelCollectors, ...settings?.collectors };
27
+
28
+ window.axel = init({ ...settings, collectors, adapter: adapter || window.axelAdapter });
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file
3
+ * Standalone integration: starts on load from page settings.
4
+ */
5
+
6
+ import { Axel, init } from '../core/core.js';
7
+
8
+ /**
9
+ * Initialize from 'window.axelSettings' unless 'autoInit' is false, and expose
10
+ * the class and the instance on 'window'.
11
+ *
12
+ * The collectors in 'window.axelCollectors' are added to the collectors in the
13
+ * settings.
14
+ *
15
+ * @param {object} [adapter] - The adapter. Defaults to 'window.axelAdapter'.
16
+ */
17
+ export function start(adapter) {
18
+ const pageSettings = window.axelSettings;
19
+ const collectors = { ...window.axelCollectors, ...pageSettings?.collectors };
20
+ const settings = { ...pageSettings, collectors, adapter: adapter || window.axelAdapter };
21
+
22
+ window.Axel = Axel;
23
+
24
+ // Listeners are delegated to the document, so the DOM does not need to be
25
+ // ready and clicks are tracked before other scripts load.
26
+ window.axel = settings.autoInit === false ? new Axel(settings) : init(settings);
27
+ }