diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 60959c927..ba4ed49ec 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -131,7 +131,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | | `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | -Note: GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, and Pulse have no proxy capability, so no privacy transforms are applied. +Note: GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, Pulse, and Statable have no proxy capability, so no privacy transforms are applied. ## Script Support diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 6f088c14c..1a2a176f8 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -286,6 +286,7 @@ These integrations serve their main loader from your domain, but some runtime re | [Google Tag Manager](/scripts/google-tag-manager) | GTM's core function is loading other scripts at runtime. Those runtime scripts bypass build-time rewriting. | | [Fathom](/scripts/fathom-analytics) | Fathom's bot detection rejects beacons from the server's IP, so Nuxt Scripts bundles the SDK but leaves beacons direct. | | [Pulse](/scripts/pulse-analytics) | Pulse identifies visitors from the connecting IP, so proxied beacons would merge everyone into one visitor. Nuxt Scripts bundles the tracker but leaves beacons direct. | +| [Statable](/scripts/statable-analytics) | Statable identifies visitors from the connecting IP and user agent, so proxied beacons would merge everyone into one visitor. Nuxt Scripts bundles the tracker but leaves beacons direct. | | [Segment](/scripts/segment) | SDK constructs API URLs dynamically, bypassing request interception. | | [Crisp](/scripts/crisp) | SDK loads secondary scripts and CSS at runtime from `client.crisp.chat`. | | [Mixpanel](/scripts/mixpanel-analytics) | No proxy integration yet. | diff --git a/docs/content/scripts/statable-analytics.md b/docs/content/scripts/statable-analytics.md new file mode 100644 index 000000000..a240cfde6 --- /dev/null +++ b/docs/content/scripts/statable-analytics.md @@ -0,0 +1,88 @@ +--- +title: Statable Analytics +description: Load the Statable tracker and record custom events. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/statable-analytics.ts + size: xs +--- + +[Statable](https://statable.com/) is privacy-first web analytics by Key Arg B.V., a Dutch company. The tracker sets no cookies and stores nothing on the visitor's device; the one thing it reads from `localStorage` is an opt-out flag. A visitor is counted on the server from a keyed one-way hash of the site, the address, the user agent and the date, and the raw inputs are not stored. Do Not Track and Global Privacy Control are honoured without configuration. The [script reference](https://statable.com/docs/developers/tracking-script/) lists every attribute this composable maps. + +::script-stats +:: + +::script-docs +:: + +## Proxying is not supported + +Statable derives visitor identity on its server from the connecting IP address and user agent. Beacons routed through your Nuxt server would all arrive from one address, so every visitor on the same browser would collapse into a single identity. + +Nuxt Scripts therefore bundles the tracker and serves it from your origin, while its beacons go straight to the Statable API. The composable pins the site id and the API endpoint on the script tag, because the tracker would otherwise read both from the URL it was served from. There is no identifier in the browser for a first-party proxy to shield, so nothing is given up by leaving the beacons direct. + +## Serving through your own domain + +If you already proxy Statable through your own domain, `host` moves both the script and its beacons there. `trackingApi` overrides the endpoint on its own. + +```ts +useScriptStatableAnalytics({ + siteId: 'YOUR_SITE_ID', + host: 'https://stats.example.com', +}) +``` + +## Custom events + +The tracker exposes one function, `t(name, props)`. Use the composable's `proxy` object for it: a call made before the script has loaded is held and replayed once the tracker is in. Property values can be strings, numbers or booleans. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptStatableAnalytics() +function trackSignup() { + proxy.t('Sign Up', { plan: 'pro' }) +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptStatableAnalytics() +onLoaded(({ t }) => { + t('Purchase', { plan: 'annual', amount: 99 }) +}) +``` + +:: + +Outbound link clicks, file downloads and elements with a `data-statable-event` attribute fire on click or submit without any code. Each needs its site module enabled under Site settings → Tracking Code. Pageviews are always on; the rest can be switched off, and the Nano preset ships pageviews and SPA navigation only. See the [JavaScript API](https://statable.com/docs/developers/javascript-api/). + +## Sticky properties + +`props` attaches custom properties to every event from the page load, which is handy for a cohort, an environment or an experiment tag. They render as `data-statable-*` attributes on the script tag. + +```ts +useScriptStatableAnalytics({ + siteId: 'YOUR_SITE_ID', + props: { env: 'production', cohort: 'beta' }, +}) +``` + +## Page views in a single-page app + +The tracker hooks `pushState` and `replaceState` itself, so client-side navigation in Nuxt is counted as a page view without extra configuration. It also sends engagement time and scroll depth when the visitor leaves the page, if the site enables the engagement module. + +::script-types +:: + +## Example + +The default trigger waits until Nuxt is ready: + +```vue [app.vue] + +``` diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 2d061b6b6..667d44657 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -11,6 +11,7 @@ export const LOGOS = { posthog: ``, fathomAnalytics: ``, pulseAnalytics: ``, + statableAnalytics: ``, matomoAnalytics: ``, rybbitAnalytics: { light: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index e601a9c63..7bc27f8f0 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1241,6 +1241,18 @@ "code": "type _AllForwardedKeysListed = AssertNever>" } ], + "statable-analytics": [ + { + "name": "StatableAnalyticsOptions", + "kind": "const", + "code": "export const StatableAnalyticsOptions = object({\n /**\n * The numeric Site ID from Site settings, Tracking Code, in Statable.\n * @see https://statable.com/docs/developers/tracking-script/\n */\n // minLength(1) rather than a bare string(): envDefaults resolves an unset siteId to '',\n // which would validate happily and render data-id=\"\", silently tracking nothing.\n // Failing loudly in dev is the point.\n siteId: pipe(string(), minLength(1)),\n /**\n * Origin the tracker script is served from. Only needed when you proxy\n * Statable through your own domain.\n * @default 'https://statable.com'\n */\n host: optional(string()),\n /**\n * Endpoint the tracker posts events to. Defaults to `/api/event` on `host`.\n * @see https://statable.com/docs/developers/tracking-script/#data-tracking-api-optional\n */\n trackingApi: optional(string()),\n /**\n * Custom properties attached to every event from the page load, rendered\n * as `data-statable-*` attributes on the script tag.\n * @see https://statable.com/docs/developers/tracking-script/#data-statable-optional\n */\n props: optional(record(string(), string())),\n})" + }, + { + "name": "StatableAnalyticsApi", + "kind": "interface", + "code": "export interface StatableAnalyticsApi {\n /**\n * Records a custom event.\n * @param name Event name. Title Case by convention.\n * @param props Optional properties, merged into the event as `p.*`.\n * Strings, numbers and booleans index best in the dashboard.\n * @see https://statable.com/docs/developers/javascript-api/\n */\n t: (name: string, props?: Record) => void\n}" + } + ], "stripe": [ { "name": "StripeOptions", @@ -3085,6 +3097,33 @@ "defaultValue": "'g'" } ], + "StatableAnalyticsOptions": [ + { + "name": "siteId", + "type": "string", + "required": true, + "description": "The numeric Site ID from Site settings, Tracking Code, in Statable." + }, + { + "name": "host", + "type": "string", + "required": false, + "description": "Origin the tracker script is served from. Only needed when you proxy Statable through your own domain.", + "defaultValue": "'https://statable.com'" + }, + { + "name": "trackingApi", + "type": "string", + "required": false, + "description": "Endpoint the tracker posts events to. Defaults to `/api/event` on `host`." + }, + { + "name": "props", + "type": "Record", + "required": false, + "description": "Custom properties attached to every event from the page load, rendered as `data-statable-*` attributes on the script tag." + } + ], "ScriptGoogleMapsProps": [ { "name": "trigger", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index eb6978466..306ee1ba7 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -7,6 +7,7 @@ import type { NpmInput } from './runtime/registry/npm' import type { PlausibleAnalyticsInput } from './runtime/registry/plausible-analytics' import type { RybbitAnalyticsInput } from './runtime/registry/rybbit-analytics' import type { SegmentInput } from './runtime/registry/segment' +import type { StatableAnalyticsInput } from './runtime/registry/statable-analytics' import type { TikTokPixelInput } from './runtime/registry/tiktok-pixel' import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' import type { ProxyAutoInject, ProxyCapability, ProxyConfig, RegistryScript, RegistryScriptKey, RegistryScriptServerHandler, ResolvedProxyAutoInject, ScriptCapabilities } from './runtime/types' @@ -47,6 +48,7 @@ import { SegmentOptions, SnapTrPixelOptions, SpeedCurveOptions, + StatableAnalyticsOptions, StripeOptions, TawkToOptions, TikTokPixelOptions, @@ -145,6 +147,10 @@ export const registryMeta: RegistryScriptMeta[] = [ // No proxy: Pulse derives visitor identity from the connecting IP, so proxied // beacons collapse every visitor into one. Same family as Fathom (#720). m('pulseAnalytics', 'Pulse Analytics', 'analytics', 'useScriptPulseAnalytics', { bundle: true }, null), + // No proxy: Statable hashes the connecting IP and user agent into its + // visitor identity, so proxied beacons would collapse every visitor into + // one. Same family as Fathom (#720) and Pulse. + m('statableAnalytics', 'Statable Analytics', 'analytics', 'useScriptStatableAnalytics', { bundle: true }, null), // ad m('bingUet', 'Bing UET', 'ad', 'useScriptBingUet', { bundle: true, partytown: true }, null), m('metaPixel', 'Meta Pixel', 'ad', 'useScriptMetaPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), @@ -478,6 +484,23 @@ export async function registry(resolve?: (path: string) => Promise): Pro // script element and posts to `data-api` wherever it is served from. bundle: true, }), + def('statableAnalytics', { + schema: StatableAnalyticsOptions, + label: 'Statable Analytics', + category: 'analytics', + envDefaults: { siteId: '' }, + // Bundling needs no SDK patch: the composable pins `data-id` and + // `data-tracking-api` on the script element, so the bundled copy keeps + // reporting to the right site and endpoint. + bundle: { + resolve: (options?: StatableAnalyticsInput) => { + if (!options?.siteId) + return false + const host = (options.host || 'https://statable.com').replace(/\/+$/, '') + return `${host}/js/${options.siteId}/s.js` + }, + }, + }), // ad def('bingUet', { schema: BingUetOptions, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 05ce35732..4de41f7d6 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1374,3 +1374,31 @@ export const GravatarOptions = object({ */ rating: optional(string()), }) + +export const StatableAnalyticsOptions = object({ + /** + * The numeric Site ID from Site settings, Tracking Code, in Statable. + * @see https://statable.com/docs/developers/tracking-script/ + */ + // minLength(1) rather than a bare string(): envDefaults resolves an unset siteId to '', + // which would validate happily and render data-id="", silently tracking nothing. + // Failing loudly in dev is the point. + siteId: pipe(string(), minLength(1)), + /** + * Origin the tracker script is served from. Only needed when you proxy + * Statable through your own domain. + * @default 'https://statable.com' + */ + host: optional(string()), + /** + * Endpoint the tracker posts events to. Defaults to `/api/event` on `host`. + * @see https://statable.com/docs/developers/tracking-script/#data-tracking-api-optional + */ + trackingApi: optional(string()), + /** + * Custom properties attached to every event from the page load, rendered + * as `data-statable-*` attributes on the script tag. + * @see https://statable.com/docs/developers/tracking-script/#data-statable-optional + */ + props: optional(record(string(), string())), +}) diff --git a/packages/script/src/runtime/registry/statable-analytics.ts b/packages/script/src/runtime/registry/statable-analytics.ts new file mode 100644 index 000000000..26a820079 --- /dev/null +++ b/packages/script/src/runtime/registry/statable-analytics.ts @@ -0,0 +1,51 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { StatableAnalyticsOptions } from './schemas' + +export { StatableAnalyticsOptions } + +export type StatableAnalyticsInput = RegistryScriptInput + +export interface StatableAnalyticsApi { + /** + * Records a custom event. + * @param name Event name. Title Case by convention. + * @param props Optional properties, merged into the event as `p.*`. + * Strings, numbers and booleans index best in the dashboard. + * @see https://statable.com/docs/developers/javascript-api/ + */ + t: (name: string, props?: Record) => void +} + +declare global { + interface Window { + statable: StatableAnalyticsApi + } +} + +const DEFAULT_HOST = 'https://statable.com' + +export function useScriptStatableAnalytics(_options?: StatableAnalyticsInput) { + return useRegistryScript('statableAnalytics', (options) => { + const host = (options.host || DEFAULT_HOST).replace(/\/+$/, '') + return { + scriptInput: { + 'src': `${host}/js/${options.siteId}/s.js`, + // The tracker reads the site id from its own src path and posts to the + // origin it was served from. Bundling serves the file from the Nuxt + // origin, so both are pinned here and survive the rewrite. + 'data-id': options.siteId, + 'data-tracking-api': options.trackingApi || `${host}/api/event`, + ...Object.fromEntries( + Object.entries(options.props || {}).map(([key, value]) => [`data-statable-${key}`, value]), + ), + }, + schema: import.meta.dev ? StatableAnalyticsOptions : undefined, + scriptOptions: { + use() { + return window.statable + }, + }, + } + }, _options) +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 61227ccab..24793ba54 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -42,6 +42,7 @@ import type { RybbitAnalyticsInput } from './registry/rybbit-analytics' import type { SegmentInput } from './registry/segment' import type { SnapTrPixelInput } from './registry/snapchat-pixel' import type { SpeedCurveInput } from './registry/speedcurve' +import type { StatableAnalyticsInput } from './registry/statable-analytics' import type { StripeInput } from './registry/stripe' import type { TawkToInput } from './registry/tawk-to' import type { TikTokPixelInput } from './registry/tiktok-pixel' @@ -308,6 +309,7 @@ export interface ScriptRegistry { rybbitAnalytics?: RybbitAnalyticsInput redditPixel?: RedditPixelInput segment?: SegmentInput + statableAnalytics?: StatableAnalyticsInput speedcurve?: SpeedCurveInput stripe?: StripeInput tawkTo?: TawkToInput @@ -335,7 +337,7 @@ export type BuiltInRegistryScriptKey | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'pulseAnalytics' | 'matomoAnalytics' - | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tawkTo' | 'tiktokPixel' + | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'statableAnalytics' | 'stripe' | 'tawkTo' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'speedcurve' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index bc9ecf78e..f20c2fed8 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -52,6 +52,10 @@ export const scriptMeta = { urls: ['https://js.ciphera.net/script.js'], trackedData: ['page-views', 'events', 'conversions', 'scrolls'], }, + statableAnalytics: { + urls: ['https://statable.com/js/3078194/s.js'], + trackedData: ['page-views', 'events', 'conversions', 'scrolls'], + }, databuddyAnalytics: { urls: ['https://cdn.databuddy.cc/databuddy.js'], trackedData: ['page-views', 'events'], diff --git a/packages/script/src/script-sizes.json b/packages/script/src/script-sizes.json index 52a3d57b2..29af1dfe9 100644 --- a/packages/script/src/script-sizes.json +++ b/packages/script/src/script-sizes.json @@ -528,6 +528,75 @@ } ] }, + "statableAnalytics": { + "totalTransferKb": 3, + "totalDecodedKb": 4.8, + "loadTimeMs": 54, + "collectsWebVitals": false, + "apis": { + "cookies": false, + "localStorage": true, + "sessionStorage": false, + "indexedDB": false, + "canvas": false, + "webgl": false, + "audioContext": false, + "userAgent": false, + "doNotTrack": true, + "hardwareConcurrency": false, + "deviceMemory": false, + "plugins": false, + "languages": false, + "screen": false, + "timezone": false, + "platform": false, + "vendor": false, + "connection": false, + "maxTouchPoints": false, + "devicePixelRatio": false, + "mediaDevices": false, + "getBattery": false, + "referrer": true, + "windowName": false, + "rtcPeerConnection": false, + "geolocation": false, + "serviceWorker": false, + "cacheApi": false, + "sendBeacon": false, + "fetch": true, + "xhr": false, + "websocket": false, + "mutationObserver": false, + "performanceObserver": false, + "intersectionObserver": false + }, + "cookies": [], + "network": { + "requestCount": 1, + "domains": [ + "statable.com" + ], + "outboundBytes": 0, + "inboundBytes": 3100, + "injectedElements": [] + }, + "performance": { + "taskDurationMs": 9, + "scriptDurationMs": 2, + "heapDeltaKb": 783 + }, + "scripts": [ + { + "url": "https://statable.com/js/3078194/s.js", + "transferKb": 3, + "decodedKb": 4.8, + "encoding": "zstd", + "durationMs": 54, + "initiatorType": "script", + "protocol": "unknown" + } + ] + }, "databuddyAnalytics": { "totalTransferKb": 10.8, "totalDecodedKb": 29.4, diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 3d576e743..919a5593c 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -37,6 +37,7 @@ export default defineNuxtConfig({ databuddyAnalytics: { clientId: 'demo-client-123' }, fathomAnalytics: { site: 'BRDEJWKJ' }, pulseAnalytics: { domain: 'example.com' }, + statableAnalytics: { siteId: '123456' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -89,6 +90,7 @@ export default defineNuxtConfig({ umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c', trigger: 'manual' }, fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, + statableAnalytics: { siteId: '123456', trigger: 'manual' }, cloudflareWebAnalytics: { token: 'ade278253a19413c9bd923b079870902', trigger: 'manual' }, matomoAnalytics: { matomoUrl: 'https://cdn.matomo.cloud', siteId: '1', trigger: 'manual' }, vercelAnalytics: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 47a2d822c..30ce9ec03 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -25,6 +25,7 @@ function getPlaygroundPath(script: any): string | null { 'ahrefs-analytics': '/third-parties/ahrefs-analytics/nuxt-scripts', 'rybbit-analytics': '/third-parties/rybbit-analytics', 'pulse-analytics': '/third-parties/pulse-analytics', + 'statable-analytics': '/third-parties/statable-analytics', 'databuddy-analytics': '/third-parties/databuddy-analytics', 'umami-analytics': '/third-parties/umami-analytics', 'segment': '/third-parties/segment', diff --git a/playground/pages/third-parties/statable-analytics.vue b/playground/pages/third-parties/statable-analytics.vue new file mode 100644 index 000000000..e4219a5e1 --- /dev/null +++ b/playground/pages/third-parties/statable-analytics.vue @@ -0,0 +1,111 @@ + + + diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index 947442e4b..ab68fc10c 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -665,6 +665,7 @@ describe('first-party privacy stripping', () => { // cloudflareWebAnalytics — auto-engagement only, no CTA buttons // fathomAnalytics — bundle/proxy disabled (Fathom bot-detection flags self-hosted/proxied traffic, see #720) // pulseAnalytics — bundle only, no proxy (server-side visitor identity needs the real client IP) + // statableAnalytics — bundle only, no proxy (server-side visitor identity needs the real client IP) ]) /** @@ -938,6 +939,7 @@ describe('first-party privacy stripping', () => { // fathomAnalytics — bundle/proxy disabled in registry (see #720), script loads directly from CDN // pulseAnalytics — bundle only, no proxy in registry; beacons go direct to pulse-api.ciphera.net + // statableAnalytics — bundle only, no proxy in registry; beacons go direct to statable.com/api/event it('intercom', async () => { const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('intercom', '/intercom-test') @@ -1002,6 +1004,7 @@ describe('first-party privacy stripping', () => { { name: 'databuddyAnalytics', path: '/databuddy' }, { name: 'fathomAnalytics', path: '/fathom' }, { name: 'pulseAnalytics', path: '/pulse' }, + { name: 'statableAnalytics', path: '/statable' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, @@ -1188,6 +1191,7 @@ describe('first-party privacy stripping', () => { { name: 'databuddyAnalytics', path: '/databuddy' }, { name: 'fathomAnalytics', path: '/fathom' }, { name: 'pulseAnalytics', path: '/pulse' }, + { name: 'statableAnalytics', path: '/statable' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 764a0bdf8..1a31691dc 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -25,6 +25,7 @@ export default defineNuxtConfig({ databuddyAnalytics: { clientId: 'demo-client-123' }, fathomAnalytics: { site: 'BRDEJWKJ' }, pulseAnalytics: { domain: 'example.com' }, + statableAnalytics: { siteId: '123456' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -71,6 +72,7 @@ export default defineNuxtConfig({ databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' }, fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, + statableAnalytics: { siteId: '123456', trigger: 'manual' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' }, intercom: { app_id: 'akg5rmxb', trigger: 'manual' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, diff --git a/test/fixtures/first-party/pages/index.vue b/test/fixtures/first-party/pages/index.vue index fd6569218..556e5e5c1 100644 --- a/test/fixtures/first-party/pages/index.vue +++ b/test/fixtures/first-party/pages/index.vue @@ -9,6 +9,7 @@
  • Cloudflare Web Analytics
  • Fathom Analytics
  • Pulse Analytics
  • +
  • Statable Analytics
  • Umami Analytics
  • PostHog
  • Rybbit Analytics
  • diff --git a/test/fixtures/first-party/pages/statable.vue b/test/fixtures/first-party/pages/statable.vue new file mode 100644 index 000000000..964457ef6 --- /dev/null +++ b/test/fixtures/first-party/pages/statable.vue @@ -0,0 +1,32 @@ + + + diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 6d1e8b8f7..4dfb4539b 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -43,6 +43,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 6cdd805d0..e287746bf 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -386,6 +386,13 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('does not return proxy config for statableAnalytics (server-side visitor identity needs the real client IP)', async () => { + // Statable hashes the connecting IP + user agent into its visitor identity, + // so beacons routed through the Nuxt server would merge every visitor into one. + const config = (await getProxyConfigs()).statableAnalytics + expect(config).toBeUndefined() + }) + it('returns proxy config for intercom', async () => { const config = (await getProxyConfigs()).intercom expect(config).toBeDefined() @@ -466,6 +473,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('ahrefsAnalytics') expect(configs).not.toHaveProperty('fathomAnalytics') expect(configs).not.toHaveProperty('pulseAnalytics') + expect(configs).not.toHaveProperty('statableAnalytics') expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') expect(configs).not.toHaveProperty('deskcrew') diff --git a/test/unit/statable-analytics.test.ts b/test/unit/statable-analytics.test.ts new file mode 100644 index 000000000..d499fc557 --- /dev/null +++ b/test/unit/statable-analytics.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useScriptStatableAnalytics } from '../../packages/script/src/runtime/registry/statable-analytics' + +const mocks = vi.hoisted(() => ({ + definition: undefined as any, + useRegistryScript: vi.fn(), +})) + +vi.mock('@unhead/vue', () => ({ useHead: vi.fn() })) + +vi.mock('../../packages/script/src/runtime/utils', () => ({ + useRegistryScript: mocks.useRegistryScript, +})) + +function setup(options: Record = { siteId: '3270462' }) { + mocks.useRegistryScript.mockImplementation((_key: string, factory: (o: any) => any) => { + mocks.definition = factory(options) + return { status: ref('awaitingLoad'), signal: new AbortController().signal, load: vi.fn() } + }) + useScriptStatableAnalytics(options as any) + return { + scriptInput: mocks.definition.scriptInput as Record, + use: mocks.definition.scriptOptions.use as () => { t: (...args: any[]) => void }, + } +} + +describe('statable analytics', () => { + beforeEach(() => { + vi.clearAllMocks() + delete (window as any).statable + }) + + it('builds the script src from the site id', () => { + const { scriptInput } = setup() + expect(scriptInput.src).toBe('https://statable.com/js/3270462/s.js') + }) + + it('pins the site id and the endpoint so bundling does not change them', () => { + // The tracker reads both from its own src URL. A bundled copy is served + // from the Nuxt origin, so the attributes have to carry them instead. + const { scriptInput } = setup() + expect(scriptInput['data-id']).toBe('3270462') + expect(scriptInput['data-tracking-api']).toBe('https://statable.com/api/event') + }) + + it('follows a custom host for both the script and the endpoint', () => { + const { scriptInput } = setup({ siteId: '3270462', host: 'https://stats.example.com/' }) + expect(scriptInput.src).toBe('https://stats.example.com/js/3270462/s.js') + expect(scriptInput['data-tracking-api']).toBe('https://stats.example.com/api/event') + }) + + it('lets trackingApi override the endpoint on its own', () => { + const { scriptInput } = setup({ siteId: '3270462', trackingApi: 'https://example.com/collect' }) + expect(scriptInput.src).toBe('https://statable.com/js/3270462/s.js') + expect(scriptInput['data-tracking-api']).toBe('https://example.com/collect') + }) + + it('renders sticky props as data-statable-* attributes', () => { + const { scriptInput } = setup({ siteId: '3270462', props: { env: 'production', cohort: 'beta' } }) + expect(scriptInput['data-statable-env']).toBe('production') + expect(scriptInput['data-statable-cohort']).toBe('beta') + }) + + it('adds no data-statable-* attributes without props', () => { + const { scriptInput } = setup() + expect(Object.keys(scriptInput).filter(key => key.startsWith('data-statable-'))).toEqual([]) + }) + + it('exposes the tracker global once loaded', () => { + const t = vi.fn() + ;(window as any).statable = { t } + const { use } = setup() + use().t('Sign Up', { plan: 'pro' }) + expect(t).toHaveBeenCalledWith('Sign Up', { plan: 'pro' }) + }) +}) diff --git a/test/unit/statable-bundle-resolve.test.ts b/test/unit/statable-bundle-resolve.test.ts new file mode 100644 index 000000000..6e797cd05 --- /dev/null +++ b/test/unit/statable-bundle-resolve.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { getBundleResolve, registry } from '../../packages/script/src/registry' +import { StatableAnalyticsOptions } from '../../packages/script/src/runtime/registry/schemas' + +async function getStatableResolve() { + const all = await registry() + const script = all.find(s => s.registryKey === 'statableAnalytics')! + const resolve = getBundleResolve(script) + if (!resolve) + throw new Error('statableAnalytics bundle.resolve missing') + return resolve +} + +describe('statableAnalytics bundle.resolve', () => { + it('returns false for a missing siteId instead of throwing', async () => { + const resolve = await getStatableResolve() + expect(resolve(undefined)).toBe(false) + expect(resolve({})).toBe(false) + }) + + it('builds the src from the siteId', async () => { + const resolve = await getStatableResolve() + expect(resolve({ siteId: '3270462' })).toBe('https://statable.com/js/3270462/s.js') + }) +}) + +describe('statableAnalytics dev validation', () => { + function validate(options: Record) { + return (StatableAnalyticsOptions as any)['~standard'].validate(options) as { issues?: unknown[] } + } + + it('rejects an empty siteId', () => { + const result = validate({ siteId: '' }) + expect(result.issues?.length).toBeGreaterThan(0) + }) + + it('accepts a non-empty siteId', () => { + const result = validate({ siteId: '3270462' }) + expect(result.issues).toBeUndefined() + }) +})