UNPKG

@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
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] }); }