@jaseeey/vue-umami-plugin
Version:
A plugin designed for Vue 3 which enables the use of Umami Analytics
286 lines (285 loc) • 10.5 kB
JavaScript
const PLUGIN_MARKER_ATTRIBUTE = 'data-umami-plugin';
const PLUGIN_STATE_ATTRIBUTE = 'data-umami-plugin-state';
const PROTECTED_DATA_ATTRIBUTES = new Set([
'data-website-id',
PLUGIN_MARKER_ATTRIBUTE
]);
const DEFAULT_MAX_QUEUED_EVENTS = 100;
const queuedEvents = [];
const attachedRouters = new WeakSet();
let installState = 'idle';
let hasWarnedQueueLimit = false;
let maxQueuedEvents = DEFAULT_MAX_QUEUED_EVENTS;
function setMaxQueuedEvents(value) {
if (typeof value === 'undefined') {
maxQueuedEvents = DEFAULT_MAX_QUEUED_EVENTS;
}
else if (typeof value === 'number' && Number.isFinite(value) && value >= 1) {
maxQueuedEvents = Math.floor(value);
}
else {
console.warn(`Invalid maxQueuedEvents value (${String(value)}); falling back to default of ${DEFAULT_MAX_QUEUED_EVENTS}.`);
maxQueuedEvents = DEFAULT_MAX_QUEUED_EVENTS;
}
hasWarnedQueueLimit = false;
let hasTrimmedQueuedEvents = false;
while (queuedEvents.length > maxQueuedEvents) {
queuedEvents.shift();
hasTrimmedQueuedEvents = true;
}
if (hasTrimmedQueuedEvents) {
warnQueueLimit();
}
}
function queueEvent(item) {
if (queuedEvents.length >= maxQueuedEvents) {
queuedEvents.shift();
warnQueueLimit();
}
queuedEvents.push(item);
}
function warnQueueLimit() {
if (!hasWarnedQueueLimit) {
console.warn(`Umami queue limit of ${maxQueuedEvents} reached; dropping oldest queued events until tracker is available.`);
hasWarnedQueueLimit = true;
}
}
function resolveAutoTrack(value, extraDataAttributes = {}) {
if (typeof value === 'boolean') {
return {
value,
isProvided: true,
shouldWarnConflict: true
};
}
if (value !== undefined) {
console.warn(`Invalid autoTrack value (${String(value)}); falling back to default of false.`);
return {
value: false,
isProvided: true,
shouldWarnConflict: false
};
}
if ('data-auto-track' in extraDataAttributes) {
return {
value: extraDataAttributes['data-auto-track'] !== 'false',
isProvided: false,
shouldWarnConflict: false
};
}
return {
value: false,
isProvided: false,
shouldWarnConflict: false
};
}
/**
* Creates a Vue plugin that injects the Umami tracker script and optionally wires SPA page-view tracking through a
* router.
*
* Installation is idempotent: repeated successful installs keep the existing tracker configuration and log a warning,
* but a new router is attached so separate Vue roots can track navigation. If the script fails to load, a later
* `install()` can retry (optionally with updated options). An empty or missing {@link UmamiPluginOptions.websiteID}
* skips installation with a warning. Tracking is skipped when
* `window.location.hostname` includes the substring `localhost` unless {@link UmamiPluginOptions.allowLocalhost} is
* `true`.
*
* @param options - Plugin configuration; see {@link UmamiPluginOptions}.
* @returns A Vue plugin object with an `install` method for `app.use(...)`.
*
* @example
* ```ts
* import { createApp } from 'vue';
* import { VueUmamiPlugin } from '@jaseeey/vue-umami-plugin';
* import router from './router';
*
* createApp(App)
* .use(VueUmamiPlugin({ websiteID: 'YOUR_ID', router }))
* .use(router)
* .mount('#app');
* ```
*/
export function VueUmamiPlugin(options) {
return {
install: () => {
if (window.location.hostname.includes('localhost') && !options.allowLocalhost) {
console.warn('Umami plugin not installed due to being on localhost.');
return;
}
const { scriptSrc = 'https://us.umami.is/script.js', websiteID, router, extraDataAttributes = {} } = options;
const autoTrack = resolveAutoTrack(options.autoTrack, extraDataAttributes);
if (!websiteID) {
return console.warn('Website ID not provided for Umami plugin, skipping.');
}
const currentInstallState = getInstallState();
if (currentInstallState !== 'idle') {
if (router) {
attachUmamiToRouter(router);
}
console.warn('Umami plugin is already installed or pending installation; keeping the existing configuration.');
return;
}
installState = 'pending';
setMaxQueuedEvents(options.maxQueuedEvents);
const debug = options.debug === true;
if (router) {
attachUmamiToRouter(router);
}
onDocumentReady(() => initUmamiScript(scriptSrc, websiteID, extraDataAttributes, autoTrack, debug));
}
};
}
function getInstallState() {
if (installState === 'idle') {
return installState;
}
if (installState === 'pending' && document.readyState === 'loading') {
return installState;
}
if (!document.head.querySelector(`script[${PLUGIN_MARKER_ATTRIBUTE}]`)) {
installState = 'idle';
}
return installState;
}
function attachUmamiToRouter(router) {
if (attachedRouters.has(router)) {
console.warn('Umami plugin router hook is already attached to this router; keeping the existing hook.');
return;
}
attachedRouters.add(router);
router.afterEach((to) => trackUmamiPageView({ url: to.fullPath }));
}
function onDocumentReady(callback) {
document.readyState !== 'loading'
? callback()
: document.addEventListener('DOMContentLoaded', callback);
}
function initUmamiScript(scriptSrc, websiteID, extraDataAttributes, autoTrack, debug) {
const existingScript = document.head.querySelector(`script[${PLUGIN_MARKER_ATTRIBUTE}]`);
if (existingScript) {
const isLoaded = existingScript.getAttribute(PLUGIN_STATE_ATTRIBUTE) === 'loaded';
installState = isLoaded ? 'loaded' : 'pending';
if (isLoaded) {
processQueuedEvents();
}
else {
existingScript.addEventListener('load', () => {
installState = 'loaded';
processQueuedEvents();
}, { once: true });
existingScript.addEventListener('error', () => {
installState = 'idle';
}, { once: true });
}
console.warn('Umami plugin script is already injected; skipping duplicate injection.');
return;
}
const script = document.createElement('script');
script.defer = true;
script.src = scriptSrc;
script.onload = () => {
script.setAttribute(PLUGIN_STATE_ATTRIBUTE, 'loaded');
installState = 'loaded';
if (debug) {
console.log('Umami plugin loaded');
}
processQueuedEvents();
};
script.onerror = () => {
installState = 'idle';
console.warn('Umami plugin script failed to load; removing marker so a later install can retry.');
script.remove();
};
script.setAttribute(PLUGIN_MARKER_ATTRIBUTE, 'true');
script.setAttribute(PLUGIN_STATE_ATTRIBUTE, 'pending');
script.setAttribute('data-website-id', websiteID);
script.setAttribute('data-auto-track', String(autoTrack.value));
if (extraDataAttributes) {
if (autoTrack.shouldWarnConflict && 'data-auto-track' in extraDataAttributes) {
console.warn('Umami plugin autoTrack option conflicts with extraDataAttributes["data-auto-track"]; the explicit autoTrack option takes precedence.');
}
for (const [key, value] of Object.entries(extraDataAttributes)) {
if (PROTECTED_DATA_ATTRIBUTES.has(key) || !key.startsWith('data-')) {
continue;
}
if (autoTrack.isProvided && key === 'data-auto-track') {
continue;
}
script.setAttribute(key, value);
}
}
document.head.appendChild(script);
}
function processQueuedEvents() {
const tracker = window.umami;
if (!tracker) {
return;
}
while (queuedEvents.length) {
const item = queuedEvents.shift();
if (!item) {
continue;
}
typeof item === 'function'
? tracker.track(item)
: item.kind === 'identify'
? typeof item.args[0] === 'string'
? tracker.identify(item.args[0], item.args[1])
: tracker.identify(item.args[0])
: tracker.track(item.event, item.args[0]);
}
hasWarnedQueueLimit = false;
}
/**
* Tracks a page view, optionally overriding Umami's default payload fields such as `url`, `title`, or `referrer`.
*
* Useful when not using a router, or when you need a view outside normal navigation. If the tracker is not loaded yet,
* the call is queued (subject to {@link UmamiPluginOptions.maxQueuedEvents}).
*
* @param options - Partial page-view fields merged onto tracker defaults.
*
* @example
* ```ts
* trackUmamiPageView({ url: '/checkout', title: 'Checkout' });
* ```
*/
export function trackUmamiPageView(options) {
const trackPageViewOptionsFn = (props) => {
return { ...props, ...options };
};
const tracker = window.umami;
tracker
? tracker.track(trackPageViewOptionsFn)
: queueEvent(trackPageViewOptionsFn);
}
/**
* Tracks a named custom event with optional event data.
*
* If the tracker is not loaded yet, the call is queued (subject to {@link UmamiPluginOptions.maxQueuedEvents}).
*
* @param event - Event name reported to Umami.
* @param eventParams - Optional structured payload for the event.
*
* @example
* ```ts
* trackUmamiEvent('button-click', { buttonName: 'subscribe' });
* ```
*/
export function trackUmamiEvent(event, eventParams) {
const tracker = window.umami;
tracker
? tracker.track(event, eventParams)
: queueEvent({ kind: 'track', event, args: [eventParams] });
}
export function identifyUmamiSession(idOrSessionData, sessionData) {
const tracker = window.umami;
if (typeof idOrSessionData === 'string') {
tracker
? tracker.identify(idOrSessionData, sessionData)
: queueEvent({ kind: 'identify', args: [idOrSessionData, sessionData] });
return;
}
tracker
? tracker.identify(idOrSessionData)
: queueEvent({ kind: 'identify', args: [idOrSessionData] });
}