diff --git a/src/index.ts b/src/index.ts index 906e53b..8e0dcea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,16 @@ import { } from './helper/accessibility'; import { scrollToFragment } from './helper/scroll-to-fragment'; import './index.scss'; +import Plausible from './plausible/tracker'; + +const plausible = Plausible({ + hashMode: true, + trackLocalhost: true, + apiURI: 'https://stats.schmelczer.dev/status', +}); + +plausible.enableAutoPageviews(); +plausible.enableAutoOutboundTracking(); addSupportForTabNavigation(); removeUnnecessaryOutlines(); diff --git a/src/plausible/request.ts b/src/plausible/request.ts new file mode 100644 index 0000000..26e152c --- /dev/null +++ b/src/plausible/request.ts @@ -0,0 +1,73 @@ +import type { PlausibleOptions } from './tracker'; + +type EventPayload = { + readonly n: string; + readonly u: Location['href']; + readonly d: Location['hostname']; + readonly r: Document['referrer'] | null; + readonly w: Window['innerWidth']; + readonly h: 1 | 0; + readonly p?: string; +}; + +export type EventOptions = { + /** + * Callback called when the event is successfully sent. + */ + readonly callback?: () => void; + /** + * Properties to be bound to the event. + */ + readonly props?: { readonly [propName: string]: string }; +}; + +/** + * @internal + * Sends an event to Plausible's API + * + * @param data - Event data to send + * @param options - Event options + */ +export function sendEvent( + eventName: string, + data: Required, + options?: EventOptions, +): void { + const isLocalhost = + /^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*:)*?:?0*1$/.test( + window.location.hostname, + ) || window.location.protocol === 'file:'; + + if (!data.trackLocalhost && isLocalhost) { + return console.warn('[Plausible] Ignoring event because website is running locally'); + } + + const shouldIgnoreCurrentBrowser = localStorage.getItem('plausible_ignore') === 'true'; + if (shouldIgnoreCurrentBrowser) { + return console.warn( + '[Plausible] Ignoring event because "plausible_ignore" is set to "true" in localStorage', + ); + } + + const payload: EventPayload = { + n: eventName, + u: data.url, + d: data.domain, + r: data.referrer, + w: data.deviceWidth, + h: data.hashMode ? 1 : 0, + p: options && options.props ? JSON.stringify(options.props) : undefined, + }; + + const req = new XMLHttpRequest(); + req.open('POST', data.apiURI, true); + req.setRequestHeader('Content-Type', 'text/plain'); + req.send(JSON.stringify(payload)); + + req.onreadystatechange = () => { + if (req.readyState !== 4) return; + if (options && options.callback) { + options.callback(); + } + }; +} diff --git a/src/plausible/tracker.ts b/src/plausible/tracker.ts new file mode 100644 index 0000000..996c519 --- /dev/null +++ b/src/plausible/tracker.ts @@ -0,0 +1,334 @@ +import { EventOptions, sendEvent } from './request'; + +/** + * Options used when initializing the tracker. + */ +export type PlausibleInitOptions = { + /** + * If true, pageviews will be tracked when the URL hash changes. + * Enable this if you are using a frontend that uses hash-based routing. + */ + readonly hashMode?: boolean; + /** + * Set to true if you want events to be tracked when running the site locally. + */ + readonly trackLocalhost?: boolean; + /** + * The domain to bind the event to. + * Defaults to `location.hostname` + */ + readonly domain?: Location['hostname']; + /** + * The API host where the events will be sent. + * Defaults to `'https://plausible.io/api/event'` + */ + readonly apiURI?: string; +}; + +/** + * Data passed to Plausible as events. + */ +export type PlausibleEventData = { + /** + * The URL to bind the event to. + * Defaults to `location.href`. + */ + readonly url?: Location['href']; + /** + * The referrer to bind the event to. + * Defaults to `document.referrer` + */ + readonly referrer?: Document['referrer'] | null; + /** + * The current device's width. + * Defaults to `window.innerWidth` + */ + readonly deviceWidth?: Window['innerWidth']; +}; + +/** + * Options used when tracking Plausible events. + */ +export type PlausibleOptions = PlausibleInitOptions & PlausibleEventData; + +/** + * Tracks a custom event. + * + * Use it to track your defined goals by providing the goal's name as `eventName`. + * + * ### Example + * ```js + * import Plausible from 'plausible-tracker' + * + * const { trackEvent } = Plausible() + * + * // Tracks the 'signup' goal + * trackEvent('signup') + * + * // Tracks the 'Download' goal passing a 'method' property. + * trackEvent('Download', { props: { method: 'HTTP' } }) + * ``` + * + * @param eventName - Name of the event to track + * @param options - Event options. + * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier. + */ +type TrackEvent = ( + eventName: string, + options?: EventOptions, + eventData?: PlausibleOptions, +) => void; + +/** + * Manually tracks a page view. + * + * ### Example + * ```js + * import Plausible from 'plausible-tracker' + * + * const { trackPageview } = Plausible() + * + * // Track a page view + * trackPageview() + * ``` + * + * @param eventData - Optional event data to send. Defaults to the current page's data merged with the default options provided earlier. + * @param options - Event options. + */ +type TrackPageview = (eventData?: PlausibleOptions, options?: EventOptions) => void; + +/** + * Cleans up all event listeners attached. + */ +type Cleanup = () => void; + +/** + * Tracks the current page and all further pages automatically. + * + * Call this if you don't want to manually manage pageview tracking. + * + * ### Example + * ```js + * import Plausible from 'plausible-tracker' + * + * const { enableAutoPageviews } = Plausible() + * + * // This tracks the current page view and all future ones as well + * enableAutoPageviews() + * ``` + * + * The returned value is a callback that removes the added event listeners and restores `history.pushState` + * ```js + * import Plausible from 'plausible-tracker' + * + * const { enableAutoPageviews } = Plausible() + * + * const cleanup = enableAutoPageviews() + * + * // Remove event listeners and restore `history.pushState` + * cleanup() + * ``` + */ +type EnableAutoPageviews = () => Cleanup; + +/** + * Tracks all outbound link clicks automatically + * + * Call this if you don't want to manually manage these links. + * + * It works using a **[MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)** to automagically detect link nodes throughout your application and bind `click` events to them. + * + * Optionally takes the same parameters as [`MutationObserver.observe`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe). + * + * ### Example + * ```js + * import Plausible from 'plausible-tracker' + * + * const { enableAutoOutboundTracking } = Plausible() + * + * // This tracks all the existing and future outbound links on your page. + * enableAutoOutboundTracking() + * ``` + * + * The returned value is a callback that removes the added event listeners and disconnects the observer + * ```js + * import Plausible from 'plausible-tracker' + * + * const { enableAutoOutboundTracking } = Plausible() + * + * const cleanup = enableAutoOutboundTracking() + * + * // Remove event listeners and disconnect the observer + * cleanup() + * ``` + */ +type EnableAutoOutboundTracking = ( + targetNode?: Node & ParentNode, + observerInit?: MutationObserverInit, +) => Cleanup; + +/** + * Initializes the tracker with your default values. + * + * ### Example (es module) + * ```js + * import Plausible from 'plausible-tracker' + * + * const { enableAutoPageviews, trackEvent } = Plausible({ + * domain: 'my-app-domain.com', + * hashMode: true + * }) + * + * enableAutoPageviews() + * + * function onUserRegister() { + * trackEvent('register') + * } + * ``` + * + * ### Example (commonjs) + * ```js + * var Plausible = require('plausible-tracker'); + * + * var { enableAutoPageviews, trackEvent } = Plausible({ + * domain: 'my-app-domain.com', + * hashMode: true + * }) + * + * enableAutoPageviews() + * + * function onUserRegister() { + * trackEvent('register') + * } + * ``` + * + * @param defaults - Default event parameters that will be applied to all requests. + */ +export default function Plausible(defaults?: PlausibleInitOptions): { + readonly trackEvent: TrackEvent; + readonly trackPageview: TrackPageview; + readonly enableAutoPageviews: EnableAutoPageviews; + readonly enableAutoOutboundTracking: EnableAutoOutboundTracking; +} { + const getConfig = (): Required => ({ + hashMode: false, + trackLocalhost: false, + url: window.location.href, + domain: window.location.hostname, + referrer: document.referrer || null, + deviceWidth: window.innerWidth, + apiURI: 'https://plausible.io/api/event/', + ...defaults, + }); + + const trackEvent: TrackEvent = (eventName, options, eventData) => { + sendEvent(eventName, { ...getConfig(), ...eventData }, options); + }; + + const trackPageview: TrackPageview = (eventData, options) => { + trackEvent('pageview', options, eventData); + }; + + const enableAutoPageviews: EnableAutoPageviews = () => { + const page = () => trackPageview(); + // Attach pushState and popState listeners + const originalPushState = window.history.pushState; + if (originalPushState) { + window.history.pushState = function (data, title, url) { + originalPushState.apply(this, [data, title, url]); + page(); + }; + window.addEventListener('popstate', page); + } + + // Attach hashchange listener + if (defaults && defaults.hashMode) { + window.addEventListener('hashchange', page); + } + + // Trigger first page view + trackPageview(); + + return function cleanup() { + if (originalPushState) { + window.history.pushState = originalPushState; + window.removeEventListener('popstate', page); + } + if (defaults && defaults.hashMode) { + window.removeEventListener('hashchange', page); + } + }; + }; + + const enableAutoOutboundTracking: EnableAutoOutboundTracking = ( + targetNode: Node & ParentNode = document, + observerInit: MutationObserverInit = { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['href'], + }, + ) => { + function trackClick(this: HTMLAnchorElement, event: MouseEvent) { + trackEvent('Outbound Link: Click', { props: { url: this.href } }); + } + + const tracked: Set = new Set(); + + function addNode(node: Node | ParentNode) { + if (node instanceof HTMLAnchorElement) { + if (node.host !== window.location.host) { + node.addEventListener('click', trackClick); + tracked.add(node); + } + } /* istanbul ignore next */ else if ('querySelectorAll' in node) { + node.querySelectorAll('a').forEach(addNode); + } + } + + function removeNode(node: Node | ParentNode) { + if (node instanceof HTMLAnchorElement) { + node.removeEventListener('click', trackClick); + tracked.delete(node); + } /* istanbul ignore next */ else if ('querySelectorAll' in node) { + node.querySelectorAll('a').forEach(removeNode); + } + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + // Handle changed href + removeNode(mutation.target); + addNode(mutation.target); + } /* istanbul ignore next */ else if (mutation.type === 'childList') { + // Handle added nodes + mutation.addedNodes.forEach(addNode); + // Handle removed nodes + mutation.removedNodes.forEach(removeNode); + } + }); + }); + + // Track existing nodes + targetNode.querySelectorAll('a').forEach(addNode); + + // Observe mutations + observer.observe(targetNode, observerInit); + + return function cleanup() { + tracked.forEach((a) => { + a.removeEventListener('click', trackClick); + }); + tracked.clear(); + observer.disconnect(); + }; + }; + + return { + trackEvent, + trackPageview, + enableAutoPageviews, + enableAutoOutboundTracking, + }; +}