From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001 From: Fuwn <50817549+Fuwn@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:09:50 +0000 Subject: Initial commit Created from https://vercel.com/new --- src/tracker/index.d.ts | 153 +++++++++++++++++++++++++++++++ src/tracker/index.js | 240 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/tracker/index.d.ts create mode 100644 src/tracker/index.js (limited to 'src/tracker') diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts new file mode 100644 index 0000000..32fbee9 --- /dev/null +++ b/src/tracker/index.d.ts @@ -0,0 +1,153 @@ +export type TrackedProperties = { + /** + * Hostname of server + * + * @description extracted from `window.location.hostname` + * @example 'analytics.umami.is' + */ + hostname: string; + + /** + * Browser language + * + * @description extracted from `window.navigator.language` + * @example 'en-US', 'fr-FR' + */ + language: string; + + /** + * Page referrer + * + * @description extracted from `window.navigator.language` + * @example 'https://analytics.umami.is/docs/getting-started' + */ + referrer: string; + + /** + * Screen dimensions + * + * @description extracted from `window.screen.width` and `window.screen.height` + * @example '1920x1080', '2560x1440' + */ + screen: string; + + /** + * Page title + * + * @description extracted from `document.querySelector('head > title')` + * @example 'umami' + */ + title: string; + + /** + * Page url + * + * @description built from `${window.location.pathname}${window.location.search}` + * @example 'docs/getting-started' + */ + url: string; + + /** + * Website ID (required) + * + * @example 'b59e9c65-ae32-47f1-8400-119fcf4861c4' + */ + website: string; +}; + +export type WithRequired = T & { [P in K]-?: T[P] }; + +/** + * + * Event Data can work with any JSON data. There are a few rules in place to maintain performance. + * - Numbers have a max precision of 4. + * - Strings have a max length of 500. + * - Arrays are converted to a String, with the same max length of 500. + * - Objects have a max of 50 properties. Arrays are considered 1 property. + */ +export interface EventData { + [key: string]: number | string | EventData | number[] | string[] | EventData[]; +} + +export type EventProperties = { + /** + * NOTE: event names will be truncated past 50 characters + */ + name: string; + data?: EventData; +} & WithRequired; +export type PageViewProperties = WithRequired; +export type CustomEventFunction = ( + props: PageViewProperties, +) => EventProperties | PageViewProperties; + +export type UmamiTracker = { + track: { + /** + * Track a page view + * + * @example ``` + * umami.track(); + * ``` + */ + (): Promise; + + /** + * Track an event with a given name + * + * NOTE: event names will be truncated past 50 characters + * + * @example ``` + * umami.track('signup-button'); + * ``` + */ + (eventName: string): Promise; + + /** + * Tracks an event with dynamic data. + * + * NOTE: event names will be truncated past 50 characters + * + * When tracking events, the default properties are included in the payload. This is equivalent to running: + * + * ```js + * umami.track(props => ({ + * ...props, + * name: 'signup-button', + * data: { + * name: 'newsletter', + * id: 123 + * } + * })); + * ``` + * + * @example ``` + * umami.track('signup-button', { name: 'newsletter', id: 123 }); + * ``` + */ + (eventName: string, obj: EventData): Promise; + + /** + * Tracks a page view with custom properties + * + * @example ``` + * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' }); + * ``` + */ + (properties: PageViewProperties): Promise; + + /** + * Tracks an event with fully customizable dynamic data + * If you don't specify any `name` and/or `data`, it will be treated as a page view + * + * @example ``` + * umami.track((props) => ({ ...props, url: path })); + * ``` + */ + (eventFunction: CustomEventFunction): Promise; + }; +}; + +export interface Window { + umami: UmamiTracker; +} diff --git a/src/tracker/index.js b/src/tracker/index.js new file mode 100644 index 0000000..ad3648a --- /dev/null +++ b/src/tracker/index.js @@ -0,0 +1,240 @@ +(window => { + const { + screen: { width, height }, + navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt }, + location, + document, + history, + top, + doNotTrack, + } = window; + const { currentScript, referrer } = document; + if (!currentScript) return; + + const { hostname, href, origin } = location; + const localStorage = href.startsWith('data:') ? undefined : window.localStorage; + + const _data = 'data-'; + const _false = 'false'; + const _true = 'true'; + const attr = currentScript.getAttribute.bind(currentScript); + + const website = attr(`${_data}website-id`); + const hostUrl = attr(`${_data}host-url`); + const beforeSend = attr(`${_data}before-send`); + const tag = attr(`${_data}tag`) || undefined; + const autoTrack = attr(`${_data}auto-track`) !== _false; + const dnt = attr(`${_data}do-not-track`) === _true; + const excludeSearch = attr(`${_data}exclude-search`) === _true; + const excludeHash = attr(`${_data}exclude-hash`) === _true; + const domain = attr(`${_data}domains`) || ''; + const credentials = attr(`${_data}fetch-credentials`) || 'omit'; + + const domains = domain.split(',').map(n => n.trim()); + const host = + hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/'); + const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`; + const screen = `${width}x${height}`; + const eventRegex = /data-umami-event-([\w-_]+)/; + const eventNameAttribute = `${_data}umami-event`; + const delayDuration = 300; + + /* Helper functions */ + + const normalize = raw => { + if (!raw) return raw; + try { + const u = new URL(raw, location.href); + if (excludeSearch) u.search = ''; + if (excludeHash) u.hash = ''; + return u.toString(); + } catch { + return raw; + } + }; + + const getPayload = () => ({ + website, + screen, + language, + title: document.title, + hostname, + url: currentUrl, + referrer: currentRef, + tag, + id: identity ? identity : undefined, + }); + + const hasDoNotTrack = () => { + const dnt = doNotTrack || ndnt || msdnt; + return dnt === 1 || dnt === '1' || dnt === 'yes'; + }; + + /* Event handlers */ + + const handlePush = (_state, _title, url) => { + if (!url) return; + + currentRef = currentUrl; + currentUrl = normalize(new URL(url, location.href).toString()); + + if (currentUrl !== currentRef) { + setTimeout(track, delayDuration); + } + }; + + const handlePathChanges = () => { + const hook = (_this, method, callback) => { + const orig = _this[method]; + return (...args) => { + callback.apply(null, args); + return orig.apply(_this, args); + }; + }; + + history.pushState = hook(history, 'pushState', handlePush); + history.replaceState = hook(history, 'replaceState', handlePush); + }; + + const handleClicks = () => { + const trackElement = async el => { + const eventName = el.getAttribute(eventNameAttribute); + if (eventName) { + const eventData = {}; + + el.getAttributeNames().forEach(name => { + const match = name.match(eventRegex); + if (match) eventData[match[1]] = el.getAttribute(name); + }); + + return track(eventName, eventData); + } + }; + const onClick = async e => { + const el = e.target; + const parentElement = el.closest('a,button'); + if (!parentElement) return trackElement(el); + + const { href, target } = parentElement; + if (!parentElement.getAttribute(eventNameAttribute)) return; + + if (parentElement.tagName === 'BUTTON') { + return trackElement(parentElement); + } + if (parentElement.tagName === 'A' && href) { + const external = + target === '_blank' || + e.ctrlKey || + e.shiftKey || + e.metaKey || + (e.button && e.button === 1); + if (!external) e.preventDefault(); + return trackElement(parentElement).then(() => { + if (!external) { + (target === '_top' ? top.location : location).href = href; + } + }); + } + }; + document.addEventListener('click', onClick, true); + }; + + /* Tracking functions */ + + const trackingDisabled = () => + disabled || + !website || + localStorage?.getItem('umami.disabled') || + (domain && !domains.includes(hostname)) || + (dnt && hasDoNotTrack()); + + const send = async (payload, type = 'event') => { + if (trackingDisabled()) return; + + const callback = window[beforeSend]; + + if (typeof callback === 'function') { + payload = await Promise.resolve(callback(type, payload)); + } + + if (!payload) return; + + try { + const res = await fetch(endpoint, { + keepalive: true, + method: 'POST', + body: JSON.stringify({ type, payload }), + headers: { + 'Content-Type': 'application/json', + ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }), + }, + credentials, + }); + + const data = await res.json(); + if (data) { + disabled = !!data.disabled; + cache = data.cache; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_e) { + /* no-op */ + } + }; + + const init = () => { + if (!initialized) { + initialized = true; + track(); + handlePathChanges(); + handleClicks(); + } + }; + + const track = (name, data) => { + if (typeof name === 'string') return send({ ...getPayload(), name, data }); + if (typeof name === 'object') return send({ ...name }); + if (typeof name === 'function') return send(name(getPayload())); + return send(getPayload()); + }; + + const identify = (id, data) => { + if (typeof id === 'string') { + identity = id; + } + + cache = ''; + return send( + { + ...getPayload(), + data: typeof id === 'object' ? id : data, + }, + 'identify', + ); + }; + + /* Start */ + + if (!window.umami) { + window.umami = { + track, + identify, + }; + } + + let currentUrl = normalize(href); + let currentRef = normalize(referrer.startsWith(origin) ? '' : referrer); + + let initialized = false; + let disabled = false; + let cache; + let identity; + + if (autoTrack && !trackingDisabled()) { + if (document.readyState === 'complete') { + init(); + } else { + document.addEventListener('readystatechange', init, true); + } + } +})(window); -- cgit v1.2.3