From adb0118ff3f98ef40b74bf8df8cda21df821b4cb Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:13:59 +1000 Subject: [PATCH 1/5] feat: Ahrefs Web Analytics Adds the `useScriptAhrefsAnalytics` composable for Ahrefs's privacy-first, cookie-less web analytics. Bundles `analytics.js` and proxies the beacon endpoint through `/_scripts/p/` (`PRIVACY_IP_ONLY`). SPA navigation is auto-tracked by the script's native `history.pushState` patch, so no `enableAutoSpaTracking` flag is required. --- FIRST_PARTY.md | 3 +- docs/content/docs/1.guides/2.first-party.md | 4 +- docs/content/scripts/ahrefs-analytics.md | 58 ++++++++ package.json | 2 +- packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 30 +++++ packages/script/src/registry.ts | 14 ++ .../src/runtime/registry/ahrefs-analytics.ts | 60 +++++++++ .../script/src/runtime/registry/schemas.ts | 9 ++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 5 + playground/pages/index.vue | 5 + .../ahrefs-analytics/default.vue | 27 ++++ .../ahrefs-analytics/nuxt-scripts.vue | 35 +++++ test/e2e/_ahrefs-analytics-suite.ts | 127 ++++++++++++++++++ test/e2e/ahrefs-analytics-cdn.test.ts | 14 ++ test/e2e/ahrefs-analytics.test.ts | 14 ++ .../ahrefs-analytics-cdn/nuxt.config.ts | 12 ++ .../ahrefs-analytics-cdn/pages/ahrefs.vue | 38 ++++++ .../ahrefs-analytics-cdn/tsconfig.json | 3 + test/fixtures/ahrefs-analytics/app.vue | 3 + test/fixtures/ahrefs-analytics/nuxt.config.ts | 15 +++ test/fixtures/ahrefs-analytics/package.json | 3 + .../ahrefs-analytics/pages/ahrefs.vue | 34 +++++ .../fixtures/ahrefs-analytics/pages/index.vue | 15 +++ test/fixtures/ahrefs-analytics/tsconfig.json | 3 + test/types/types.test-d.ts | 1 + test/unit/first-party.test.ts | 1 + test/unit/proxy-configs.test.ts | 13 +- 29 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 docs/content/scripts/ahrefs-analytics.md create mode 100644 packages/script/src/runtime/registry/ahrefs-analytics.ts create mode 100644 playground/pages/third-parties/ahrefs-analytics/default.vue create mode 100644 playground/pages/third-parties/ahrefs-analytics/nuxt-scripts.vue create mode 100644 test/e2e/_ahrefs-analytics-suite.ts create mode 100644 test/e2e/ahrefs-analytics-cdn.test.ts create mode 100644 test/e2e/ahrefs-analytics.test.ts create mode 100644 test/fixtures/ahrefs-analytics-cdn/nuxt.config.ts create mode 100644 test/fixtures/ahrefs-analytics-cdn/pages/ahrefs.vue create mode 100644 test/fixtures/ahrefs-analytics-cdn/tsconfig.json create mode 100644 test/fixtures/ahrefs-analytics/app.vue create mode 100644 test/fixtures/ahrefs-analytics/nuxt.config.ts create mode 100644 test/fixtures/ahrefs-analytics/package.json create mode 100644 test/fixtures/ahrefs-analytics/pages/ahrefs.vue create mode 100644 test/fixtures/ahrefs-analytics/pages/index.vue create mode 100644 test/fixtures/ahrefs-analytics/tsconfig.json diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index fd01a1a98..b67e482c0 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -113,7 +113,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_NONE` | all false | (not currently assigned to any script) | | `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit, LinkedIn | | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | -| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo | +| `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 | Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied. @@ -128,6 +128,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `snapchatPixel` | snapchatPixel | `PRIVACY_FULL` | Path A | | `redditPixel` | redditPixel | `PRIVACY_FULL` | Path A | | `linkedinInsight` | linkedinInsight | `PRIVACY_FULL` | Path A | +| `ahrefsAnalytics` | ahrefsAnalytics | `PRIVACY_IP_ONLY` | Path A | | `clarity` | clarity | `PRIVACY_HEATMAP` | Path A | | `hotjar` | hotjar | `PRIVACY_HEATMAP` | Path A | | `posthog` | posthog | `PRIVACY_IP_ONLY` | **Path B** (npm-only) + autoInject | diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index e38044c5e..4aef34365 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -63,7 +63,7 @@ Every proxied script defaults to a privacy tier based on what level of anonymisa |------|-------------------|---------| | **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel, LinkedIn Insight Tag | | **Heatmap-safe** | IP, language, hardware fingerprints (preserves screen and user agent for session replay) | Google Analytics, Microsoft Clarity, Hotjar | -| **IP only** | IP addresses anonymised to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense | +| **IP only** | IP addresses anonymised to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Ahrefs Web Analytics, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense | Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of tier. @@ -323,7 +323,7 @@ These scripts are downloaded at build time, served from your domain, and have th | Category | Scripts | |----------|---------| -| **Analytics** | [Google Analytics](/scripts/google-analytics), [Plausible](/scripts/plausible-analytics), [Cloudflare Web Analytics](/scripts/cloudflare-web-analytics), [Umami](/scripts/umami-analytics), [Fathom](/scripts/fathom-analytics), [Rybbit](/scripts/rybbit-analytics), [Databuddy](/scripts/databuddy-analytics), [Vercel Analytics](/scripts/vercel-analytics), [Microsoft Clarity](/scripts/clarity), [Hotjar](/scripts/hotjar) | +| **Analytics** | [Google Analytics](/scripts/google-analytics), [Plausible](/scripts/plausible-analytics), [Cloudflare Web Analytics](/scripts/cloudflare-web-analytics), [Umami](/scripts/umami-analytics), [Fathom](/scripts/fathom-analytics), [Rybbit](/scripts/rybbit-analytics), [Databuddy](/scripts/databuddy-analytics), [Ahrefs Web Analytics](/scripts/ahrefs-analytics), [Vercel Analytics](/scripts/vercel-analytics), [Microsoft Clarity](/scripts/clarity), [Hotjar](/scripts/hotjar) | | **Ad Pixels** | [Meta Pixel](/scripts/meta-pixel), [TikTok Pixel](/scripts/tiktok-pixel), [X Pixel](/scripts/x-pixel), [Snapchat Pixel](/scripts/snapchat-pixel), [Reddit Pixel](/scripts/reddit-pixel), [LinkedIn Insight Tag](/scripts/linkedin-insight), [Google AdSense](/scripts/google-adsense) | | **Video** | [YouTube Player](/scripts/youtube-player), [Vimeo Player](/scripts/vimeo-player) | | **Utility** | [Intercom](/scripts/intercom), [Gravatar](/scripts/gravatar) | diff --git a/docs/content/scripts/ahrefs-analytics.md b/docs/content/scripts/ahrefs-analytics.md new file mode 100644 index 000000000..746204c10 --- /dev/null +++ b/docs/content/scripts/ahrefs-analytics.md @@ -0,0 +1,58 @@ +--- +title: Ahrefs Web Analytics +description: Use Ahrefs Web Analytics in your Nuxt app to track page views and custom events with a privacy-first, cookie-less analytics script. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/ahrefs-analytics.ts + size: xs +--- + +[Ahrefs Web Analytics](https://ahrefs.com/web-analytics) is a privacy-first, cookie-less web analytics service from [Ahrefs](https://ahrefs.com) that tracks page views and custom events without sharing visitor data with third parties. + +Nuxt Scripts provides a registry script composable [`useScriptAhrefsAnalytics()`{lang="ts"}](/scripts/ahrefs-analytics) to integrate it in your Nuxt app. + +::script-stats +:: + +::script-docs +:: + +::script-types +:: + +## Examples + +### Basic usage + +The script auto-fires a page-view on initial load and tracks SPA navigations natively by patching `history.pushState` and listening for `popstate`. Provide your project key and the integration is done. + +```vue + +``` + +### Tracking custom events + +Use `proxy.AhrefsAnalytics.sendEvent()`{lang="ts"} to send custom events from your components. + +```vue + +``` + +### SPA navigation tracking + +Ahrefs Analytics tracks single-page-app navigations automatically: the loaded `analytics.js` patches `history.pushState` and listens for `popstate`, firing a fresh page-view whenever the URL changes. No extra configuration is needed for Nuxt route changes. diff --git a/package.json b/package.json index 095fb6741..87722e9f2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 233c3a68c..8e28d8d03 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -4,6 +4,7 @@ import type { BuiltInRegistryScriptKey } from './runtime/types' type LogoValue = string | { light: string, dark: string } export const LOGOS = { + ahrefsAnalytics: ``, plausibleAnalytics: ``, cloudflareWebAnalytics: ``, vercelAnalytics: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 493e5a33a..0013a48e7 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1,5 +1,27 @@ { "types": { + "ahrefs-analytics": [ + { + "name": "AhrefsAnalyticsOptions", + "kind": "const", + "code": "export const AhrefsAnalyticsOptions = object({\n /**\n * Your Ahrefs Web Analytics project key. Set as the `data-key` attribute\n * on the loaded `analytics.js` script tag.\n * @see https://ahrefs.com/web-analytics\n */\n key: pipe(string(), minLength(1)),\n})" + }, + { + "name": "AhrefsAnalyticsSendEventOptions", + "kind": "interface", + "code": "export interface AhrefsAnalyticsSendEventOptions {\n /** Custom dimensions sent under `props`. */\n props?: Record\n /** Arbitrary metadata sent under `meta`. */\n meta?: Record\n /** Optional callback invoked once the beacon request completes. */\n callback?: (result?: { status?: number }) => void\n}" + }, + { + "name": "AhrefsAnalyticsInstance", + "kind": "interface", + "code": "export interface AhrefsAnalyticsInstance {\n /**\n * Manually send an event to Ahrefs Analytics. The script auto-fires\n * page-view events on initial load and on `history.pushState`/`popstate`,\n * so SPA navigations are tracked without calling this.\n */\n sendEvent: (name: string, options?: AhrefsAnalyticsSendEventOptions) => void\n}" + }, + { + "name": "AhrefsAnalyticsApi", + "kind": "interface", + "code": "export interface AhrefsAnalyticsApi {\n AhrefsAnalytics: AhrefsAnalyticsInstance\n}" + } + ], "bing-uet": [ { "name": "BingUetOptions", @@ -1115,6 +1137,14 @@ ] }, "schemaFields": { + "AhrefsAnalyticsOptions": [ + { + "name": "key", + "type": "string", + "required": true, + "description": "Your Ahrefs Web Analytics project key. Set as the `data-key` attribute on the loaded `analytics.js` script tag." + } + ], "BlueskyEmbedOptions": [ { "name": "postUrl", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 9117c4308..6136507be 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -13,6 +13,7 @@ import type { ProxyAutoInject, ProxyCapability, ProxyConfig, RegistryScript, Reg import { joinURL, withBase, withQuery } from 'ufo' import { LOGOS } from './registry-logos' import { + AhrefsAnalyticsOptions, BingUetOptions, BlueskyEmbedOptions, ClarityOptions, @@ -132,6 +133,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('clarity', 'Clarity', 'analytics', 'useScriptClarity', { bundle: true, proxy: true, partytown: true }, PRIVACY_HEATMAP), m('vercelAnalytics', 'Vercel Analytics', 'analytics', 'useScriptVercelAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), m('mixpanelAnalytics', 'Mixpanel', 'analytics', 'useScriptMixpanelAnalytics', { bundle: true, partytown: true }, null), + m('ahrefsAnalytics', 'Ahrefs Web Analytics', 'analytics', 'useScriptAhrefsAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), // 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), @@ -274,6 +276,18 @@ export async function registry(resolve?: (path: string) => Promise): Pro return Promise.all([ // analytics + def('ahrefsAnalytics', { + schema: AhrefsAnalyticsOptions, + label: 'Ahrefs Web Analytics', + src: 'https://analytics.ahrefs.com/analytics.js', + category: 'analytics', + envDefaults: { key: '' }, + bundle: true, + proxy: { + domains: ['analytics.ahrefs.com'], + privacy: PRIVACY_IP_ONLY, + }, + }), def('plausibleAnalytics', { label: 'Plausible Analytics', category: 'analytics', diff --git a/packages/script/src/runtime/registry/ahrefs-analytics.ts b/packages/script/src/runtime/registry/ahrefs-analytics.ts new file mode 100644 index 000000000..784449428 --- /dev/null +++ b/packages/script/src/runtime/registry/ahrefs-analytics.ts @@ -0,0 +1,60 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { AhrefsAnalyticsOptions } from './schemas' + +export { AhrefsAnalyticsOptions } + +export type AhrefsAnalyticsInput = RegistryScriptInput + +export interface AhrefsAnalyticsSendEventOptions { + /** Custom dimensions sent under `props`. */ + props?: Record + /** Arbitrary metadata sent under `meta`. */ + meta?: Record + /** Optional callback invoked once the beacon request completes. */ + callback?: (result?: { status?: number }) => void +} + +export interface AhrefsAnalyticsInstance { + /** + * Manually send an event to Ahrefs Analytics. The script auto-fires + * page-view events on initial load and on `history.pushState`/`popstate`, + * so SPA navigations are tracked without calling this. + */ + sendEvent: (name: string, options?: AhrefsAnalyticsSendEventOptions) => void +} + +export interface AhrefsAnalyticsApi { + AhrefsAnalytics: AhrefsAnalyticsInstance +} + +declare global { + interface Window extends AhrefsAnalyticsApi {} +} + +/** + * Load Ahrefs Web Analytics and expose its `sendEvent` API. + * + * The script attaches `window.AhrefsAnalytics` once loaded, fires an initial + * page-view, and tracks SPA navigations natively by patching + * `history.pushState` and listening to `popstate`. + * + * @see https://ahrefs.com/web-analytics + */ +export function useScriptAhrefsAnalytics( + _options?: AhrefsAnalyticsInput, +): UseScriptContext { + return useRegistryScript('ahrefsAnalytics', options => ({ + scriptInput: { + 'src': 'https://analytics.ahrefs.com/analytics.js', + 'data-key': options.key, + 'crossorigin': false, + }, + schema: import.meta.dev ? AhrefsAnalyticsOptions : undefined, + scriptOptions: { + use() { + return { AhrefsAnalytics: window.AhrefsAnalytics } + }, + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 1b1c250ab..24e5f2e16 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -16,6 +16,15 @@ const gcmConsentState = object({ region: optional(array(string())), }) +export const AhrefsAnalyticsOptions = object({ + /** + * Your Ahrefs Web Analytics project key. Set as the `data-key` attribute + * on the loaded `analytics.js` script tag. + * @see https://ahrefs.com/web-analytics + */ + key: pipe(string(), minLength(1)), +}) + export const BlueskyEmbedOptions = object({ /** * The Bluesky post URL to embed. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 4f705b59e..a0a93c6f2 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -5,6 +5,7 @@ import type { import type { Import } from 'unimport' import type { InferInput, ObjectEntries, ObjectSchema, UnionSchema, ValiError } from 'valibot' import type { ComputedRef, Ref } from 'vue' +import type { AhrefsAnalyticsInput } from './registry/ahrefs-analytics' import type { BingUetInput } from './registry/bing-uet' import type { BlueskyEmbedInput } from './registry/bluesky-embed' import type { ClarityInput } from './registry/clarity' @@ -224,6 +225,7 @@ export interface NuxtDevToolsScriptInstance { } export interface ScriptRegistry { + ahrefsAnalytics?: AhrefsAnalyticsInput bingUet?: BingUetInput blueskyEmbed?: BlueskyEmbedInput carbonAds?: true @@ -271,7 +273,7 @@ export interface ScriptRegistry { * Use this to type-check records that must enumerate all built-in scripts (logos, meta, etc.). */ export type BuiltInRegistryScriptKey - = | 'bingUet' | 'blueskyEmbed' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' + = | 'ahrefsAnalytics' | 'bingUet' | 'blueskyEmbed' | 'carbonAds' | 'crisp' | 'clarity' | 'cloudflareWebAnalytics' | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index c2faa595f..c8d87605a 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -18,6 +18,11 @@ export interface ScriptMeta { export const scriptMeta = { // Analytics + ahrefsAnalytics: { + urls: ['https://analytics.ahrefs.com/analytics.js'], + trackedData: ['page-views', 'events'], + testId: 'test-ahrefs-key', + }, plausibleAnalytics: { urls: ['https://plausible.io/js/script.js'], trackedData: ['page-views', 'events', 'conversions'], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index a0b83dc8b..1992ea7c9 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -22,6 +22,7 @@ function getPlaygroundPath(script: any): string | null { 'plausible-analytics': '/third-parties/plausible-analytics', 'posthog': '/third-parties/posthog/nuxt-scripts', 'matomo-analytics': '/third-parties/matomo-analytics/nuxt-scripts', + 'ahrefs-analytics': '/third-parties/ahrefs-analytics/nuxt-scripts', 'rybbit-analytics': '/third-parties/rybbit-analytics', 'databuddy-analytics': '/third-parties/databuddy-analytics', 'umami-analytics': '/third-parties/umami-analytics', @@ -272,6 +273,10 @@ const benchmark = [ name: 'LinkedIn Insight (Default)', path: '/third-parties/linkedin-insight/default', }, + { + name: 'Ahrefs Analytics (Default)', + path: '/third-parties/ahrefs-analytics/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/ahrefs-analytics/default.vue b/playground/pages/third-parties/ahrefs-analytics/default.vue new file mode 100644 index 000000000..a3ded1f31 --- /dev/null +++ b/playground/pages/third-parties/ahrefs-analytics/default.vue @@ -0,0 +1,27 @@ + + + diff --git a/playground/pages/third-parties/ahrefs-analytics/nuxt-scripts.vue b/playground/pages/third-parties/ahrefs-analytics/nuxt-scripts.vue new file mode 100644 index 000000000..8a8f10b0f --- /dev/null +++ b/playground/pages/third-parties/ahrefs-analytics/nuxt-scripts.vue @@ -0,0 +1,35 @@ + + + diff --git a/test/e2e/_ahrefs-analytics-suite.ts b/test/e2e/_ahrefs-analytics-suite.ts new file mode 100644 index 000000000..12f32bc42 --- /dev/null +++ b/test/e2e/_ahrefs-analytics-suite.ts @@ -0,0 +1,127 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { beforeAll, expect, it } from 'vitest' + +// Probe Ahrefs egress; behavior tests skip when network is unavailable so +// CI doesn't fail in offline sandboxes. Wiring tests (script tag in DOM, +// data-key set) run regardless. +const NETWORK_PROBE_TIMEOUT_MS = 5000 +async function probeAhrefsEgress(): Promise { + return fetch('https://analytics.ahrefs.com/analytics.js', { + method: 'HEAD', + signal: AbortSignal.timeout(NETWORK_PROBE_TIMEOUT_MS), + }).then(() => true, () => false) +} + +interface CapturedRequest { + method: string + url: string + postData: string | null +} + +async function newCapturePage() { + const browser = await getBrowser() + const page = await browser.newPage() + const requests: CapturedRequest[] = [] + page.on('request', (req) => { + requests.push({ method: req.method(), url: req.url(), postData: req.postData() ?? null }) + }) + return { page, requests } +} + +async function waitFor( + predicate: () => boolean, + { timeoutMs = 10000, intervalMs = 50, message = 'condition' }: { timeoutMs?: number, intervalMs?: number, message?: string } = {}, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) + return + await new Promise(r => setTimeout(r, intervalMs)) + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${message}`) +} + +interface SuiteOptions { + bundled: boolean +} + +export function defineAhrefsAnalyticsSuite(opts: SuiteOptions) { + let networkAvailable = false + beforeAll(async () => { + networkAvailable = await probeAhrefsEgress() + }) + + it('script tag points at the expected origin with data-key set', async () => { + // Wiring assertion: the + + diff --git a/test/fixtures/ahrefs-analytics-cdn/tsconfig.json b/test/fixtures/ahrefs-analytics-cdn/tsconfig.json new file mode 100644 index 000000000..4b34df157 --- /dev/null +++ b/test/fixtures/ahrefs-analytics-cdn/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/fixtures/ahrefs-analytics/app.vue b/test/fixtures/ahrefs-analytics/app.vue new file mode 100644 index 000000000..8f62b8bf9 --- /dev/null +++ b/test/fixtures/ahrefs-analytics/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/ahrefs-analytics/nuxt.config.ts b/test/fixtures/ahrefs-analytics/nuxt.config.ts new file mode 100644 index 000000000..65cc1dd2b --- /dev/null +++ b/test/fixtures/ahrefs-analytics/nuxt.config.ts @@ -0,0 +1,15 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Bundled fixture (default `bundle: true`, so the script is served from +// /_scripts/assets/ after AST rewrite). The CDN fixture extends this one +// and overrides only the bundle setting + the page composable calls. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + defaultScriptOptions: { trigger: 'onNuxtReady' }, + registry: { + ahrefsAnalytics: { key: 'test-ahrefs-key' }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/ahrefs-analytics/package.json b/test/fixtures/ahrefs-analytics/package.json new file mode 100644 index 000000000..b9826b340 --- /dev/null +++ b/test/fixtures/ahrefs-analytics/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/ahrefs-analytics/pages/ahrefs.vue b/test/fixtures/ahrefs-analytics/pages/ahrefs.vue new file mode 100644 index 000000000..5f5580702 --- /dev/null +++ b/test/fixtures/ahrefs-analytics/pages/ahrefs.vue @@ -0,0 +1,34 @@ + + + diff --git a/test/fixtures/ahrefs-analytics/pages/index.vue b/test/fixtures/ahrefs-analytics/pages/index.vue new file mode 100644 index 000000000..b171dec8c --- /dev/null +++ b/test/fixtures/ahrefs-analytics/pages/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/ahrefs-analytics/tsconfig.json b/test/fixtures/ahrefs-analytics/tsconfig.json new file mode 100644 index 000000000..4b34df157 --- /dev/null +++ b/test/fixtures/ahrefs-analytics/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 1c0736048..06b50e7dc 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -11,6 +11,7 @@ describe('module options registry', () => { // Every built-in registry key must resolve to its specific type, not `any`. // NuxtConfigScriptRegistry is an interface (not an intersection), so explicit // properties inherited via `extends` always take priority over the index signature. + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() diff --git a/test/unit/first-party.test.ts b/test/unit/first-party.test.ts index 7097bbe3c..d975d9944 100644 --- a/test/unit/first-party.test.ts +++ b/test/unit/first-party.test.ts @@ -264,6 +264,7 @@ describe('first-party mode', () => { 'snapchatPixel', 'redditPixel', 'linkedinInsight', + 'ahrefsAnalytics', 'clarity', 'hotjar', ] diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index a65ceeac1..aa1b9cb75 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -393,6 +393,16 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('returns proxy config for ahrefsAnalytics (covers /analytics.js + /api/event beacon)', async () => { + const config = (await getProxyConfigs()).ahrefsAnalytics + expect(config).toBeDefined() + expect(config?.domains).toContain('analytics.ahrefs.com') + // Both the script (/analytics.js) and the beacon (/api/event, /api/error) + // are served from analytics.ahrefs.com, so a single domain entry covers + // both via the AST rewrite + runtime intercept. + expect(config?.privacy.ip).toBe(true) + }) + it('returns proxy config for vercelAnalytics', async () => { const config = (await getProxyConfigs()).vercelAnalytics expect(config).toBeDefined() @@ -425,6 +435,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('rybbitAnalytics') expect(configs).toHaveProperty('umamiAnalytics') expect(configs).toHaveProperty('databuddyAnalytics') + expect(configs).toHaveProperty('ahrefsAnalytics') expect(configs).not.toHaveProperty('fathomAnalytics') expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') @@ -435,7 +446,7 @@ describe('proxy configs', () => { it('all configs have valid structure', async () => { const configs = await getProxyConfigs() const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel', 'linkedinInsight'] - const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar'] + const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'ahrefsAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains') expect(Array.isArray(config.domains), `${key}.domains should be an array`).toBe(true) From 2c16aec169f2da577b33ea06ab34fbf31d2b98a4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 16:46:13 +1000 Subject: [PATCH 2/5] test(ahrefs): gate beacon assertions on AHREFS_TEST_KEY Probing analytics.ahrefs.com proved the script is reachable on CI but beacons silently drop because test-ahrefs-key isn't registered to the test origin. Match the Usercentrics pattern: only run behavioural assertions when AHREFS_TEST_KEY is set; wiring assertions still run unconditionally. --- test/e2e/_ahrefs-analytics-suite.ts | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/test/e2e/_ahrefs-analytics-suite.ts b/test/e2e/_ahrefs-analytics-suite.ts index 12f32bc42..943b312a0 100644 --- a/test/e2e/_ahrefs-analytics-suite.ts +++ b/test/e2e/_ahrefs-analytics-suite.ts @@ -1,16 +1,11 @@ import { getBrowser, url } from '@nuxt/test-utils/e2e' -import { beforeAll, expect, it } from 'vitest' +import { expect, it } from 'vitest' -// Probe Ahrefs egress; behavior tests skip when network is unavailable so -// CI doesn't fail in offline sandboxes. Wiring tests (script tag in DOM, +// Behavior tests require a real Ahrefs key registered to the test origin so +// /api/event accepts the beacon. Without it the script silently drops events, +// so CI gates these on AHREFS_TEST_KEY. Wiring tests (script tag in DOM, // data-key set) run regardless. -const NETWORK_PROBE_TIMEOUT_MS = 5000 -async function probeAhrefsEgress(): Promise { - return fetch('https://analytics.ahrefs.com/analytics.js', { - method: 'HEAD', - signal: AbortSignal.timeout(NETWORK_PROBE_TIMEOUT_MS), - }).then(() => true, () => false) -} +const hasRealKey = Boolean(process.env.AHREFS_TEST_KEY) interface CapturedRequest { method: string @@ -46,11 +41,6 @@ interface SuiteOptions { } export function defineAhrefsAnalyticsSuite(opts: SuiteOptions) { - let networkAvailable = false - beforeAll(async () => { - networkAvailable = await probeAhrefsEgress() - }) - it('script tag points at the expected origin with data-key set', async () => { // Wiring assertion: the +onLoaded(({ AhrefsAnalytics }) => { + AhrefsAnalytics.sendEvent('signup', { + props: { plan: 'pro' }, + }) +}) ``` -### Tracking custom events +:: + +## SPA navigation + +Ahrefs Analytics tracks single-page-app navigations natively: the loaded `analytics.js` patches `history.pushState` and listens for `popstate`, firing a fresh page-view whenever the URL changes. No extra configuration is needed for Nuxt route changes. -Use `proxy.AhrefsAnalytics.sendEvent()`{lang="ts"} to send custom events from your components. +::script-types +:: -```vue +## Example + +Loading Ahrefs Web Analytics through `app.vue` when Nuxt is ready. + +```vue [app.vue] ``` - -### SPA navigation tracking - -Ahrefs Analytics tracks single-page-app navigations automatically: the loaded `analytics.js` patches `history.pushState` and listens for `popstate`, firing a fresh page-view whenever the URL changes. No extra configuration is needed for Nuxt route changes.