diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index fd01a1a9..b67e482c 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 e38044c5..4aef3436 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 00000000..8c4eaa04 --- /dev/null +++ b/docs/content/scripts/ahrefs-analytics.md @@ -0,0 +1,70 @@ +--- +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. + +::script-stats +:: + +::script-docs +:: + +The composable comes with the following defaults: +- **Trigger: Client** Script will load when Nuxt is hydrating. + +You can access the `AhrefsAnalytics` object as a proxy directly or await the `$script` promise to access the object. It's recommended to use the proxy for any void functions. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptAhrefsAnalytics({ + key: 'your-project-key', +}) +function trackSignup() { + proxy.AhrefsAnalytics.sendEvent('signup', { + props: { plan: 'pro' }, + }) +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptAhrefsAnalytics({ + key: 'your-project-key', +}) +onLoaded(({ AhrefsAnalytics }) => { + AhrefsAnalytics.sendEvent('signup', { + props: { plan: 'pro' }, + }) +}) +``` + +:: + +## 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. + +::script-types +:: + +## Example + +Loading Ahrefs Web Analytics through `app.vue` when Nuxt is ready. + +```vue [app.vue] + +``` diff --git a/package.json b/package.json index 095fb674..87722e9f 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/plugins/rewrite-ast.ts b/packages/script/src/plugins/rewrite-ast.ts index 2e134eab..e99e461c 100644 --- a/packages/script/src/plugins/rewrite-ast.ts +++ b/packages/script/src/plugins/rewrite-ast.ts @@ -411,6 +411,29 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites } } + // SDK patch: replace-new-url-origin + // Matches `new URL().origin` and replaces with `self.location.origin + ""` + if (sdkPatches?.some(p => p.type === 'replace-new-url-origin') + && node.type === 'MemberExpression' + && !(node as any).computed) { + const obj = (node as any).object + const prop = (node as any).property + if (prop?.type === 'Identifier' && prop.name === 'origin' + && obj?.type === 'NewExpression' + && obj.callee?.type === 'Identifier' && obj.callee.name === 'URL' + && obj.arguments?.length >= 1) { + for (const patch of sdkPatches) { + if (patch.type !== 'replace-new-url-origin') + continue + const rewrite = rewrites.find(r => r.from === patch.fromDomain) + if (!rewrite) + continue + s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}(self.location.origin+"${rewrite.to}")`) + break + } + } + } + // new XMLHttpRequest / new Image / new x.XMLHttpRequest / new x.Image if (node.type === 'NewExpression' && !options?.skipApiRewrites) { const callee = (node as any).callee diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 233c3a68..8e28d8d0 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 493e5a33..0013a48e 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 9117c430..512a55f4 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,19 @@ 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, + sdkPatches: [{ type: 'replace-new-url-origin', fromDomain: 'analytics.ahrefs.com' }], + }, + }), 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 00000000..78444942 --- /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 1b1c250a..24e5f2e1 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 4f705b59..9cbcb42f 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' @@ -433,6 +435,13 @@ export type SdkPatch * the correct proxy path. */ | { type: 'replace-src-split', separator: string, fromDomain: string, appendPath?: string } + /** + * Replace `new URL().origin` with `self.location.origin + ""`. + * Used by SDKs that derive their API host as `new URL(currentScript.src).origin + "/api/..."`. + * When bundled, the script src origin is the Nuxt origin, so the derived endpoint + * lands on a 404 instead of the proxy. This patch redirects it through the proxy. + */ + | { type: 'replace-new-url-origin', fromDomain: string } /** * Partytown capability config. When present, the script can run in a diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index c2faa595..c8d87605 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 a0b83dc8..1992ea7c 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 00000000..a3ded1f3 --- /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 00000000..8a8f10b0 --- /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 00000000..db7aa170 --- /dev/null +++ b/test/e2e/_ahrefs-analytics-suite.ts @@ -0,0 +1,234 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +interface CapturedRequest { + method: string + url: string + postData: string | null + contentType: string | null +} + +// Stand-in for Ahrefs's analytics.js. The real script bails out on +// `localhost` and on `navigator.webdriver`, which makes deterministic +// assertions impossible under Playwright + the test fixture. The stubs below +// mirror the integration shape we care about (initial pageview POST + +// history.pushState patch) and post to the *exact* endpoint each mode +// resolves to in production: +// CDN mode -> https://analytics.ahrefs.com/api/event +// bundled mode -> /_scripts/p/analytics.ahrefs.com/api/event (the path +// the replace-new-url-origin sdkPatch produces from the +// real `new URL(currentScript.src).origin + "/api/event"`) +// Splitting the two stubs (rather than reusing currentScript.src origin) +// guarantees the bundled-mode test breaks if the AST patch ever stops being +// applied to the bundle output, which is the contract this suite guards. +function buildStubAnalyticsJs(endpoint: string): string { + return ` +;(function(){ + var s = document.currentScript; + var endpoint = ${JSON.stringify(endpoint)}; + var key = s ? s.getAttribute('data-key') : null; + function send(name) { + try { + var body = JSON.stringify({ n: name, u: window.location.href, k: key, t: document.title }); + var xhr = new XMLHttpRequest(); + xhr.open('POST', endpoint, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(body); + } catch (e) {} + } + function instance() { + return { sendEvent: function(n) { send(n || 'custom'); } }; + } + window.AhrefsAnalytics = instance(); + send('pageview'); + var origPush = history.pushState; + history.pushState = function() { + var r = origPush.apply(this, arguments); + send('pageview'); + return r; + }; + window.addEventListener('popstate', function() { send('pageview'); }); +})(); +` +} + +const CDN_STUB = buildStubAnalyticsJs('https://analytics.ahrefs.com/api/event') +const BUNDLED_PROXY_PATH = '/_scripts/p/analytics.ahrefs.com/api/event' + +// Stub /api/event so beacon assertions are deterministic on CI. The real +// script ties the data-key to a registered domain and silently drops beacons +// from unregistered origins (e.g. localhost) and from headless/webdriver +// contexts. We also stub analytics.js itself with a minimal pageview-firing +// implementation, used in both modes: +// CDN mode -> intercept https://analytics.ahrefs.com/analytics.js +// bundled mode -> intercept /_scripts/assets/* (script body is rewritten +// at build time but still served as a local asset) +// Beacons land at: +// CDN mode -> https://analytics.ahrefs.com/api/event +// bundled mode -> /_scripts/p/analytics.ahrefs.com/api/event (proxy path +// produced by the replace-new-url-origin sdkPatch) +async function newCapturePage(opts: { bundled: boolean }) { + const browser = await getBrowser() + const page = await browser.newPage() + const requests: CapturedRequest[] = [] + // Match either endpoint shape so both modes flow through the same capture. + await page.route(/\/api\/event(?:\?|$)/, async (route) => { + const req = route.request() + requests.push({ + method: req.method(), + url: req.url(), + postData: req.postData() ?? null, + contentType: req.headers()['content-type'] ?? null, + }) + await route.fulfill({ status: 200, contentType: 'text/plain', body: '' }) + }) + // CDN mode: replace analytics.js with our stub before it reaches the page. + await page.route('**/analytics.ahrefs.com/analytics.js', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/javascript', body: CDN_STUB }) + }) + // Bundled mode: the rewritten script is served from /_scripts/assets/*.js. + // We don't know the hashed filename, but the suite only loads one bundled + // SDK per fixture so any /_scripts/assets/*.js request is unambiguous. The + // stub posts to the proxy path the real AST-rewritten script would resolve + // to. We compute it relative to the page origin so the test stays portable. + if (opts.bundled) { + const bundledStub = buildStubAnalyticsJs(`${new URL(url('/')).origin}${BUNDLED_PROXY_PATH}`) + await page.route('**/_scripts/assets/*.js', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/javascript', body: bundledStub }) + }) + } + 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 +} + +function isExpectedUrl(reqUrl: string, bundled: boolean): boolean { + const u = new URL(reqUrl) + if (bundled) { + // Bundled mode contract: the replace-new-url-origin sdkPatch rewrites + // `new URL(currentScript.src).origin + "/api/event"` to the proxy path. + // Beacons must land on the local origin AND on the proxy pathname — if + // either part regresses, the integration silently drops user data. + return u.origin === new URL(url('/')).origin + && u.pathname === BUNDLED_PROXY_PATH + } + // CDN mode: the script is loaded from analytics.ahrefs.com so beacons go + // directly to that host. + return u.host === 'analytics.ahrefs.com' && u.pathname === '/api/event' +} + +function assertBeaconShape(req: CapturedRequest, bundled: boolean) { + expect(req.method, `expected POST, got ${req.method} for ${req.url}`).toBe('POST') + expect(isExpectedUrl(req.url, bundled), `unexpected URL shape: ${req.url}`).toBe(true) + const body = req.postData ?? '' + expect(body.length, `expected non-empty beacon payload, got empty body for ${req.url}`).toBeGreaterThan(0) + // Stub posts JSON; if content-type advertises it, ensure body parses. + if (req.contentType && req.contentType.includes('json')) { + expect(() => JSON.parse(body), `expected JSON-parseable body for ${req.url}`).not.toThrow() + } +} + +export function defineAhrefsAnalyticsSuite(opts: SuiteOptions) { + 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 00000000..4b34df15 --- /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 00000000..8f62b8bf --- /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 00000000..65cc1dd2 --- /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 00000000..b9826b34 --- /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 00000000..5f558070 --- /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 00000000..b171dec8 --- /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 00000000..4b34df15 --- /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 1c073604..06b50e7d 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/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts index dada8cda..c75ee56a 100644 --- a/test/unit/bundle-sdk-patches.test.ts +++ b/test/unit/bundle-sdk-patches.test.ts @@ -64,6 +64,15 @@ describe('bundle-only sdkPatches integration', () => { `(function(){var e=document.currentScript;if(e.src.indexOf("cdn.usefathom.com")<0){t="custom"}})();`, ) + // Mirrors the Ahrefs analytics.js endpoint derivation: it computes its + // `/api/event` host as `new URL(currentScript.src).origin`. When the script + // is bundled, that origin becomes the local Nuxt origin and beacons land on + // a 404. The replace-new-url-origin patch redirects the derivation through + // the script's proxy path. + const ahrefsLike = Buffer.from( + `(function(){var s=document.currentScript;var E=s.getAttribute("data-api")||new URL(s.src).origin+"/api/event";var _=s.getAttribute("data-error")||new URL(s.src).origin+"/api/error";})();`, + ) + it('applies neutralize-domain-check to bundle-only scripts (no proxy)', async () => { mockUpstream(fathomLike) const renderedScript = new Map() @@ -93,6 +102,45 @@ describe('bundle-only sdkPatches integration', () => { expect(content).not.toMatch(/indexOf\("cdn\.usefathom\.com"\)\s*<\s*0\b/) }) + it('applies replace-new-url-origin to bundled scripts that derive endpoints from currentScript.src', async () => { + mockUpstream(ahrefsLike) + vi.mocked(hash).mockImplementationOnce(() => 'ahrefs-script') + const renderedScript = new Map() + + await runTransform( + `const instance = useScriptAhrefsAnalytics({ key: 'k' }, { bundle: true })`, + { + renderedScript, + scripts: [ + { + registryKey: 'ahrefsAnalytics', + bundle: { resolve: () => 'https://analytics.ahrefs.com/analytics.js' }, + proxy: 'ahrefsAnalytics', + import: { name: 'useScriptAhrefsAnalytics', from: '' }, + }, + ] as any, + proxyConfigs: { + ahrefsAnalytics: { + domains: ['analytics.ahrefs.com'], + sdkPatches: [{ type: 'replace-new-url-origin', fromDomain: 'analytics.ahrefs.com' }], + } as any, + }, + proxyPrefix: '/_scripts/p', + }, + ) + + const stored = [...renderedScript.values()][0] + expect(stored, 'bundle was not stored').toBeDefined() + const content = (stored.content as Buffer).toString('utf-8') + // Both /api/event and /api/error endpoints get redirected through the proxy. + // The patch wraps the rewritten origin in parens to preserve operator precedence + // when the original `new URL(...).origin` was concatenated with a path literal. + expect(content).toMatch(/\(self\.location\.origin\+"\/_scripts\/p\/analytics\.ahrefs\.com"\)\+"\/api\/event"/) + expect(content).toMatch(/\(self\.location\.origin\+"\/_scripts\/p\/analytics\.ahrefs\.com"\)\+"\/api\/error"/) + // Original derivation is gone — no remaining `new URL(s.src).origin`. + expect(content).not.toMatch(/new URL\(s\.src\)\.origin/) + }) + it('leaves bundles untouched when no patches are configured', async () => { mockUpstream(fathomLike) const renderedScript = new Map() diff --git a/test/unit/first-party.test.ts b/test/unit/first-party.test.ts index 7097bbe3..d975d994 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 a65ceeac..aa1b9cb7 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)