From 3ba0e82bceaed0abe8397b2bdfc57efd9bc9ae39 Mon Sep 17 00:00:00 2001 From: Key Arg Date: Wed, 23 Sep 2026 11:00:54 +0200 Subject: [PATCH 1/6] feat: Statable Analytics --- FIRST_PARTY.md | 2 +- docs/content/docs/1.guides/2.first-party.md | 1 + docs/content/scripts/statable-analytics.md | 88 ++++++++++++++ packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 39 ++++++ packages/script/src/registry.ts | 23 ++++ .../script/src/runtime/registry/schemas.ts | 25 ++++ .../runtime/registry/statable-analytics.ts | 51 ++++++++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 4 + packages/script/src/script-sizes.json | 69 +++++++++++ playground/nuxt.config.ts | 2 + playground/pages/index.vue | 1 + .../third-parties/statable-analytics.vue | 111 ++++++++++++++++++ test/e2e-dev/first-party.test.ts | 4 + test/fixtures/first-party/nuxt.config.ts | 2 + test/fixtures/first-party/pages/index.vue | 1 + test/fixtures/first-party/pages/statable.vue | 32 +++++ test/types/types.test-d.ts | 1 + test/unit/proxy-configs.test.ts | 8 ++ test/unit/statable-analytics.test.ts | 80 +++++++++++++ 21 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 docs/content/scripts/statable-analytics.md create mode 100644 packages/script/src/runtime/registry/statable-analytics.ts create mode 100644 playground/pages/third-parties/statable-analytics.vue create mode 100644 test/fixtures/first-party/pages/statable.vue create mode 100644 test/unit/statable-analytics.test.ts 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..4d36401ca --- /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 and file downloads are recorded on their own, and an element with a `data-statable-event` attribute fires on click or submit without any code. 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. Engagement time and scroll depth for a page are sent when the visitor leaves it. + +::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..6f0057f67 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 siteId: string(),\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..22ac96925 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) + throw new TypeError('statableAnalytics requires siteId') + 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..76a822225 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1374,3 +1374,28 @@ 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/ + */ + siteId: string(), + /** + * 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' }) + }) +}) From 5d430cbce72871e05e88480592a5289b7f8a99eb Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Thu, 24 Sep 2026 00:18:55 +1000 Subject: [PATCH 2/6] docs(statable): qualify auto-tracked events as module-gated --- docs/content/scripts/statable-analytics.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/scripts/statable-analytics.md b/docs/content/scripts/statable-analytics.md index 4d36401ca..a240cfde6 100644 --- a/docs/content/scripts/statable-analytics.md +++ b/docs/content/scripts/statable-analytics.md @@ -55,7 +55,7 @@ onLoaded(({ t }) => { :: -Outbound link clicks and file downloads are recorded on their own, and an element with a `data-statable-event` attribute fires on click or submit without any code. See the [JavaScript API](https://statable.com/docs/developers/javascript-api/). +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 @@ -70,7 +70,7 @@ useScriptStatableAnalytics({ ## 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. Engagement time and scroll depth for a page are sent when the visitor leaves it. +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 :: From 2e4ff5f52603b45bfbad3f287747b166ba7e0ef3 Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Thu, 24 Sep 2026 02:53:44 +1000 Subject: [PATCH 3/6] fix(statable): skip bundling on missing siteId and require non-empty --- packages/script/src/registry-types.json | 2 +- packages/script/src/registry.ts | 2 +- .../script/src/runtime/registry/schemas.ts | 5 ++- test/unit/statable-bundle-resolve.test.ts | 41 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 test/unit/statable-bundle-resolve.test.ts diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 6f0057f67..7bc27f8f0 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1245,7 +1245,7 @@ { "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 siteId: string(),\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})" + "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", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 22ac96925..306ee1ba7 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -495,7 +495,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro bundle: { resolve: (options?: StatableAnalyticsInput) => { if (!options?.siteId) - throw new TypeError('statableAnalytics requires siteId') + return false const host = (options.host || 'https://statable.com').replace(/\/+$/, '') return `${host}/js/${options.siteId}/s.js` }, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 76a822225..4de41f7d6 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1380,7 +1380,10 @@ export const StatableAnalyticsOptions = object({ * The numeric Site ID from Site settings, Tracking Code, in Statable. * @see https://statable.com/docs/developers/tracking-script/ */ - siteId: string(), + // 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. 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() + }) +}) From 748e478814a779fde64df3cb7c99896c5974796f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 28 Sep 2026 23:01:01 +1000 Subject: [PATCH 4/6] fix(statable): validate site ids and clarify tracker setup --- docs/content/scripts/statable-analytics.md | 23 +++++++++++-------- packages/script/src/registry-types.json | 4 ++-- packages/script/src/registry.ts | 2 +- .../script/src/runtime/registry/schemas.ts | 12 ++++------ .../third-parties/statable-analytics.vue | 21 ++++++----------- test/e2e-dev/first-party.test.ts | 6 +++++ test/unit/statable-bundle-resolve.test.ts | 10 ++++++++ 7 files changed, 44 insertions(+), 34 deletions(-) diff --git a/docs/content/scripts/statable-analytics.md b/docs/content/scripts/statable-analytics.md index a240cfde6..61d9fc5e5 100644 --- a/docs/content/scripts/statable-analytics.md +++ b/docs/content/scripts/statable-analytics.md @@ -8,7 +8,9 @@ links: 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. +[Statable](https://statable.com/) is cookie-free web analytics. It honours Do Not Track and Global Privacy Control. The [tracking script reference](https://statable.com/docs/developers/tracking-script/) lists its options. + +This integration needs Statable's standalone tracking script, available on paid plans. The Hobby plan uses a widget bundle and has no separate tracker. ::script-stats :: @@ -18,24 +20,25 @@ links: ## 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. +Statable uses the connecting IP address and user agent to count visitors. If your Nuxt server relays beacons, Statable sees the server's IP instead. -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. +You can bundle the tracker on your Nuxt origin. Beacons still go to the Statable API. The composable sets the Site ID and API endpoint on the script tag so bundling keeps both values. -## Serving through your own domain +## Custom host and API endpoint -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. +`host` selects where Nuxt Scripts fetches the tracker and sets the default beacon endpoint. With bundling, browsers load the tracker from your Nuxt origin. If the custom host relays beacons, set `trackingApi` to the Statable API so Statable sees each visitor's IP. ```ts useScriptStatableAnalytics({ siteId: 'YOUR_SITE_ID', host: 'https://stats.example.com', + trackingApi: 'https://statable.com/api/event', }) ``` ## 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. +Use `proxy.t(name, props)` to record an event. Calls made before the tracker loads run once it is ready. Properties can be strings, numbers or booleans. ::code-group @@ -55,11 +58,11 @@ onLoaded(({ t }) => { :: -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/). +Statable can also track outbound links, downloads and elements with `data-statable-event`. Enable each module in Statable's Site settings. 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. +`props` adds properties to every event. Nuxt Scripts renders them as `data-statable-*` attributes. ```ts useScriptStatableAnalytics({ @@ -68,9 +71,9 @@ useScriptStatableAnalytics({ }) ``` -## Page views in a single-page app +## Page views in Nuxt -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. +The tracker counts client-side navigation without extra setup. Enable Statable's engagement module to record time and scroll depth. ::script-types :: diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 7bc27f8f0..e4d002476 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1245,7 +1245,7 @@ { "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})" + "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 // The tracker parses the ID as a number. Reject empty and malformed IDs in dev.\n siteId: pipe(string(), regex(/^\\d+$/)),\n /**\n * Origin used to fetch the tracker and set the default event endpoint.\n * Bundling still serves the script from your Nuxt origin.\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", @@ -3108,7 +3108,7 @@ "name": "host", "type": "string", "required": false, - "description": "Origin the tracker script is served from. Only needed when you proxy Statable through your own domain.", + "description": "Origin used to fetch the tracker and set the default event endpoint. Bundling still serves the script from your Nuxt origin.", "defaultValue": "'https://statable.com'" }, { diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 306ee1ba7..6c5044bc2 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -494,7 +494,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro // reporting to the right site and endpoint. bundle: { resolve: (options?: StatableAnalyticsInput) => { - if (!options?.siteId) + if (!options?.siteId || !/^\d+$/.test(options.siteId)) return false const host = (options.host || 'https://statable.com').replace(/\/+$/, '') return `${host}/js/${options.siteId}/s.js` diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 4de41f7d6..937b178b6 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1,4 +1,4 @@ -import { any, array, boolean, custom, function_, literal, maxValue, minLength, minValue, number, object, optional, pipe, record, string, union } from 'valibot' +import { any, array, boolean, custom, function_, literal, maxValue, minLength, minValue, number, object, optional, pipe, record, regex, string, union } from 'valibot' // Shared GCMv2 consent category value. const consentCategoryValue = union([literal('granted'), literal('denied')]) @@ -1380,13 +1380,11 @@ 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)), + // The tracker parses the ID as a number. Reject empty and malformed IDs in dev. + siteId: pipe(string(), regex(/^\d+$/)), /** - * Origin the tracker script is served from. Only needed when you proxy - * Statable through your own domain. + * Origin used to fetch the tracker and set the default event endpoint. + * Bundling still serves the script from your Nuxt origin. * @default 'https://statable.com' */ host: optional(string()), diff --git a/playground/pages/third-parties/statable-analytics.vue b/playground/pages/third-parties/statable-analytics.vue index e4219a5e1..7e6eea855 100644 --- a/playground/pages/third-parties/statable-analytics.vue +++ b/playground/pages/third-parties/statable-analytics.vue @@ -5,9 +5,7 @@ useHead({ title: 'Statable Analytics', }) -// `siteId` must be a site registered in Statable. 123456 is a placeholder: -// the tracker loads and every call fires, but the API rejects the events -// until you replace it with your own Site ID. +// Replace this placeholder with a paid-plan Statable Site ID. const { status, proxy } = useScriptStatableAnalytics({ siteId: '123456', scriptOptions: { @@ -15,8 +13,7 @@ const { status, proxy } = useScriptStatableAnalytics({ }, }) -// Fired during setup, before the tracker can have loaded: the proxy holds the -// call and replays it once the script is in. Watch the network tab for the POST. +// The proxy queues this call until the tracker loads. proxy.t('Mount', { fired_at: 'component_setup' }) const clicks = ref(0) @@ -34,15 +31,15 @@ function trackClick() { Statable Analytics

    - Cookieless analytics with server-side visitor identity. Bundled, never proxied. + Cookie-free analytics. The script can be bundled; beacons go to Statable.

    @@ -69,8 +66,7 @@ function trackClick() {

    - The button calls proxy.t() without checking status. A call made before - the tracker has loaded is queued and replayed once it has. + proxy.t() queues calls until the tracker loads.

    @@ -99,10 +95,7 @@ function trackClick() {

    Track Events

    -
    // One function: a name and optional properties
    -proxy.t('Sign Up', { plan: 'pro' })
    -
    -// Strings, numbers and booleans all work as property values
    +          
    proxy.t('Sign Up', { plan: 'pro' })
     proxy.t('Purchase', { plan: 'annual', amount: 99 })
    diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index ab68fc10c..cdfe601cc 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -1238,6 +1238,12 @@ describe('first-party privacy stripping', () => { .catch(() => false) expect(loaded, `${name}: Script never reached "loaded" status`).toBe(true) + if (name === 'statableAnalytics') { + const script = page.locator('script[data-id="123456"][data-tracking-api="https://statable.com/api/event"]') + expect(await script.count()).toBe(1) + expect(await script.getAttribute('src')).toContain('/_scripts/assets/') + } + // Click all buttons to trigger SDK interactions and generate proxy requests const buttons = await page.$$('button') for (const btn of buttons) { diff --git a/test/unit/statable-bundle-resolve.test.ts b/test/unit/statable-bundle-resolve.test.ts index 6e797cd05..a694e9ef1 100644 --- a/test/unit/statable-bundle-resolve.test.ts +++ b/test/unit/statable-bundle-resolve.test.ts @@ -22,6 +22,11 @@ describe('statableAnalytics bundle.resolve', () => { const resolve = await getStatableResolve() expect(resolve({ siteId: '3270462' })).toBe('https://statable.com/js/3270462/s.js') }) + + it.each([' ', 'abc', '3270462/other'])('skips bundling for an invalid siteId: %s', async (siteId) => { + const resolve = await getStatableResolve() + expect(resolve({ siteId })).toBe(false) + }) }) describe('statableAnalytics dev validation', () => { @@ -38,4 +43,9 @@ describe('statableAnalytics dev validation', () => { const result = validate({ siteId: '3270462' }) expect(result.issues).toBeUndefined() }) + + it.each([' ', 'abc', '3270462abc', '3270462/other', ' 3270462 '])('rejects a non-numeric siteId: %s', (siteId) => { + const result = validate({ siteId }) + expect(result.issues?.length).toBeGreaterThan(0) + }) }) From f7018c8def45a21e35f376e030216b01419c364c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 28 Sep 2026 23:02:25 +1000 Subject: [PATCH 5/6] docs(statable): trim repeated tracker comments --- packages/script/src/registry-types.json | 2 +- packages/script/src/runtime/registry/schemas.ts | 2 +- playground/pages/third-parties/statable-analytics.vue | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index e4d002476..738653c25 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1245,7 +1245,7 @@ { "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 // The tracker parses the ID as a number. Reject empty and malformed IDs in dev.\n siteId: pipe(string(), regex(/^\\d+$/)),\n /**\n * Origin used to fetch the tracker and set the default event endpoint.\n * Bundling still serves the script from your Nuxt origin.\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})" + "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 // The tracker parses the site ID with parseInt.\n siteId: pipe(string(), regex(/^\\d+$/)),\n /**\n * Origin used to fetch the tracker and set the default event endpoint.\n * Bundling still serves the script from your Nuxt origin.\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", diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 937b178b6..53ad7189b 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1380,7 +1380,7 @@ export const StatableAnalyticsOptions = object({ * The numeric Site ID from Site settings, Tracking Code, in Statable. * @see https://statable.com/docs/developers/tracking-script/ */ - // The tracker parses the ID as a number. Reject empty and malformed IDs in dev. + // The tracker parses the site ID with parseInt. siteId: pipe(string(), regex(/^\d+$/)), /** * Origin used to fetch the tracker and set the default event endpoint. diff --git a/playground/pages/third-parties/statable-analytics.vue b/playground/pages/third-parties/statable-analytics.vue index 7e6eea855..b1142ad66 100644 --- a/playground/pages/third-parties/statable-analytics.vue +++ b/playground/pages/third-parties/statable-analytics.vue @@ -13,7 +13,6 @@ const { status, proxy } = useScriptStatableAnalytics({ }, }) -// The proxy queues this call until the tracker loads. proxy.t('Mount', { fired_at: 'component_setup' }) const clicks = ref(0) From 3e3893d53b1356ca2220327c85eb06717555ba09 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 28 Sep 2026 23:24:52 +1000 Subject: [PATCH 6/6] fix(statable): verify bundle and align logo The shared browser fixture fetches unrelated scripts and cannot isolate Statable failures. Add a focused fixture and use the official mark in both themes. --- packages/script/src/registry-logos.ts | 5 +++- test/e2e/statable-analytics.test.ts | 29 +++++++++++++++++++ .../statable-analytics/nuxt.config.ts | 11 +++++++ .../statable-analytics/pages/index.vue | 11 +++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/e2e/statable-analytics.test.ts create mode 100644 test/fixtures/statable-analytics/nuxt.config.ts create mode 100644 test/fixtures/statable-analytics/pages/index.vue diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 667d44657..61f53b013 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -11,7 +11,10 @@ export const LOGOS = { posthog: ``, fathomAnalytics: ``, pulseAnalytics: ``, - statableAnalytics: ``, + statableAnalytics: { + light: ``, + dark: ``, + }, matomoAnalytics: ``, rybbitAnalytics: { light: ``, diff --git a/test/e2e/statable-analytics.test.ts b/test/e2e/statable-analytics.test.ts new file mode 100644 index 000000000..15149a99f --- /dev/null +++ b/test/e2e/statable-analytics.test.ts @@ -0,0 +1,29 @@ +import { createResolver } from '@nuxt/kit' +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' +import { setupFixture } from '../utils/setup-fixture' + +const { resolve } = createResolver(import.meta.url) + +describe('statable analytics bundled script', async () => { + await setupFixture({ + rootDir: resolve('../fixtures/statable-analytics'), + browser: true, + build: true, + }) + + it('keeps the Site ID and event endpoint on the bundled script', async () => { + const page = await (await getBrowser()).newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('#status:has-text("loaded")', { timeout: 10000 }) + + const script = page.locator('script[data-id="123456"][data-tracking-api="https://statable.com/api/event"]') + expect(await script.count()).toBe(1) + expect(await script.getAttribute('src')).toContain('/_scripts/assets/') + } + finally { + await page.close() + } + }) +}) diff --git a/test/fixtures/statable-analytics/nuxt.config.ts b/test/fixtures/statable-analytics/nuxt.config.ts new file mode 100644 index 000000000..680e591ba --- /dev/null +++ b/test/fixtures/statable-analytics/nuxt.config.ts @@ -0,0 +1,11 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + statableAnalytics: { siteId: '123456', trigger: 'manual' }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/statable-analytics/pages/index.vue b/test/fixtures/statable-analytics/pages/index.vue new file mode 100644 index 000000000..ee0b4dc59 --- /dev/null +++ b/test/fixtures/statable-analytics/pages/index.vue @@ -0,0 +1,11 @@ + + +