diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index a59c6e88..fd01a1a9 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -111,7 +111,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | Preset | Flags | Used by | |---|---|---| | `PRIVACY_NONE` | all false | (not currently assigned to any script) | -| `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit | +| `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 | @@ -127,6 +127,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa | `xPixel` | xPixel | `PRIVACY_FULL` | Path A | | `snapchatPixel` | snapchatPixel | `PRIVACY_FULL` | Path A | | `redditPixel` | redditPixel | `PRIVACY_FULL` | Path A | +| `linkedinInsight` | linkedinInsight | `PRIVACY_FULL` | 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 7130009f..e38044c5 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -61,7 +61,7 @@ Every proxied script defaults to a privacy tier based on what level of anonymisa | Tier | What's anonymised | Scripts | |------|-------------------|---------| -| **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel | +| **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 | @@ -324,7 +324,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) | -| **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), [Google AdSense](/scripts/google-adsense) | +| **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/linkedin-insight.md b/docs/content/scripts/linkedin-insight.md new file mode 100644 index 00000000..601a0f4a --- /dev/null +++ b/docs/content/scripts/linkedin-insight.md @@ -0,0 +1,114 @@ +--- +title: LinkedIn Insight Tag +description: Use the LinkedIn Insight Tag in your Nuxt app to track conversions, retarget visitors, and learn about your audience. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/linkedin-insight.ts + size: xs +--- + +The [LinkedIn Insight Tag](https://business.linkedin.com/marketing-solutions/insight-tag) is a lightweight JavaScript snippet for conversion tracking, retargeting, and audience insights on LinkedIn Ads campaigns. + +Nuxt Scripts provides a registry script composable [`useScriptLinkedInInsight()`{lang="ts"}](/scripts/linkedin-insight) to integrate it in your Nuxt app. + +::script-stats +:: + +::script-docs +:: + +::script-types +:: + +## Examples + +### Tracking a conversion + +```vue + +``` + +### Per-event deduplication with the Conversions API + +When you also send conversions through LinkedIn's server-side Conversions API, pass the same `event_id` to both. LinkedIn discards the server-side duplicate and counts the Insight Tag event. See [LinkedIn deduplication](https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication?view=li-lms-2026-01). + +```vue + +``` + +### Page-load conversion deduplication + +For dedup on the auto-fired page-view, set `eventId` at registration. The composable assigns `window._linkedin_event_id` *before* the Insight Tag base code runs, so the page-view URL includes `&eventId=…` automatically. + +```vue + +``` + +### Enhanced matching with `setUserData` + +Pass plain email; the Insight Tag SHA-256 hashes it on-device. See [LinkedIn enhanced matching](https://www.linkedin.com/help/lms/answer/a6246095). + +```vue + +``` + +The Insight Tag stores the hashed email in `localStorage["li_hem"]` and transmits it on the next page-view via a separate POST to `https://px.ads.linkedin.com/wa/...` (the WebsiteActions gateway). The `/collect` URL of the page-view immediately following `setUserData` does not carry it; LinkedIn picks it up on the next full page load when the bootstrap reads it back from `localStorage`. + +### SPA virtual page views + +By default, the Insight Tag fires a page-view exactly once when the script loads, so SPA route changes go untracked. Opt in to per-route tracking with `enableAutoSpaTracking`: + +```vue + +``` + +When enabled, the composable suppresses the script's built-in auto-page-view (via `window._wait_for_lintrk = true`) and fires `lintrk('track')`{lang="ts"} on Nuxt's `page:finish` hook instead, so each route (including the initial page) produces exactly one `/collect` beacon. + +### Multiple Partner IDs + +If you need to push more than one Partner ID onto `window._linkedin_data_partner_ids`, pass an array. The composable promotes the first ID to the primary `_linkedin_partner_id` global. + +```vue + +``` diff --git a/package.json b/package.json index b07ca4c1..bdd12e23 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", + "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", "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 1c572e86..233c3a68 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -24,6 +24,10 @@ export const LOGOS = { }, tiktokPixel: ``, redditPixel: ` `, + linkedinInsight: { + light: ``, + dark: ``, + }, googleAdsense: ``, carbonAds: { light: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 2d7cd29d..493e5a33 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -606,6 +606,33 @@ "code": "interface ScriptLemonSqueezySlots {\n default?: () => any\n}" } ], + "linkedin-insight": [ + { + "name": "LinkedInInsightOptions", + "kind": "const", + "code": "export const LinkedInInsightOptions = object({\n /**\n * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push\n * onto window._linkedin_data_partner_ids. The first ID is used as the\n * primary _linkedin_partner_id global.\n * @example '111143'\n * @example ['111143', '111154']\n * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id\n */\n id: union([pipe(string(), minLength(1)), pipe(array(pipe(string(), minLength(1))), minLength(1))]),\n /**\n * Optional page-load event ID for Conversions API deduplication. Assigned\n * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must\n * match the eventId sent with the corresponding server-side Conversions\n * API event.\n *\n * Per-event conversion deduplication uses the per-call event_id passed to\n * lintrk('track', { conversion_id, event_id }) instead.\n * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication\n */\n eventId: optional(string()),\n /**\n * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page\n * views). When true, suppresses the script's built-in auto-page-view via\n * window._wait_for_lintrk and tracks every navigation including the\n * initial page through Nuxt's page:finish hook. When false, the script\n * fires its own page-view exactly once on load and SPA navigations are\n * not tracked unless lintrk('track') is called manually.\n * @default false\n */\n enableAutoSpaTracking: optional(boolean()),\n})" + }, + { + "name": "LintrkTrackParams", + "kind": "interface", + "code": "interface LintrkTrackParams {\n conversion_id?: number\n event_id?: string\n commandCallback?: () => void\n [key: string]: any\n}" + }, + { + "name": "LintrkUserData", + "kind": "interface", + "code": "interface LintrkUserData {\n /** Plain email; the script SHA-256 hashes it before sending. */\n email: string\n}" + }, + { + "name": "LintrkFns", + "kind": "type", + "code": "type LintrkFns\n = & ((cmd: 'track', params?: LintrkTrackParams) => void)\n & ((cmd: 'setUserData', data: LintrkUserData) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)" + }, + { + "name": "LinkedInInsightApi", + "kind": "interface", + "code": "export interface LinkedInInsightApi {\n lintrk: LintrkFns & { q?: unknown[] }\n}" + } + ], "matomo-analytics": [ { "name": "MatomoAnalyticsOptions", @@ -2012,6 +2039,27 @@ "description": "Default consent state fired as `uetq.push('consent', 'default', ...)` before UET init." } ], + "LinkedInInsightOptions": [ + { + "name": "id", + "type": "string | string[]", + "required": true, + "description": "Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push onto window._linkedin_data_partner_ids. The first ID is used as the primary _linkedin_partner_id global." + }, + { + "name": "eventId", + "type": "string", + "required": false, + "description": "Optional page-load event ID for Conversions API deduplication. Assigned to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must match the eventId sent with the corresponding server-side Conversions API event. Per-event conversion deduplication uses the per-call event_id passed to lintrk('track', { conversion_id, event_id }) instead." + }, + { + "name": "enableAutoSpaTracking", + "type": "boolean", + "required": false, + "description": "Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page views). When true, suppresses the script's built-in auto-page-view via window._wait_for_lintrk and tracks every navigation including the initial page through Nuxt's page:finish hook. When false, the script fires its own page-view exactly once on load and SPA navigations are not tracked unless lintrk('track') is called manually.", + "defaultValue": "false" + } + ], "SegmentOptions": [ { "name": "writeKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index e7ddbc60..9117c430 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -30,6 +30,7 @@ import { HotjarOptions, InstagramEmbedOptions, IntercomOptions, + LinkedInInsightOptions, MatomoAnalyticsOptions, MetaPixelOptions, MixpanelAnalyticsOptions, @@ -138,6 +139,7 @@ export const registryMeta: RegistryScriptMeta[] = [ m('tiktokPixel', 'TikTok Pixel', 'ad', 'useScriptTikTokPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('snapchatPixel', 'Snapchat Pixel', 'ad', 'useScriptSnapchatPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('redditPixel', 'Reddit Pixel', 'ad', 'useScriptRedditPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), + m('linkedinInsight', 'LinkedIn Insight Tag', 'ad', 'useScriptLinkedInInsight', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), m('googleAdsense', 'Google Adsense', 'ad', 'useScriptGoogleAdsense', { bundle: true, proxy: true }, PRIVACY_HEATMAP), m('carbonAds', 'Carbon Ads', 'ad', false, { proxy: true }, PRIVACY_IP_ONLY), // tag-manager @@ -508,6 +510,21 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, partytown: { forwards: ['rdt'] }, }), + def('linkedinInsight', { + // explicit override: auto-derived would be `useScriptLinkedinInsight` (lowercase i) + composableName: 'useScriptLinkedInInsight', + schema: LinkedInInsightOptions, + label: 'LinkedIn Insight Tag', + src: 'https://snap.licdn.com/li.lms-analytics/insight.min.js', + category: 'ad', + envDefaults: { id: '' }, + bundle: true, + proxy: { + domains: ['snap.licdn.com', 'px.ads.linkedin.com'], + privacy: PRIVACY_FULL, + }, + partytown: { forwards: ['lintrk'] }, + }), def('googleAdsense', { schema: GoogleAdsenseOptions, label: 'Google Adsense', diff --git a/packages/script/src/runtime/registry/linkedin-insight.ts b/packages/script/src/runtime/registry/linkedin-insight.ts new file mode 100644 index 00000000..2d71d72a --- /dev/null +++ b/packages/script/src/runtime/registry/linkedin-insight.ts @@ -0,0 +1,112 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useScriptEventPage } from '../composables/useScriptEventPage' +import { useRegistryScript } from '../utils' +import { LinkedInInsightOptions } from './schemas' + +export { LinkedInInsightOptions } + +export type LinkedInInsightInput = RegistryScriptInput + +interface LintrkTrackParams { + conversion_id?: number + event_id?: string + commandCallback?: () => void + [key: string]: any +} + +interface LintrkUserData { + /** Plain email; the script SHA-256 hashes it before sending. */ + email: string +} + +type LintrkFns + = & ((cmd: 'track', params?: LintrkTrackParams) => void) + & ((cmd: 'setUserData', data: LintrkUserData) => void) + & ((cmd: (string & {}), ...args: any[]) => void) + +export interface LinkedInInsightApi { + lintrk: LintrkFns & { q?: unknown[] } +} + +declare global { + interface Window extends LinkedInInsightApi { + _linkedin_partner_id?: string + _linkedin_data_partner_ids?: string[] + _linkedin_event_id?: string + _wait_for_lintrk?: boolean + } +} + +/** + * Load the LinkedIn Insight Tag and expose a typed `lintrk` proxy. + * + * @see https://www.linkedin.com/help/lms/answer/a418880 + * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication + */ +export function useScriptLinkedInInsight( + _options?: LinkedInInsightInput, +): UseScriptContext { + let enableAutoSpaTracking = false + + const instance = useRegistryScript('linkedinInsight', (options) => { + enableAutoSpaTracking = !!options.enableAutoSpaTracking + return { + scriptInput: { + src: 'https://snap.licdn.com/li.lms-analytics/insight.min.js', + crossorigin: false, + }, + schema: import.meta.dev ? LinkedInInsightOptions : undefined, + scriptOptions: { + use() { + return { lintrk: window.lintrk } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + const ids = Array.isArray(options.id) ? options.id : [options.id] + + // _linkedin_event_id must be set before the base code runs. + // https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication + if (options.eventId) + window._linkedin_event_id = options.eventId + + // Suppress the script's built-in auto-page-view; useScriptEventPage + // (below) owns all page-view tracking when SPA tracking is on, so + // letting both fire would double-count the initial /collect. + if (options.enableAutoSpaTracking) + window._wait_for_lintrk = true + + window._linkedin_partner_id = ids[0] + window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [] + for (const id of ids) { + if (!window._linkedin_data_partner_ids.includes(id)) + window._linkedin_data_partner_ids.push(id) + } + + if (!window.lintrk) { + const lintrk = function (cmd: string, ...args: any[]) { + ;(lintrk as any).q.push([cmd, ...args]) + } as LinkedInInsightApi['lintrk'] + ;(lintrk as any).q = [] + window.lintrk = lintrk + } + }, + } + }, _options) + + // Registered per component setup (not inside clientInit) so each new route + // component owns its own page:finish hook. The previous component's hook + // tears down via onScopeDispose during unmount, BEFORE page:finish fires + // for the new route — assumes default lifecycle without a + // transition. With or a transition that keeps both + // components alive across page:finish, both hooks would fire and double- + // count. The "no double-fire" e2e regression guard catches the simple case. + if (import.meta.client && enableAutoSpaTracking) { + useScriptEventPage(() => { + window.lintrk?.('track') + }) + } + + return instance +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index cb0306a3..1b1c250a 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -829,6 +829,39 @@ export const BingUetOptions = object({ })), }) +export const LinkedInInsightOptions = object({ + /** + * Your LinkedIn Insight Tag Partner ID, or an array of Partner IDs to push + * onto window._linkedin_data_partner_ids. The first ID is used as the + * primary _linkedin_partner_id global. + * @example '111143' + * @example ['111143', '111154'] + * @see https://www.linkedin.com/help/lms/answer/a417869/access-your-linkedin-partner-id + */ + id: union([pipe(string(), minLength(1)), pipe(array(pipe(string(), minLength(1))), minLength(1))]), + /** + * Optional page-load event ID for Conversions API deduplication. Assigned + * to window._linkedin_event_id BEFORE the Insight Tag base code runs. Must + * match the eventId sent with the corresponding server-side Conversions + * API event. + * + * Per-event conversion deduplication uses the per-call event_id passed to + * lintrk('track', { conversion_id, event_id }) instead. + * @see https://learn.microsoft.com/en-us/linkedin/marketing/conversions/deduplication + */ + eventId: optional(string()), + /** + * Auto-fire lintrk('track') on Vue Router route changes (SPA virtual page + * views). When true, suppresses the script's built-in auto-page-view via + * window._wait_for_lintrk and tracks every navigation including the + * initial page through Nuxt's page:finish hook. When false, the script + * fires its own page-view exactly once on load and SPA navigations are + * not tracked unless lintrk('track') is called manually. + * @default false + */ + enableAutoSpaTracking: optional(boolean()), +}) + export const SegmentOptions = object({ /** * Your Segment write key. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index b451b088..4f705b59 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -23,6 +23,7 @@ import type { HotjarInput } from './registry/hotjar' import type { InstagramEmbedInput } from './registry/instagram-embed' import type { IntercomInput } from './registry/intercom' import type { LemonSqueezyInput } from './registry/lemon-squeezy' +import type { LinkedInInsightInput } from './registry/linkedin-insight' import type { MatomoAnalyticsInput } from './registry/matomo-analytics' import type { MetaPixelInput } from './registry/meta-pixel' import type { MixpanelAnalyticsInput } from './registry/mixpanel-analytics' @@ -243,6 +244,7 @@ export interface ScriptRegistry { googleTagManager?: GoogleTagManagerInput hotjar?: HotjarInput intercom?: IntercomInput + linkedinInsight?: LinkedInInsightInput paypal?: PayPalInput posthog?: PostHogInput matomoAnalytics?: MatomoAnalyticsInput @@ -273,7 +275,7 @@ export type BuiltInRegistryScriptKey | 'databuddyAnalytics' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' - | 'hotjar' | 'intercom' | 'paypal' | 'posthog' | 'matomoAnalytics' + | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'gravatar' | 'npm' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 32d8adf4..c2faa595 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -99,6 +99,11 @@ export const scriptMeta = { trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'], testId: 'a2_ilz4u0kbdr3v', }, + linkedinInsight: { + urls: ['https://snap.licdn.com/li.lms-analytics/insight.min.js'], + trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'], + testId: '111143', + }, googleAdsense: { urls: ['https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'], trackedData: ['page-views', 'retargeting', 'audiences'], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index dcfa1b1f..a0b83dc8 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -29,6 +29,7 @@ function getPlaygroundPath(script: any): string | null { 'meta-pixel': '/third-parties/meta-pixel', 'x-pixel': '/third-parties/x-pixel/nuxt-scripts', 'reddit-pixel': '/third-parties/reddit-pixel/nuxt-scripts', + 'linkedin-insight': '/third-parties/linkedin-insight/nuxt-scripts', 'snapchat-pixel': '/third-parties/snapchat/nuxt-scripts', 'tiktok-pixel': '/third-parties/tiktok-pixel/nuxt-scripts', 'google-adsense': '/third-parties/google-adsense/nuxt-scripts', @@ -267,6 +268,10 @@ const benchmark = [ name: 'Reddit Pixel (Default)', path: '/third-parties/reddit-pixel/default', }, + { + name: 'LinkedIn Insight (Default)', + path: '/third-parties/linkedin-insight/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/linkedin-insight/default.vue b/playground/pages/third-parties/linkedin-insight/default.vue new file mode 100644 index 00000000..e76a1ffa --- /dev/null +++ b/playground/pages/third-parties/linkedin-insight/default.vue @@ -0,0 +1,26 @@ + + + diff --git a/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue new file mode 100644 index 00000000..0f899ff8 --- /dev/null +++ b/playground/pages/third-parties/linkedin-insight/nuxt-scripts.vue @@ -0,0 +1,52 @@ + + + diff --git a/test/e2e/_linkedin-insight-suite.ts b/test/e2e/_linkedin-insight-suite.ts new file mode 100644 index 00000000..8ad53596 --- /dev/null +++ b/test/e2e/_linkedin-insight-suite.ts @@ -0,0 +1,307 @@ +import { gunzipSync } from 'node:zlib' +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { beforeAll, expect, it } from 'vitest' + +// SHA-256 hex of 'test@example.com' (the email passed by setUserData below). +const EXPECTED_HASHED_EMAIL = '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' + +// Tests fall into two groups by network requirement: +// - "wiring" tests (script tag in DOM, partner globals set by clientInit) +// run offline — both happen before the LinkedIn script executes. +// - "behavior" tests need the LinkedIn script to actually run, which means +// loading from snap.licdn.com (CDN mode) and reaching px.ads.linkedin.com +// for the bootstrap cookie-test before the canonical /collect fires. +// We probe once; behavior tests skip when egress is unavailable. +const NETWORK_PROBE_TIMEOUT_MS = 5000 +async function probeLinkedInEgress(): Promise { + const probe = (u: string) => + fetch(u, { method: 'HEAD', signal: AbortSignal.timeout(NETWORK_PROBE_TIMEOUT_MS) }) + .then(() => true, () => false) + const results = await Promise.all([ + probe('https://snap.licdn.com/li.lms-analytics/insight.min.js'), + probe('https://px.ads.linkedin.com/collect'), + ]) + return results.every(Boolean) +} + +interface CapturedRequest { + method: string + url: string + postData: Buffer | null +} + +async function newCapturePage() { + const browser = await getBrowser() + const page = await browser.newPage() + const requests: CapturedRequest[] = [] + page.on('request', (req) => { + let data: Buffer | null = null + try { + const buf = req.postDataBuffer() + data = buf ? Buffer.from(buf) : null + } + catch { /* ignore */ } + requests.push({ method: req.method(), url: req.url(), postData: data }) + }) + return { page, requests } +} + +// Poll a predicate until it's true, instead of fixed sleeps. Resolves quickly +// on healthy runs and surfaces the actual condition that timed out. +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}`) +} + +// Wait until `count()` stops changing for `stableMs`. Used by the regression +// guard (must give a potential double-fire time to happen) and the negative +// SPA-tracking assertion (must give the page time to NOT fire). +async function waitForStable( + count: () => number, + { stableMs = 1500, timeoutMs = 10000, intervalMs = 100 }: { stableMs?: number, timeoutMs?: number, intervalMs?: number } = {}, +): Promise { + const deadline = Date.now() + timeoutMs + let last = count() + let stableSince = Date.now() + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, intervalMs)) + const c = count() + if (c !== last) { + last = c + stableSince = Date.now() + } + else if (Date.now() - stableSince >= stableMs) { + return c + } + } + return count() +} + +// LinkedIn's payload formatter (`Pt` in insight.min.js) picks the first +// supported of: `g` = gzip(JSON) → base64; `b` = base64(JSON); `a` = JSON. +// All three are sent as text/plain; `g` and `b` are base64 strings on the +// wire, with `g` wrapping an inner gzip layer. +function decodeWaBody(req: CapturedRequest): string { + if (!req.postData) + return '' + const fmt = new URL(req.url).searchParams.get('fmt') + const text = req.postData.toString('utf8') + if (fmt === 'g') { + try { + return gunzipSync(Buffer.from(text, 'base64')).toString('utf8') + } + catch { /* fall through */ } + } + if (fmt === 'b') { + try { + return Buffer.from(text, 'base64').toString('utf8') + } + catch { /* fall through */ } + } + return text +} + +interface SuiteOptions { + bundled: boolean +} + +export function defineLinkedInInsightSuite(opts: SuiteOptions) { + let networkAvailable = false + beforeAll(async () => { + networkAvailable = await probeLinkedInEgress() + }) + + it('script tag points at the expected origin', async () => { + // Asserts on the rendered + + diff --git a/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue b/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue new file mode 100644 index 00000000..ae633e0c --- /dev/null +++ b/test/fixtures/linkedin-insight-cdn/pages/linkedin-spa.vue @@ -0,0 +1,19 @@ + + + diff --git a/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue new file mode 100644 index 00000000..d8a4002d --- /dev/null +++ b/test/fixtures/linkedin-insight-cdn/pages/linkedin.vue @@ -0,0 +1,59 @@ + + + diff --git a/test/fixtures/linkedin-insight-cdn/tsconfig.json b/test/fixtures/linkedin-insight-cdn/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/linkedin-insight-cdn/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/fixtures/linkedin-insight/app.vue b/test/fixtures/linkedin-insight/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/linkedin-insight/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/linkedin-insight/nuxt.config.ts b/test/fixtures/linkedin-insight/nuxt.config.ts new file mode 100644 index 00000000..da07fe10 --- /dev/null +++ b/test/fixtures/linkedin-insight/nuxt.config.ts @@ -0,0 +1,15 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Bundled fixture (default `bundle: true` from def(), 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: { + linkedinInsight: { id: ['111143', '111154'], eventId: 'page-load-event-id-test', enableAutoSpaTracking: true }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/linkedin-insight/package.json b/test/fixtures/linkedin-insight/package.json new file mode 100644 index 00000000..b9826b34 --- /dev/null +++ b/test/fixtures/linkedin-insight/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/linkedin-insight/pages/index.vue b/test/fixtures/linkedin-insight/pages/index.vue new file mode 100644 index 00000000..a2287ce5 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue b/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue new file mode 100644 index 00000000..65fdc26b --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin-no-spa.vue @@ -0,0 +1,30 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin-spa.vue b/test/fixtures/linkedin-insight/pages/linkedin-spa.vue new file mode 100644 index 00000000..95c92e99 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin-spa.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/linkedin-insight/pages/linkedin.vue b/test/fixtures/linkedin-insight/pages/linkedin.vue new file mode 100644 index 00000000..6e55ef43 --- /dev/null +++ b/test/fixtures/linkedin-insight/pages/linkedin.vue @@ -0,0 +1,48 @@ + + + diff --git a/test/fixtures/linkedin-insight/tsconfig.json b/test/fixtures/linkedin-insight/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/linkedin-insight/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 155f9bac..1c073604 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -31,6 +31,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/first-party.test.ts b/test/unit/first-party.test.ts index 3bbf47b6..7097bbe3 100644 --- a/test/unit/first-party.test.ts +++ b/test/unit/first-party.test.ts @@ -263,6 +263,7 @@ describe('first-party mode', () => { 'xPixel', 'snapchatPixel', 'redditPixel', + 'linkedinInsight', 'clarity', 'hotjar', ] diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index dc450f57..a65ceeac 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -418,6 +418,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('xPixel') expect(configs).toHaveProperty('snapchatPixel') expect(configs).toHaveProperty('redditPixel') + expect(configs).toHaveProperty('linkedinInsight') expect(configs).toHaveProperty('posthog') expect(configs).toHaveProperty('plausibleAnalytics') expect(configs).toHaveProperty('cloudflareWebAnalytics') @@ -433,7 +434,7 @@ describe('proxy configs', () => { it('all configs have valid structure', async () => { const configs = await getProxyConfigs() - const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel'] + 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'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains')