Files
Tom Moor 7a4b545e7f chore: vendor autotrack library (#12070)
The autotrack npm package is no longer maintained. Vendor the three
plugins we use (eventTracker, outboundLinkTracker, urlChangeTracker)
and their dom-utils dependencies into a single local JS file.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 07:42:21 -04:00

708 lines
18 KiB
JavaScript

/**
* Vendored from https://github.com/googleanalytics/autotrack (v2.4.1)
* and its dependency https://github.com/philipwalton/dom-utils (v0.9.0)
*
* Copyright 2016 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0
*
* Only the three plugins used by Outline are included:
* - eventTracker
* - outboundLinkTracker
* - urlChangeTracker
*
* The original libraries are no longer maintained.
*/
/* eslint-disable */
// ---------------------------------------------------------------------------
// dom-utils: matches
// ---------------------------------------------------------------------------
const nativeMatches =
Element.prototype.matches || Element.prototype.webkitMatchesSelector;
function matchesSelector(element, selector) {
if (typeof selector != "string") {
return false;
}
if (nativeMatches) {
return nativeMatches.call(element, selector);
}
const nodes = element.parentNode.querySelectorAll(selector);
for (let i = 0, node; (node = nodes[i]); i++) {
if (node == element) {
return true;
}
}
return false;
}
function matches(element, test) {
if (element && element.nodeType == 1 && test) {
if (typeof test == "string" || test.nodeType == 1) {
return element == test || matchesSelector(element, test);
} else if ("length" in test) {
for (let i = 0, item; (item = test[i]); i++) {
if (element == item || matchesSelector(element, item)) {
return true;
}
}
}
}
return false;
}
// ---------------------------------------------------------------------------
// dom-utils: parents
// ---------------------------------------------------------------------------
function parents(element) {
const list = [];
while (element && element.parentNode && element.parentNode.nodeType == 1) {
element = element.parentNode;
list.push(element);
}
return list;
}
// ---------------------------------------------------------------------------
// dom-utils: closest
// ---------------------------------------------------------------------------
function closest(element, selector, shouldCheckSelf = false) {
if (!(element && element.nodeType == 1 && selector)) {
return;
}
const parentElements = (shouldCheckSelf ? [element] : []).concat(
parents(element)
);
for (let i = 0, parent; (parent = parentElements[i]); i++) {
if (matches(parent, selector)) {
return parent;
}
}
}
// ---------------------------------------------------------------------------
// dom-utils: delegate
// ---------------------------------------------------------------------------
function delegate(ancestor, eventType, selector, callback, opts = {}) {
const listener = function (event) {
let delegateTarget;
if (opts.composed && typeof event.composedPath == "function") {
const composedPath = event.composedPath();
for (let i = 0, node; (node = composedPath[i]); i++) {
if (node.nodeType == 1 && matches(node, selector)) {
delegateTarget = node;
}
}
} else {
delegateTarget = closest(event.target, selector, true);
}
if (delegateTarget) {
callback.call(delegateTarget, event, delegateTarget);
}
};
ancestor.addEventListener(eventType, listener, opts.useCapture);
return {
destroy() {
ancestor.removeEventListener(eventType, listener, opts.useCapture);
},
};
}
// ---------------------------------------------------------------------------
// dom-utils: parseUrl
// ---------------------------------------------------------------------------
const HTTP_PORT = "80";
const HTTPS_PORT = "443";
const DEFAULT_PORT = RegExp(":(" + HTTP_PORT + "|" + HTTPS_PORT + ")$");
const anchor = document.createElement("a");
const urlCache = {};
function parseUrl(url) {
url = !url || url == "." ? location.href : url;
if (urlCache[url]) {
return urlCache[url];
}
anchor.href = url;
if (url.charAt(0) == "." || url.charAt(0) == "/") {
return parseUrl(anchor.href);
}
let port =
anchor.port == HTTP_PORT || anchor.port == HTTPS_PORT ? "" : anchor.port;
port = port == "0" ? "" : port;
const host = anchor.host.replace(DEFAULT_PORT, "");
const origin = anchor.origin ? anchor.origin : anchor.protocol + "//" + host;
const pathname =
anchor.pathname.charAt(0) == "/" ? anchor.pathname : "/" + anchor.pathname;
return (urlCache[url] = {
hash: anchor.hash,
host,
hostname: anchor.hostname,
href: anchor.href,
origin,
pathname,
port,
protocol: anchor.protocol,
search: anchor.search,
});
}
// ---------------------------------------------------------------------------
// dom-utils: getAttributes
// ---------------------------------------------------------------------------
function getAttributes(element) {
const attrs = {};
if (!(element && element.nodeType == 1)) {
return attrs;
}
const map = element.attributes;
if (map.length === 0) {
return {};
}
for (let i = 0, attr; (attr = map[i]); i++) {
attrs[attr.name] = attr.value;
}
return attrs;
}
// ---------------------------------------------------------------------------
// autotrack: utilities
// ---------------------------------------------------------------------------
function camelCase(str) {
return str.replace(/[-_]+(\w?)/g, function (_match, p1) {
return p1.toUpperCase();
});
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function createFieldsObj(
defaultFields,
userFields,
tracker,
hitFilter,
target,
event
) {
if (typeof hitFilter == "function") {
const originalBuildHitTask = tracker.get("buildHitTask");
return {
buildHitTask: (model) => {
model.set(defaultFields, null, true);
model.set(userFields, null, true);
hitFilter(model, target, event);
originalBuildHitTask(model);
},
};
} else {
return Object.assign({}, defaultFields, userFields);
}
}
function getAttributeFields(element, prefix) {
const attributes = getAttributes(element);
const attributeFields = {};
Object.keys(attributes).forEach(function (attribute) {
if (attribute.indexOf(prefix) === 0 && attribute != prefix + "on") {
let value = attributes[attribute];
if (value == "true") {
value = true;
}
if (value == "false") {
value = false;
}
const field = camelCase(attribute.slice(prefix.length));
attributeFields[field] = value;
}
});
return attributeFields;
}
function withTimeout(callback, wait = 2000) {
let called = false;
const fn = function () {
if (!called) {
called = true;
callback();
}
};
setTimeout(fn, wait);
return fn;
}
// ---------------------------------------------------------------------------
// autotrack: method-chain
// ---------------------------------------------------------------------------
const methodChainInstances = [];
function getOrCreateMethodChain(context, methodName) {
let chain = methodChainInstances.filter(
(h) => h.context == context && h.methodName == methodName
)[0];
if (!chain) {
chain = createMethodChain(context, methodName);
methodChainInstances.push(chain);
}
return chain;
}
function createMethodChain(context, methodName) {
const isTask = /Task$/.test(methodName);
const originalMethodReference = isTask
? context.get(methodName)
: context[methodName];
const instance = {
context,
methodName,
isTask,
originalMethodReference,
methodChain: [],
boundMethodChain: [],
wrappedMethod(...args) {
const lastBoundMethod =
instance.boundMethodChain[instance.boundMethodChain.length - 1];
return lastBoundMethod(...args);
},
add(overrideMethod) {
this.methodChain.push(overrideMethod);
this.rebindMethodChain();
},
remove(overrideMethod) {
const index = this.methodChain.indexOf(overrideMethod);
if (index > -1) {
this.methodChain.splice(index, 1);
if (this.methodChain.length > 0) {
this.rebindMethodChain();
} else {
this.destroy();
}
}
},
rebindMethodChain() {
this.boundMethodChain = [];
for (let method, i = 0; (method = this.methodChain[i]); i++) {
const previousMethod =
this.boundMethodChain[i - 1] ||
this.originalMethodReference.bind(this.context);
this.boundMethodChain.push(method(previousMethod));
}
},
destroy() {
const index = methodChainInstances.indexOf(this);
if (index > -1) {
methodChainInstances.splice(index, 1);
if (this.isTask) {
this.context.set(this.methodName, this.originalMethodReference);
} else {
this.context[this.methodName] = this.originalMethodReference;
}
}
},
};
if (isTask) {
context.set(methodName, instance.wrappedMethod);
} else {
context[methodName] = instance.wrappedMethod;
}
return instance;
}
const MethodChain = {
add(context, methodName, methodOverride) {
getOrCreateMethodChain(context, methodName).add(methodOverride);
},
remove(context, methodName, methodOverride) {
getOrCreateMethodChain(context, methodName).remove(methodOverride);
},
};
// ---------------------------------------------------------------------------
// autotrack: usage tracking
// ---------------------------------------------------------------------------
const VERSION = "2.4.1";
const DEV_ID = "i5iSjo";
const VERSION_PARAM = "_av";
const USAGE_PARAM = "_au";
const pluginIds = {
EVENT_TRACKER: 2,
OUTBOUND_LINK_TRACKER: 6,
URL_CHANGE_TRACKER: 9,
};
const PLUGIN_COUNT = 10;
function convertHexToBin(hex) {
return parseInt(hex || "0", 16).toString(2);
}
function convertBinToHex(bin) {
return parseInt(bin || "0", 2).toString(16);
}
function padZeros(str, len) {
while (str.length < len) {
str = "0" + str;
}
return str;
}
function flipBitOn(str, index) {
return str.substr(0, index) + 1 + str.substr(index + 1);
}
function trackPlugin(tracker, pluginIndex) {
const usageHex = tracker.get("&" + USAGE_PARAM);
let usageBin = padZeros(convertHexToBin(usageHex), PLUGIN_COUNT);
usageBin = flipBitOn(usageBin, PLUGIN_COUNT - pluginIndex);
tracker.set("&" + USAGE_PARAM, convertBinToHex(usageBin));
}
function trackVersion(tracker) {
tracker.set("&" + VERSION_PARAM, VERSION);
}
function trackUsage(tracker, plugin) {
trackVersion(tracker);
trackPlugin(tracker, plugin);
}
// ---------------------------------------------------------------------------
// autotrack: provide (plugin registration)
// ---------------------------------------------------------------------------
function provide(pluginName, pluginConstructor) {
const gaAlias = window.GoogleAnalyticsObject || "ga";
window[gaAlias] =
window[gaAlias] ||
function (...args) {
(window[gaAlias].q = window[gaAlias].q || []).push(args);
};
window.gaDevIds = window.gaDevIds || [];
if (window.gaDevIds.indexOf(DEV_ID) < 0) {
window.gaDevIds.push(DEV_ID);
}
window[gaAlias]("provide", pluginName, pluginConstructor);
window.gaplugins = window.gaplugins || {};
window.gaplugins[capitalize(pluginName)] = pluginConstructor;
}
// ---------------------------------------------------------------------------
// Plugin: eventTracker
// ---------------------------------------------------------------------------
class EventTracker {
constructor(tracker, opts) {
trackUsage(tracker, pluginIds.EVENT_TRACKER);
if (!window.addEventListener) {
return;
}
const defaultOpts = {
events: ["click"],
fieldsObj: {},
attributePrefix: "ga-",
};
this.opts = Object.assign(defaultOpts, opts);
this.tracker = tracker;
this.handleEvents = this.handleEvents.bind(this);
const selector = "[" + this.opts.attributePrefix + "on]";
this.delegates = {};
this.opts.events.forEach((event) => {
this.delegates[event] = delegate(
document,
event,
selector,
this.handleEvents,
{ composed: true, useCapture: true }
);
});
}
handleEvents(event, element) {
const prefix = this.opts.attributePrefix;
const events = element.getAttribute(prefix + "on").split(/\s*,\s*/);
if (events.indexOf(event.type) < 0) {
return;
}
const defaultFields = { transport: "beacon" };
const attributeFields = getAttributeFields(element, prefix);
const userFields = Object.assign({}, this.opts.fieldsObj, attributeFields);
const hitType = attributeFields.hitType || "event";
this.tracker.send(
hitType,
createFieldsObj(
defaultFields,
userFields,
this.tracker,
this.opts.hitFilter,
element,
event
)
);
}
remove() {
Object.keys(this.delegates).forEach((key) => {
this.delegates[key].destroy();
});
}
}
provide("eventTracker", EventTracker);
// ---------------------------------------------------------------------------
// Plugin: outboundLinkTracker
// ---------------------------------------------------------------------------
function linkClickWillUnloadCurrentPage(event, link) {
return !(
event.type != "click" ||
link.target == "_blank" ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.which > 1
);
}
class OutboundLinkTracker {
constructor(tracker, opts) {
trackUsage(tracker, pluginIds.OUTBOUND_LINK_TRACKER);
if (!window.addEventListener) {
return;
}
const defaultOpts = {
events: ["click"],
linkSelector: "a, area",
shouldTrackOutboundLink: this.shouldTrackOutboundLink,
fieldsObj: {},
attributePrefix: "ga-",
};
this.opts = Object.assign(defaultOpts, opts);
this.tracker = tracker;
this.handleLinkInteractions = this.handleLinkInteractions.bind(this);
this.delegates = {};
this.opts.events.forEach((event) => {
this.delegates[event] = delegate(
document,
event,
this.opts.linkSelector,
this.handleLinkInteractions,
{ composed: true, useCapture: true }
);
});
}
handleLinkInteractions(event, link) {
if (this.opts.shouldTrackOutboundLink(link, parseUrl)) {
const href = link.getAttribute("href") || link.getAttribute("xlink:href");
const url = parseUrl(href);
const defaultFields = {
transport: "beacon",
eventCategory: "Outbound Link",
eventAction: event.type,
eventLabel: url.href,
};
const userFields = Object.assign(
{},
this.opts.fieldsObj,
getAttributeFields(link, this.opts.attributePrefix)
);
const fieldsObj = createFieldsObj(
defaultFields,
userFields,
this.tracker,
this.opts.hitFilter,
link,
event
);
if (
!navigator.sendBeacon &&
linkClickWillUnloadCurrentPage(event, link)
) {
const clickHandler = () => {
window.removeEventListener("click", clickHandler);
if (!event.defaultPrevented) {
event.preventDefault();
const oldHitCallback = fieldsObj.hitCallback;
fieldsObj.hitCallback = withTimeout(function () {
if (typeof oldHitCallback == "function") {
oldHitCallback();
}
location.href = href;
});
}
this.tracker.send("event", fieldsObj);
};
window.addEventListener("click", clickHandler);
} else {
this.tracker.send("event", fieldsObj);
}
}
}
shouldTrackOutboundLink(link, parseUrlFn) {
const href = link.getAttribute("href") || link.getAttribute("xlink:href");
const url = parseUrlFn(href);
return (
url.hostname != location.hostname && url.protocol.slice(0, 4) == "http"
);
}
remove() {
Object.keys(this.delegates).forEach((key) => {
this.delegates[key].destroy();
});
}
}
provide("outboundLinkTracker", OutboundLinkTracker);
// ---------------------------------------------------------------------------
// Plugin: urlChangeTracker
// ---------------------------------------------------------------------------
function getPath() {
return location.pathname + location.search;
}
class UrlChangeTracker {
constructor(tracker, opts) {
trackUsage(tracker, pluginIds.URL_CHANGE_TRACKER);
if (!history.pushState || !window.addEventListener) {
return;
}
const defaultOpts = {
shouldTrackUrlChange: this.shouldTrackUrlChange,
trackReplaceState: false,
fieldsObj: {},
hitFilter: null,
};
this.opts = Object.assign(defaultOpts, opts);
this.tracker = tracker;
this.path = getPath();
this.pushStateOverride = this.pushStateOverride.bind(this);
this.replaceStateOverride = this.replaceStateOverride.bind(this);
this.handlePopState = this.handlePopState.bind(this);
MethodChain.add(history, "pushState", this.pushStateOverride);
MethodChain.add(history, "replaceState", this.replaceStateOverride);
window.addEventListener("popstate", this.handlePopState);
}
pushStateOverride(originalMethod) {
return (...args) => {
originalMethod(...args);
this.handleUrlChange(true);
};
}
replaceStateOverride(originalMethod) {
return (...args) => {
originalMethod(...args);
this.handleUrlChange(false);
};
}
handlePopState() {
this.handleUrlChange(true);
}
handleUrlChange(historyDidUpdate) {
setTimeout(() => {
const oldPath = this.path;
const newPath = getPath();
if (
oldPath != newPath &&
this.opts.shouldTrackUrlChange.call(this, newPath, oldPath)
) {
this.path = newPath;
this.tracker.set({
page: newPath,
title: document.title,
});
if (historyDidUpdate || this.opts.trackReplaceState) {
const defaultFields = { transport: "beacon" };
this.tracker.send(
"pageview",
createFieldsObj(
defaultFields,
this.opts.fieldsObj,
this.tracker,
this.opts.hitFilter
)
);
}
}
}, 0);
}
shouldTrackUrlChange(newPath, oldPath) {
return !!(newPath && oldPath);
}
remove() {
MethodChain.remove(history, "pushState", this.pushStateOverride);
MethodChain.remove(history, "replaceState", this.replaceStateOverride);
window.removeEventListener("popstate", this.handlePopState);
}
}
provide("urlChangeTracker", UrlChangeTracker);