From 369faa5fbef4b898197ca6b6d0435c6118c56039 Mon Sep 17 00:00:00 2001 From: Joe Elstner Date: Sat, 19 Sep 2026 17:03:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(outbound):=20count=20the=20clicks=20a=20se?= =?UTF-8?q?rver=20log=20cannot=20see=20=E2=80=94=20phone=20taps,=20emails,?= =?UTF-8?q?=20links=20off=20the=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four client sites in the fleet run with no working analytics at all, and the one action that matters on them — a visitor tapping the phone number — leaves no trace anywhere. The access log sees page requests; it cannot see a `tel:` tap, an email link, a link out to a merchant, or a booking widget opening. GA4 does not count `tel:` taps by default even where a tag is present. adellion #57 proved the pattern on product links: one delegated listener, a GET for a 3-byte static file, and CloudFront's access log is the counter. This generalizes it so the sites that need it can adopt ~90 lines instead of each inventing their own, and so the privacy properties are written down once. - src/outbound.ts — pure helpers. `describeClick` turns a clicked element into an event or null; `beaconUrl` renders it. Six events: tel, mailto, out, amazon, merchant, track. adellion's query keys are unchanged so outbound_clicks.py keeps reading its rows. - src/outbound-clicks.tsx — ``. One delegated click + auxclick listener on the document, credentials omitted, keepalive, renders null, so pages stay server components. `enabled` lets a site with a consent gate hold it shut. - Internal links are deliberately silent: a click that ends in a request to our own server is already in the same log as a page view. - What may leave the page: an event name, the path, a hostname, and labels the site's own source wrote. Never a phone number, an email address, a link's path or query, or anything a visitor typed. BEACON_KEYS is the whole vocabulary and the test fails if a seventh key appears without being named there. - The package had no test runner. Added vitest + jsdom and 32 tests that click real elements: one beacon per tap, no cookie header, no preventDefault, the href untouched, nothing for an internal link, nothing while disabled, nothing after unmount, and phone numbers and emails absent from the URL. - @types/node, @types/react-dom and web-vitals as devDependencies clear the three errors `tsc --noEmit` already reported; it now exits 0. - `files` excludes *.test.ts so the vitest import never ships to a client site. No consuming site is touched by this commit and no tag is pushed, so nothing deploys. 1.3.0 reaches sites only when the release tag is cut. Verified: vitest 32/32, tsc --noEmit exit 0, npm pack ships src/outbound.ts + src/outbound-clicks.tsx and no test file. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 36 +++++ package.json | 27 +++- src/index.ts | 4 + src/outbound-clicks.tsx | 73 ++++++++++ src/outbound.test.ts | 314 ++++++++++++++++++++++++++++++++++++++++ src/outbound.ts | 147 +++++++++++++++++++ vitest.config.ts | 10 ++ 7 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 src/outbound-clicks.tsx create mode 100644 src/outbound.test.ts create mode 100644 src/outbound.ts create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index 9596b63..d8e57ef 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,42 @@ Peer dependencies: `react ^19`, `react-dom ^19`, `next ^16`, `framer-motion ^12` Each component is exported on its own subpath — `@isimplifyme/ui/bento`, `@isimplifyme/ui/seo`, `@isimplifyme/ui/concierge`, and so on. See `package.json` `exports` for the full map. +## Click beacon + +`OutboundClicks` counts the clicks a server access log cannot see: phone taps, email links, links off the site, and anything the page labels itself. A click sends one `GET /out.txt?e=&p=&…&t=` for a tiny static file, and the CDN's own access log is the counter — no cookie, no identifier, no third party, no server code, and the visitor's link is never rewritten. + +```tsx +// app/layout.tsx +import { OutboundClicks } from '@isimplifyme/ui/outbound-clicks' +; + +// anywhere +(312) 500-2674 + +``` + +The consuming site must ship the file the beacon asks for — `public/out.txt`, any short body — or every click is a 404 (still logged, but it reads as an error). + +| `e` | fires on | also carries | +| --- | --- | --- | +| `tel` | `a[href^="tel:"]` | `n` label | +| `mailto` | `a[href^="mailto:"]` | `n` label | +| `out` | an `http(s)` link to another host | `h` hostname, `n` label | +| `amazon` / `merchant` | `a[data-amazon]` / `a[data-merchant]` | `n` product, `i` position, `m` merchant | +| `track` | any `[data-track]` that is not inside a link | `n` label | + +What may leave the page is an event name, the current path, a hostname, and labels written in the site's own source — never a phone number, an email address, a link's path or query, or anything a visitor typed. `BEACON_KEYS` in `src/outbound.ts` is the whole vocabulary, and `src/outbound.test.ts` fails if a new key appears without being named there. + +Internal links are deliberately silent: a click that ends in a request to our own server is already in the same log as a page view. Pass `ignoreHosts` for hostnames the site considers its own, and `enabled={false}` (or mount inside a consent gate) where a site asks before it measures. + +## Tests + +```bash +npm install +npm test # vitest +npm run typecheck +``` + ## Security Report security issues to [ai@isimplifyme.com](mailto:ai@isimplifyme.com). diff --git a/package.json b/package.json index f26823f..ca3e3d0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@isimplifyme/ui", - "version": "1.2.1", - "description": "React/Next.js UI library for iSimplifyMe properties — design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", + "version": "1.3.0", + "description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", "homepage": "https://isimplifyme.com", "type": "module", "exports": { @@ -23,10 +23,18 @@ "./image-lightbox": "./src/image-lightbox.tsx", "./related-articles": "./src/related-articles.tsx", "./bot-middleware": "./src/bot-middleware.ts", - "./concierge": "./src/concierge.tsx" + "./concierge": "./src/concierge.tsx", + "./outbound": "./src/outbound.ts", + "./outbound-clicks": "./src/outbound-clicks.tsx" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "files": [ - "src" + "src", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" ], "peerDependencies": { "react": "^19", @@ -35,12 +43,17 @@ "next": "^16.2.5" }, "devDependencies": { - "react": "^19", - "react-dom": "^19", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", "framer-motion": "^12", + "jsdom": "^27", "next": "^16", + "react": "^19", + "react-dom": "^19", "typescript": "^6", - "@types/react": "^19" + "vitest": "^4", + "web-vitals": "^5" }, "repository": { "type": "git", diff --git a/src/index.ts b/src/index.ts index 03830ea..8734ed5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,7 @@ export { Analytics, WebVitals, trackEvent, analytics } from './analytics' export { ArticleNav } from './article-nav' export { SearchFilter } from './search-filter' export { StagingBanner } from './staging-banner' +export { OutboundClicks } from './outbound-clicks' +export type { OutboundClicksProps } from './outbound-clicks' +export { OUTBOUND_BEACON_PATH, BEACON_KEYS, beaconUrl, describeClick } from './outbound' +export type { OutboundClick, OutboundKind, ClickContext } from './outbound' diff --git a/src/outbound-clicks.tsx b/src/outbound-clicks.tsx new file mode 100644 index 0000000..07f3e4c --- /dev/null +++ b/src/outbound-clicks.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect } from 'react' +import { OUTBOUND_BEACON_PATH, beaconUrl, describeClick } from './outbound' + +export interface OutboundClicksProps { + /** Path of the static file to request. Must exist — ship `public/out.txt`. */ + beaconPath?: string + /** + * Hostnames to treat as internal alongside the page's own: a staging domain, + * a sibling property, a booking host the site considers part of itself. + */ + ignoreHosts?: readonly string[] + /** + * Set false to send nothing. A site that asks before analytics mounts this + * inside its consent gate, or passes the gate's answer here. + */ + enabled?: boolean +} + +/** + * Counts the clicks a server log cannot see — phone taps, email links, links + * off the site, and anything the page labels with `data-track` (see + * ./outbound.ts for what may leave the page). + * + * One delegated listener on the document, so every page stays a server + * component and nothing has to be wrapped. Renders null. + * + * // in the root layout + * … + * + * + * Put `data-track` on the thing that gets clicked, not on a section around it: + * the label is read from the nearest labelled ancestor, so a wrapper would + * claim every click inside it. + */ +export function OutboundClicks({ beaconPath = OUTBOUND_BEACON_PATH, ignoreHosts, enabled = true }: OutboundClicksProps) { + // A caller's inline array is a new array every render; its contents are not. + const ignoreKey = (ignoreHosts ?? []).join(',') + useEffect(() => { + if (!enabled) return + const ignored = ignoreKey ? ignoreKey.split(',') : [] + const onClick = (event: MouseEvent) => { + // Left click, or middle click (auxclick) opening a background tab. + if (event.type === 'auxclick' && event.button !== 1) return + const target = event.target as Element | null + if (!target?.closest) return + const click = describeClick(target, { + pathname: window.location.pathname, + hostname: window.location.hostname, + ignoreHosts: ignored, + }) + if (!click) return + // keepalive lets the request finish if the tab navigates; credentials + // are omitted so not even our own cookies ride along. + fetch(beaconUrl(click, Date.now(), beaconPath), { + method: 'GET', + keepalive: true, + cache: 'no-store', + credentials: 'omit', + }).catch(() => {}) + } + document.addEventListener('click', onClick, true) + document.addEventListener('auxclick', onClick, true) + return () => { + document.removeEventListener('click', onClick, true) + document.removeEventListener('auxclick', onClick, true) + } + }, [beaconPath, ignoreKey, enabled]) + return null +} + +export default OutboundClicks diff --git a/src/outbound.test.ts b/src/outbound.test.ts new file mode 100644 index 0000000..ea4a666 --- /dev/null +++ b/src/outbound.test.ts @@ -0,0 +1,314 @@ +/** + * jsdom prints "Not implemented: navigation to another Document" while these + * run. That is the point: the clicks are real and the hrefs are untouched, so + * jsdom tries to follow them. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { BEACON_KEYS, OUTBOUND_BEACON_PATH, beaconUrl, describeClick } from './outbound' +import { OutboundClicks } from './outbound-clicks' + +const SITE = { pathname: '/contact', hostname: 'example.com' } + +/** Builds the markup and returns the element the visitor actually clicks. */ +function clicked(html: string, selector = '[data-hit]'): Element { + document.body.innerHTML = html + const element = document.querySelector(selector) + if (!element) throw new Error(`no ${selector} in ${html}`) + return element +} + +const read = (...parts: string[]) => fs.readFileSync(path.join(process.cwd(), ...parts), 'utf-8') + +describe('describeClick — phone and email', () => { + it('counts a phone tap, and carries the label instead of the number', () => { + const click = describeClick(clicked('Call us'), SITE) + expect(click).toEqual({ kind: 'tel', page: '/contact', label: 'header' }) + expect(beaconUrl(click!, 1)).not.toContain('3125002674') + }) + + it('counts an unlabelled phone tap with no label at all', () => { + expect(describeClick(clicked('Call'), SITE)).toEqual({ + kind: 'tel', + page: '/contact', + label: undefined, + }) + }) + + it('counts an email click without the address', () => { + const click = describeClick(clicked('Email'), SITE) + expect(click).toEqual({ kind: 'mailto', page: '/contact', label: 'footer' }) + expect(beaconUrl(click!, 1)).not.toContain('hello') + }) + + it('reads the label off the clicked child when the anchor is unlabelled', () => { + const click = describeClick(clicked('Call'), SITE) + expect(click?.label).toBe('hero') + }) +}) + +describe('describeClick — links off the site', () => { + it('keeps the hostname and nothing else from the URL', () => { + const click = describeClick( + clicked('Apply'), + SITE, + ) + expect(click).toEqual({ kind: 'out', page: '/contact', host: 'apply.rate.com', label: 'apply' }) + const url = beaconUrl(click!, 1) + expect(url).not.toContain('loans') + expect(url).not.toContain('secret') + expect(url).not.toContain('ssn') + }) + + it('lower-cases the hostname', () => { + expect(describeClick(clicked('Directions'), SITE)?.host).toBe('maps.google.com') + }) + + it('ignores our own host, with or without www', () => { + expect(describeClick(clicked('About'), SITE)).toBeNull() + expect(describeClick(clicked('About'), SITE)).toBeNull() + }) + + it('ignores a relative link — a request to us is already in the log', () => { + expect(describeClick(clicked('Services'), SITE)).toBeNull() + }) + + it('ignores the hosts the site calls its own', () => { + const context = { ...SITE, ignoreHosts: ['staging.example.com', 'Example.net'] } + expect(describeClick(clicked('x'), context)).toBeNull() + expect(describeClick(clicked('x'), context)).toBeNull() + expect(describeClick(clicked('x'), context)?.host).toBe('other.example.org') + }) + + it('ignores schemes it cannot read a hostname from', () => { + expect(describeClick(clicked('x'), SITE)).toBeNull() + expect(describeClick(clicked('Text'), SITE)).toBeNull() + }) +}) + +describe('describeClick — product links keep adellion’s shape', () => { + it('reads an Amazon card link', () => { + expect( + describeClick( + clicked('Buy'), + { pathname: '/capture-cards', hostname: 'adellion.com' }, + ), + ).toEqual({ kind: 'amazon', page: '/capture-cards', label: 'Elgato HD60 X', position: 2 }) + }) + + it('reads a merchant card link and keeps the merchant name', () => { + expect( + describeClick( + clicked('Buy'), + { pathname: '/best-gaming-chairs', hostname: 'adellion.com' }, + ), + ).toEqual({ kind: 'merchant', page: '/best-gaming-chairs', label: 'Titan Evo', position: 1, merchant: 'Secretlab' }) + }) + + it('emits exactly the query adellion’s reader already parses', () => { + const click = describeClick( + clicked('Buy'), + { pathname: '/capture-cards', hostname: 'adellion.com' }, + ) + expect(Object.fromEntries(new URLSearchParams(beaconUrl(click!, 1700000000000).split('?')[1]))).toEqual({ + e: 'amazon', + p: '/capture-cards', + n: 'Elgato HD60 X', + i: '2', + t: '1700000000000', + }) + }) + + it('survives a missing position', () => { + const click = describeClick(clicked('Buy'), SITE) + expect(click?.position).toBe(0) + }) +}) + +describe('describeClick — labelled things that are not links', () => { + it('counts a button the site asked to count', () => { + expect(describeClick(clicked(''), SITE)).toEqual({ + kind: 'track', + page: '/contact', + label: 'book-online', + }) + }) + + it('stays silent on an unlabelled click anywhere else', () => { + expect(describeClick(clicked('
just words
'), SITE)).toBeNull() + }) + + it('an element inside a link belongs to the link, not to itself', () => { + const click = describeClick(clicked(''), SITE) + expect(click?.kind).toBe('tel') + }) +}) + +describe('describeClick — clipping and defaults', () => { + it('clips long values and defaults an empty path', () => { + const click = describeClick(clicked(``), { + pathname: 'p'.repeat(300), + hostname: 'example.com', + }) + expect(click?.label).toHaveLength(60) + expect(click?.page).toHaveLength(120) + expect(describeClick(clicked(''), { pathname: '', hostname: 'example.com' })?.page).toBe('/') + }) + + it('treats a blank label as no label', () => { + expect(describeClick(clicked(''), SITE)).toBeNull() + expect(describeClick(clicked('Call'), SITE)?.label).toBeUndefined() + }) +}) + +describe('beaconUrl', () => { + it('is a same-origin GET for the static beacon file', () => { + expect(beaconUrl({ kind: 'tel', page: '/' }, 1).startsWith(`${OUTBOUND_BEACON_PATH}?`)).toBe(true) + expect(beaconUrl({ kind: 'tel', page: '/' }, 1, '/beacon.txt').startsWith('/beacon.txt?')).toBe(true) + }) + + it('is unique per click, so no cache answers it', () => { + const click = { kind: 'out' as const, page: '/x', host: 'y.com' } + expect(beaconUrl(click, 1)).not.toBe(beaconUrl(click, 2)) + }) + + it('omits the keys a click has nothing to say about', () => { + expect(beaconUrl({ kind: 'tel', page: '/contact' }, 1)).toBe('/out.txt?e=tel&p=%2Fcontact&t=1') + }) + + /** + * The privacy guarantee is the size of this vocabulary. A new key here means + * something new about the visitor can leave the page, so it has to be named + * in BEACON_KEYS and in this literal before any test passes again. + */ + it('never emits a key outside the allow-list', () => { + expect([...BEACON_KEYS]).toEqual(['e', 'p', 'n', 'h', 'i', 'm', 't']) + const kinds = ['tel', 'mailto', 'out', 'amazon', 'merchant', 'track'] as const + for (const kind of kinds) { + const url = beaconUrl({ kind, page: '/p', label: 'l', host: 'h.com', position: 1, merchant: 'm' }, 1) + for (const key of new URLSearchParams(url.split('?')[1]).keys()) { + expect(BEACON_KEYS).toContain(key) + } + } + }) +}) + +describe(' on a real page', () => { + let fetchMock: ReturnType + let container: HTMLElement + let root: ReturnType + + const mount = (props: Record = {}) => { + act(() => root.render(createElement(OutboundClicks, props))) + } + + beforeEach(() => { + ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + fetchMock = vi.fn(() => Promise.resolve({ ok: true } as Response)) + vi.stubGlobal('fetch', fetchMock) + document.body.innerHTML = '' + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.unstubAllGlobals() + }) + + it('sends exactly one cookieless beacon per tap, and leaves the link alone', () => { + mount() + const link = document.createElement('a') + link.href = 'tel:+13125002674' + link.dataset.track = 'header' + document.body.appendChild(link) + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + link.dispatchEvent(event) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toMatch(/^\/out\.txt\?e=tel&p=[^&]*&n=header&t=\d+$/) + expect(init).toMatchObject({ method: 'GET', credentials: 'omit', cache: 'no-store', keepalive: true }) + expect(init.headers).toBeUndefined() + // The visitor's own click still does what it was going to do. + expect(event.defaultPrevented).toBe(false) + expect(link.getAttribute('href')).toBe('tel:+13125002674') + }) + + it('sends nothing for a click with nothing to count', () => { + mount() + document.body.appendChild(document.createElement('p')).dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('counts a middle click that opens a background tab, but not a right click', () => { + mount() + const link = document.createElement('a') + link.href = 'https://apply.rate.com/x' + document.body.appendChild(link) + link.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 2 })) + expect(fetchMock).not.toHaveBeenCalled() + link.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 })) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toContain('h=apply.rate.com') + }) + + it('sends nothing at all while disabled — a consent gate can hold it shut', () => { + mount({ enabled: false }) + const link = document.createElement('a') + link.href = 'tel:+13125002674' + document.body.appendChild(link) + link.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('stops listening when it unmounts', () => { + mount() + const link = document.createElement('a') + link.href = 'tel:+13125002674' + document.body.appendChild(link) + act(() => root.unmount()) + link.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + root = createRoot(container) // afterEach unmounts something valid + }) + + it('respects a custom beacon path and the site’s own hosts', () => { + mount({ beaconPath: '/b.txt', ignoreHosts: ['apply.rate.com'] }) + const ignored = document.createElement('a') + ignored.href = 'https://apply.rate.com/x' + document.body.appendChild(ignored) + ignored.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + + const counted = document.createElement('a') + counted.href = 'https://maps.google.com/x' + document.body.appendChild(counted) + counted.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toMatch(/^\/b\.txt\?/) + }) +}) + +describe('the source keeps its promises', () => { + const component = read('src', 'outbound-clicks.tsx') + + it('sends no cookies', () => { + expect(component).toContain("credentials: 'omit'") + }) + + it('never touches the visitor’s link', () => { + expect(component).not.toMatch(/\.href\s*=|preventDefault|window\.open|location\.assign|location\.replace/) + }) + + it('names no third-party host anywhere in the beacon code', () => { + for (const source of [component, read('src', 'outbound.ts')]) { + expect(source).not.toMatch(/https?:\/\/(?!$)[a-z0-9.-]+\.(com|net|io|ai)\//i) + } + }) +}) diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 0000000..1b36402 --- /dev/null +++ b/src/outbound.ts @@ -0,0 +1,147 @@ +/** + * Clicks the server logs cannot see, counted without analytics. + * + * A tap on a phone number, an email link, a link off the site or any element + * the page has labelled sends one GET for a tiny static file, with the click + * described in its query string. CloudFront's access log already records every + * request, query string included, so the log IS the counter — no cookie, no + * identifier, no third party, no server code. Read it with + * ~/claude/projects/iSM/scripts/outbound_clicks.py. + * + * The visitor's link is never touched: no redirect, no rewritten href, no + * preventDefault. The beacon rides alongside the click. + * + * Internal links are deliberately silent — a click that ends in a request to + * our own server is already in the same access log as a page view. + * + * WHAT MAY LEAVE THE PAGE: an event name, the current path, a hostname, and + * labels the site's own source code wrote. Never a phone number, an email + * address, a link's path or query string, form input, or anything a visitor + * typed. `BEACON_KEYS` is the whole vocabulary and outbound.test.ts fails if + * this file starts emitting a key that is not on it. + */ + +/** Default path of the static file the beacon requests. */ +export const OUTBOUND_BEACON_PATH = '/out.txt' + +export type OutboundKind = 'tel' | 'mailto' | 'out' | 'amazon' | 'merchant' | 'track' + +/** Every query key the beacon may ever carry. Adding one is a privacy decision. */ +export const BEACON_KEYS = ['e', 'p', 'n', 'h', 'i', 'm', 't'] as const + +export interface OutboundClick { + /** `e` — what happened. */ + kind: OutboundKind + /** `p` — pathname of the page the click happened on. */ + page: string + /** `n` — the site's own label for this element (`data-track`), or a product name. */ + label?: string + /** `h` — hostname of an outbound link. Hostname only: never the path or query. */ + host?: string + /** `i` — 1-based position of the element in its list, when the site says so. */ + position?: number + /** `m` — merchant name, for kind "merchant". */ + merchant?: string +} + +/** The part of an element this module reads; a DOM Element satisfies it. */ +export interface ElementLike { + getAttribute(name: string): string | null + closest(selector: string): ElementLike | null +} + +export interface ClickContext { + /** Pathname of the current page. */ + pathname: string + /** The page's own hostname. A link here is internal, not outbound. */ + hostname: string + /** Extra hostnames to treat as internal (a staging domain, a sibling property). */ + ignoreHosts?: readonly string[] +} + +const clip = (value: string, max: number) => value.trim().slice(0, max) + +const LABEL_MAX = 60 + +/** The site's own label for the clicked thing, from the nearest `data-track`. */ +function labelFor(element: ElementLike): string | undefined { + const labelled = element.closest('[data-track]') + const label = labelled?.getAttribute('data-track') + if (!label) return undefined + return clip(label, LABEL_MAX) || undefined +} + +/** Hostname of an absolute http(s) href, or null when it is not one we can read. */ +function hostOf(href: string): string | null { + if (!/^https?:\/\//i.test(href)) return null + try { + return new URL(href).hostname.toLowerCase() || null + } catch { + return null + } +} + +function isInternal(host: string, context: ClickContext): boolean { + const own = context.hostname.toLowerCase() + const bare = (h: string) => (h.startsWith('www.') ? h.slice(4) : h) + if (bare(host) === bare(own)) return true + return (context.ignoreHosts ?? []).some((ignored) => bare(host) === bare(ignored.toLowerCase())) +} + +/** + * Describes the click on `element`, or null when there is nothing to count. + * + * Order matters: a product link is a product link even though it is also + * outbound, and an element inside a link belongs to the link. + */ +export function describeClick(element: ElementLike, context: ClickContext): OutboundClick | null { + const page = clip(context.pathname, 120) || '/' + const anchor = element.closest('a[href]') + + if (anchor) { + const href = anchor.getAttribute('href') ?? '' + const label = labelFor(element) + + const merchant = anchor.getAttribute('data-merchant') + const isAmazon = anchor.getAttribute('data-amazon') !== null + if (isAmazon || merchant !== null) { + // adellion's original event, keys unchanged so outbound_clicks.py keeps reading it. + const position = Number.parseInt(anchor.getAttribute('data-position') ?? '', 10) + return { + kind: isAmazon ? 'amazon' : 'merchant', + page, + label: clip(anchor.getAttribute('data-product') ?? '', 80) || label, + position: Number.isFinite(position) ? position : 0, + ...(isAmazon || !merchant ? {} : { merchant: clip(merchant, 40) }), + } + } + + // A phone number and an email address are the message, not a label for it. + if (/^tel:/i.test(href)) return { kind: 'tel', page, label } + if (/^mailto:/i.test(href)) return { kind: 'mailto', page, label } + + const host = hostOf(href) + if (!host || isInternal(host, context)) return null + return { kind: 'out', page, host, label } + } + + // Not a link: a booking or chat opener, a store badge drawn as a button, a + // tool's "calculate". Only fires where the site asked for it by name. + const label = labelFor(element) + return label ? { kind: 'track', page, label } : null +} + +/** `t` makes every click its own URL, so neither the browser nor the edge answers one from cache. */ +export function beaconUrl( + click: OutboundClick, + now: number = Date.now(), + beaconPath: string = OUTBOUND_BEACON_PATH, +): string { + const params = new URLSearchParams({ e: click.kind, p: click.page }) + if (click.label) params.set('n', click.label) + if (click.host) params.set('h', click.host) + if (click.position !== undefined) params.set('i', String(click.position)) + if (click.merchant) params.set('m', click.merchant) + params.set('t', String(now)) + return `${beaconPath}?${params.toString()}` +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d473944 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // The beacon is a delegated document listener; its tests click real elements. + environment: 'jsdom', + environmentOptions: { jsdom: { url: 'https://example.com/contact' } }, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +})