From 40110921fcbf8efad645d4daa1e24000d3f79782 Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 05:27:13 -0800 Subject: [PATCH 1/6] Measure privacy-bounded SuperDoc referral CTA clicks --- apps/web/src/lib/growth-attribution.d.mts | 4 ++ apps/web/src/lib/growth-attribution.mjs | 66 +++++++++++++++++++++++ apps/web/src/main.tsx | 3 ++ docs/growth-attribution.md | 26 +++++++++ tests/growth-attribution.test.mjs | 37 +++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 apps/web/src/lib/growth-attribution.d.mts create mode 100644 apps/web/src/lib/growth-attribution.mjs create mode 100644 docs/growth-attribution.md create mode 100644 tests/growth-attribution.test.mjs diff --git a/apps/web/src/lib/growth-attribution.d.mts b/apps/web/src/lib/growth-attribution.d.mts new file mode 100644 index 0000000..459f45f --- /dev/null +++ b/apps/web/src/lib/growth-attribution.d.mts @@ -0,0 +1,4 @@ +export function analyticsAllowed(win: any, sourceProject: string): boolean; +export function classifyDestination(href: string, origin: string): string | null; +export function ctaPayload(input: { sourceProject: string; measurementId: string; destination: string; placement: string }): Record | null; +export function installGrowthAttribution(input: { sourceProject: string; measurementId: string; win?: any; doc?: any }): () => void; diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs new file mode 100644 index 0000000..d65d384 --- /dev/null +++ b/apps/web/src/lib/growth-attribution.mjs @@ -0,0 +1,66 @@ +// Growth contract v1: aggregate first-party CTA signals, never document/user content. +const PROJECTS = Object.freeze({ + homepage: ['superdoc.dev', 'www.superdoc.dev'], + ooxml_dev: ['ooxml.dev', 'www.ooxml.dev'], + docx_tools: ['docx.tools', 'www.docx.tools'], + docx_corpus: ['docxcorp.us', 'www.docxcorp.us'], + docs: ['docs.superdoc.dev'], +}); +const PLACEMENTS = new Set(['header', 'footer', 'hero', 'content', 'pricing']); +const INSTALLED = new WeakMap(); + +export function analyticsAllowed(win, sourceProject) { + return Boolean(PROJECTS[sourceProject]?.includes(win?.location?.hostname) && + typeof win?.gtag === 'function' && + win?.navigator?.globalPrivacyControl !== true && + !['1', 'yes'].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && + win?.superdocAnalyticsOptOut !== true && + !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? '')); +} + +// Classify locally. No URL, query, fragment, referrer, anchor text or document ID +// is ever included in an analytics payload. +export function classifyDestination(href, origin) { + let url; + try { url = new URL(href, origin); } catch { return null; } + if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; + if (url.hostname === 'docs.superdoc.dev') return /quickstart|getting-started/.test(url.pathname) ? 'quickstart' : 'docs'; + if (!['superdoc.dev', 'www.superdoc.dev'].includes(url.hostname)) return null; + if (url.hash === '#pricing' || url.pathname === '/pricing') return 'pricing'; + if (url.pathname === '/business-signup') return 'signup'; + if (url.pathname === '/' && !url.hash) return 'superdoc_home'; + return null; +} + +export function ctaPayload({ sourceProject, measurementId, destination, placement }) { + if (!PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '') || + !['superdoc_home', 'docs', 'quickstart', 'pricing', 'signup'].includes(destination) || + !PLACEMENTS.has(placement)) return null; + return Object.freeze({ send_to: measurementId, event_version: 'growth-v1', + source_project: sourceProject, destination, placement }); +} + +export function installGrowthAttribution({ sourceProject, measurementId, win = globalThis.window, doc = globalThis.document } = {}) { + if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '')) return () => {}; + if (INSTALLED.has(doc)) return () => {}; + const click = event => { + try { + if (!analyticsAllowed(win, sourceProject) || event.button > 0 || event.defaultPrevented) return; + const anchor = event.target?.closest?.('a[href]'); + if (!anchor) return; + const destination = classifyDestination(anchor.getAttribute('href'), win.location.origin); + if (!destination) return; + // Only external referrals or a specific conversion CTA, never ordinary home navigation. + if (sourceProject === 'homepage' && destination === 'superdoc_home') return; + const explicit = anchor.closest('[data-growth-placement]')?.getAttribute('data-growth-placement'); + const placement = PLACEMENTS.has(explicit) ? explicit : anchor.closest('footer') ? 'footer' : anchor.closest('header, nav') ? 'header' : 'content'; + const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); + if (payload) win.gtag('event', 'superdoc_cta_clicked', payload); + } catch { /* Analytics must never interrupt navigation or the developer tool. */ } + }; + doc.addEventListener('click', click, { passive: true }); + INSTALLED.set(doc, click); + return () => { + if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click); INSTALLED.delete(doc); } + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 88e097f..4981248 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import { installGrowthAttribution } from './lib/growth-attribution.mjs'; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; @@ -10,6 +11,8 @@ import { NotFound } from "./pages/NotFound"; import { SpecExplorer } from "./pages/SpecExplorer"; import "./index.css"; +installGrowthAttribution({ sourceProject: 'ooxml_dev', measurementId: 'G-JFTMJ85VCW' }); + const router = createBrowserRouter([ { element: , diff --git a/docs/growth-attribution.md b/docs/growth-attribution.md new file mode 100644 index 0000000..66dd260 --- /dev/null +++ b/docs/growth-attribution.md @@ -0,0 +1,26 @@ +# Growth attribution v1 + +This adds aggregate `superdoc_cta_clicked` events to the existing GA4 property. +It does not add another analytics tag, page_view, cookie, identifier or outreach +trigger. Existing UTM links remain unchanged. Anonymous engagement never creates +a named lead. These are CTA clicks, not signups, activation or successful payment. + +Payload: event_version, source_project, bounded destination and placement, send_to. +No URLs, queries, referrers, anchor text, identity, file names, document contents, +OOXML, prompts or free text are sent. Only exact production hostnames emit events. +GPC, DNT and `window.superdocAnalyticsOptOut` suppress these added events; existing +GA consent controls continue to apply. This change does not alter existing tags. + +## Reviewer validation + +Run `node --test tests/growth-attribution.test.mjs` and the project's web build. +In a production-like browser journey, open GA4 DebugView with your own browser +Debug setting, follow an existing SuperDoc CTA, and verify exactly one event with +only the fields above and the correct site's measurement ID. No page_view should +be added by this module. Repeat with GPC/DNT/opt-out and confirm no added event. +Do not upload a real customer document for validation. + +After merge/deployment, compare referral clicks and downstream hand-raises by +source over complete 28-day periods. Keep source property counts separate; do not +sum them into unique people. Server-confirmed signup/activation/payment events +need their own authoritative receipt and deduplication; a CTA cannot stand in. diff --git a/tests/growth-attribution.test.mjs b/tests/growth-attribution.test.mjs new file mode 100644 index 0000000..4b9eb00 --- /dev/null +++ b/tests/growth-attribution.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyticsAllowed, classifyDestination, ctaPayload, installGrowthAttribution } from '../apps/web/src/lib/growth-attribution.mjs'; +function surface() { + const calls = []; const listeners = new Map(); + const win = { location: { hostname: 'ooxml.dev', origin: 'https://ooxml.dev', pathname: '/' }, navigator: {}, gtag: (...args) => calls.push(args) }; + const doc = { addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) }; + const anchor = { getAttribute: () => 'https://superdoc.dev/?utm_source=ooxml.dev&email=private@example.com', closest: () => null }; + const click = { target: { closest: () => anchor }, button: 0, defaultPrevented: false }; + return { calls, listeners, win, doc, click }; +} +test('classifies exact destinations and excludes malformed/credentialed/unrelated URLs', () => { + assert.equal(classifyDestination('https://docs.superdoc.dev/getting-started/quickstart?secret=x', 'https://ooxml.dev'), 'quickstart'); + for (const href of ['javascript:alert(1)', 'https://superdoc.dev.evil.test/', 'https://private@superdoc.dev/', 'https://superdoc.dev/account?email=x', 'https://superdoc.dev:8443/']) assert.equal(classifyDestination(href, 'https://ooxml.dev'), null); +}); +test('payload excludes arbitrary keys and only accepts bounded enums', () => { + const value = ctaPayload({ sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer', email: 'private@example.com', url: 'secret' }); + assert.deepEqual(Object.keys(value).sort(), ['send_to', 'event_version', 'source_project', 'destination', 'placement'].sort()); + assert.equal(ctaPayload({ sourceProject: 'user@example.com', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer' }), null); +}); +test('respects opt-out, DNT, GPC and private/non-production surfaces', () => { + for (const mutate of [w => { w.superdocAnalyticsOptOut = true; }, w => { w.navigator.doNotTrack = '1'; }, w => { w.navigator.globalPrivacyControl = true; }, w => { w.location.hostname = 'localhost'; }, w => { w.location.pathname = '/account'; }, w => { w.gtag = undefined; }]) { + const { win } = surface(); mutate(win); assert.equal(analyticsAllowed(win, 'ooxml_dev'), false); + } +}); +test('single listener preserves navigation and never emits raw link/query data', () => { + const s = surface(); const stop = installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + assert.equal(s.listeners.size, 1); s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); + assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|https:|utm_/); + stop(); assert.equal(s.listeners.size, 0); +}); +test('provider errors and privacy changes after installation never break a click', () => { + const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.win.gtag = () => { throw new Error('blocked'); }; assert.doesNotThrow(() => s.listeners.get('click')(s.click)); + s.win.superdocAnalyticsOptOut = true; s.win.gtag = (...args) => s.calls.push(args); s.listeners.get('click')(s.click); assert.equal(s.calls.length, 0); +}); From 7bca1c0ad68561e46dd93d4ffb7a950c7122a44d Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 05:38:40 -0800 Subject: [PATCH 2/6] Capture client-routed CTA intent without duplicate listeners --- apps/web/src/lib/growth-attribution.mjs | 6 +++--- tests/growth-attribution.test.mjs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs index d65d384..57c036a 100644 --- a/apps/web/src/lib/growth-attribution.mjs +++ b/apps/web/src/lib/growth-attribution.mjs @@ -45,7 +45,7 @@ export function installGrowthAttribution({ sourceProject, measurementId, win = g if (INSTALLED.has(doc)) return () => {}; const click = event => { try { - if (!analyticsAllowed(win, sourceProject) || event.button > 0 || event.defaultPrevented) return; + if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; const anchor = event.target?.closest?.('a[href]'); if (!anchor) return; const destination = classifyDestination(anchor.getAttribute('href'), win.location.origin); @@ -58,9 +58,9 @@ export function installGrowthAttribution({ sourceProject, measurementId, win = g if (payload) win.gtag('event', 'superdoc_cta_clicked', payload); } catch { /* Analytics must never interrupt navigation or the developer tool. */ } }; - doc.addEventListener('click', click, { passive: true }); + doc.addEventListener('click', click, { passive: true, capture: true }); INSTALLED.set(doc, click); return () => { - if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click); INSTALLED.delete(doc); } + if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click, true); INSTALLED.delete(doc); } }; } diff --git a/tests/growth-attribution.test.mjs b/tests/growth-attribution.test.mjs index 4b9eb00..0cdbafe 100644 --- a/tests/growth-attribution.test.mjs +++ b/tests/growth-attribution.test.mjs @@ -30,6 +30,10 @@ test('single listener preserves navigation and never emits raw link/query data', assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|https:|utm_/); stop(); assert.equal(s.listeners.size, 0); }); +test('client-side router prevention does not hide CTA intent', () => { + const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.click.defaultPrevented = true; s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); +}); test('provider errors and privacy changes after installation never break a click', () => { const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); s.win.gtag = () => { throw new Error('blocked'); }; assert.doesNotThrow(() => s.listeners.get('click')(s.click)); From 60b6180df991515580a8e028643c12df17a5da00 Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 05:42:13 -0800 Subject: [PATCH 3/6] Match OOXML formatter and verify routed CTA capture --- apps/web/src/lib/growth-attribution.d.mts | 14 ++- apps/web/src/lib/growth-attribution.mjs | 133 ++++++++++++++-------- apps/web/src/main.tsx | 4 +- 3 files changed, 98 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/growth-attribution.d.mts b/apps/web/src/lib/growth-attribution.d.mts index 459f45f..6aa71fd 100644 --- a/apps/web/src/lib/growth-attribution.d.mts +++ b/apps/web/src/lib/growth-attribution.d.mts @@ -1,4 +1,14 @@ export function analyticsAllowed(win: any, sourceProject: string): boolean; export function classifyDestination(href: string, origin: string): string | null; -export function ctaPayload(input: { sourceProject: string; measurementId: string; destination: string; placement: string }): Record | null; -export function installGrowthAttribution(input: { sourceProject: string; measurementId: string; win?: any; doc?: any }): () => void; +export function ctaPayload(input: { + sourceProject: string; + measurementId: string; + destination: string; + placement: string; +}): Record | null; +export function installGrowthAttribution(input: { + sourceProject: string; + measurementId: string; + win?: any; + doc?: any; +}): () => void; diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs index 57c036a..e9a159a 100644 --- a/apps/web/src/lib/growth-attribution.mjs +++ b/apps/web/src/lib/growth-attribution.mjs @@ -1,66 +1,101 @@ // Growth contract v1: aggregate first-party CTA signals, never document/user content. const PROJECTS = Object.freeze({ - homepage: ['superdoc.dev', 'www.superdoc.dev'], - ooxml_dev: ['ooxml.dev', 'www.ooxml.dev'], - docx_tools: ['docx.tools', 'www.docx.tools'], - docx_corpus: ['docxcorp.us', 'www.docxcorp.us'], - docs: ['docs.superdoc.dev'], + homepage: ["superdoc.dev", "www.superdoc.dev"], + ooxml_dev: ["ooxml.dev", "www.ooxml.dev"], + docx_tools: ["docx.tools", "www.docx.tools"], + docx_corpus: ["docxcorp.us", "www.docxcorp.us"], + docs: ["docs.superdoc.dev"], }); -const PLACEMENTS = new Set(['header', 'footer', 'hero', 'content', 'pricing']); +const PLACEMENTS = new Set(["header", "footer", "hero", "content", "pricing"]); const INSTALLED = new WeakMap(); export function analyticsAllowed(win, sourceProject) { - return Boolean(PROJECTS[sourceProject]?.includes(win?.location?.hostname) && - typeof win?.gtag === 'function' && - win?.navigator?.globalPrivacyControl !== true && - !['1', 'yes'].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && - win?.superdocAnalyticsOptOut !== true && - !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? '')); + return Boolean( + PROJECTS[sourceProject]?.includes(win?.location?.hostname) && + typeof win?.gtag === "function" && + win?.navigator?.globalPrivacyControl !== true && + !["1", "yes"].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && + win?.superdocAnalyticsOptOut !== true && + !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? ""), + ); } // Classify locally. No URL, query, fragment, referrer, anchor text or document ID // is ever included in an analytics payload. export function classifyDestination(href, origin) { - let url; - try { url = new URL(href, origin); } catch { return null; } - if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; - if (url.hostname === 'docs.superdoc.dev') return /quickstart|getting-started/.test(url.pathname) ? 'quickstart' : 'docs'; - if (!['superdoc.dev', 'www.superdoc.dev'].includes(url.hostname)) return null; - if (url.hash === '#pricing' || url.pathname === '/pricing') return 'pricing'; - if (url.pathname === '/business-signup') return 'signup'; - if (url.pathname === '/' && !url.hash) return 'superdoc_home'; - return null; + let url; + try { + url = new URL(href, origin); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + if (url.hostname === "docs.superdoc.dev") + return /quickstart|getting-started/.test(url.pathname) ? "quickstart" : "docs"; + if (!["superdoc.dev", "www.superdoc.dev"].includes(url.hostname)) return null; + if (url.hash === "#pricing" || url.pathname === "/pricing") return "pricing"; + if (url.pathname === "/business-signup") return "signup"; + if (url.pathname === "/" && !url.hash) return "superdoc_home"; + return null; } export function ctaPayload({ sourceProject, measurementId, destination, placement }) { - if (!PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '') || - !['superdoc_home', 'docs', 'quickstart', 'pricing', 'signup'].includes(destination) || - !PLACEMENTS.has(placement)) return null; - return Object.freeze({ send_to: measurementId, event_version: 'growth-v1', - source_project: sourceProject, destination, placement }); + if ( + !PROJECTS[sourceProject] || + !/^G-[A-Z0-9]+$/.test(measurementId ?? "") || + !["superdoc_home", "docs", "quickstart", "pricing", "signup"].includes(destination) || + !PLACEMENTS.has(placement) + ) + return null; + return Object.freeze({ + send_to: measurementId, + event_version: "growth-v1", + source_project: sourceProject, + destination, + placement, + }); } -export function installGrowthAttribution({ sourceProject, measurementId, win = globalThis.window, doc = globalThis.document } = {}) { - if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '')) return () => {}; - if (INSTALLED.has(doc)) return () => {}; - const click = event => { - try { - if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; - const anchor = event.target?.closest?.('a[href]'); - if (!anchor) return; - const destination = classifyDestination(anchor.getAttribute('href'), win.location.origin); - if (!destination) return; - // Only external referrals or a specific conversion CTA, never ordinary home navigation. - if (sourceProject === 'homepage' && destination === 'superdoc_home') return; - const explicit = anchor.closest('[data-growth-placement]')?.getAttribute('data-growth-placement'); - const placement = PLACEMENTS.has(explicit) ? explicit : anchor.closest('footer') ? 'footer' : anchor.closest('header, nav') ? 'header' : 'content'; - const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); - if (payload) win.gtag('event', 'superdoc_cta_clicked', payload); - } catch { /* Analytics must never interrupt navigation or the developer tool. */ } - }; - doc.addEventListener('click', click, { passive: true, capture: true }); - INSTALLED.set(doc, click); - return () => { - if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click, true); INSTALLED.delete(doc); } - }; +export function installGrowthAttribution({ + sourceProject, + measurementId, + win = globalThis.window, + doc = globalThis.document, +} = {}) { + if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? "")) + return () => {}; + if (INSTALLED.has(doc)) return () => {}; + const click = (event) => { + try { + if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; + const anchor = event.target?.closest?.("a[href]"); + if (!anchor) return; + const destination = classifyDestination(anchor.getAttribute("href"), win.location.origin); + if (!destination) return; + // Only external referrals or a specific conversion CTA, never ordinary home navigation. + if (sourceProject === "homepage" && destination === "superdoc_home") return; + const explicit = anchor + .closest("[data-growth-placement]") + ?.getAttribute("data-growth-placement"); + const placement = PLACEMENTS.has(explicit) + ? explicit + : anchor.closest("footer") + ? "footer" + : anchor.closest("header, nav") + ? "header" + : "content"; + const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); + if (payload) win.gtag("event", "superdoc_cta_clicked", payload); + } catch { + /* Analytics must never interrupt navigation or the developer tool. */ + } + }; + doc.addEventListener("click", click, { passive: true, capture: true }); + INSTALLED.set(doc, click); + return () => { + if (INSTALLED.get(doc) === click) { + doc.removeEventListener("click", click, true); + INSTALLED.delete(doc); + } + }; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4981248..60879b3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,4 +1,4 @@ -import { installGrowthAttribution } from './lib/growth-attribution.mjs'; +import { installGrowthAttribution } from "./lib/growth-attribution.mjs"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; @@ -11,7 +11,7 @@ import { NotFound } from "./pages/NotFound"; import { SpecExplorer } from "./pages/SpecExplorer"; import "./index.css"; -installGrowthAttribution({ sourceProject: 'ooxml_dev', measurementId: 'G-JFTMJ85VCW' }); +installGrowthAttribution({ sourceProject: "ooxml_dev", measurementId: "G-JFTMJ85VCW" }); const router = createBrowserRouter([ { From d4930623645e69026bacdd001ac888c38c2f8622 Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 17:03:38 -0500 Subject: [PATCH 4/6] Bound inherited analytics page context to static project labels --- apps/web/src/lib/growth-attribution.mjs | 141 +++++++++--------------- docs/growth-attribution.md | 13 ++- tests/growth-attribution.test.mjs | 22 +++- 3 files changed, 84 insertions(+), 92 deletions(-) diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs index e9a159a..5d0eaae 100644 --- a/apps/web/src/lib/growth-attribution.mjs +++ b/apps/web/src/lib/growth-attribution.mjs @@ -1,101 +1,70 @@ // Growth contract v1: aggregate first-party CTA signals, never document/user content. const PROJECTS = Object.freeze({ - homepage: ["superdoc.dev", "www.superdoc.dev"], - ooxml_dev: ["ooxml.dev", "www.ooxml.dev"], - docx_tools: ["docx.tools", "www.docx.tools"], - docx_corpus: ["docxcorp.us", "www.docxcorp.us"], - docs: ["docs.superdoc.dev"], + homepage: ['superdoc.dev', 'www.superdoc.dev'], + ooxml_dev: ['ooxml.dev', 'www.ooxml.dev'], + docx_tools: ['docx.tools', 'www.docx.tools'], + docx_corpus: ['docxcorp.us', 'www.docxcorp.us'], + docs: ['docs.superdoc.dev'], }); -const PLACEMENTS = new Set(["header", "footer", "hero", "content", "pricing"]); +const PLACEMENTS = new Set(['header', 'footer', 'hero', 'content', 'pricing']); const INSTALLED = new WeakMap(); export function analyticsAllowed(win, sourceProject) { - return Boolean( - PROJECTS[sourceProject]?.includes(win?.location?.hostname) && - typeof win?.gtag === "function" && - win?.navigator?.globalPrivacyControl !== true && - !["1", "yes"].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && - win?.superdocAnalyticsOptOut !== true && - !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? ""), - ); + return Boolean(PROJECTS[sourceProject]?.includes(win?.location?.hostname) && + typeof win?.gtag === 'function' && + win?.navigator?.globalPrivacyControl !== true && + !['1', 'yes'].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && + win?.superdocAnalyticsOptOut !== true && + !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? '')); } -// Classify locally. No URL, query, fragment, referrer, anchor text or document ID -// is ever included in an analytics payload. +// Classify locally. Observed URLs, queries, fragments, referrers, anchor text +// and document IDs are never copied into this event. export function classifyDestination(href, origin) { - let url; - try { - url = new URL(href, origin); - } catch { - return null; - } - if (url.protocol !== "https:" || url.username || url.password || url.port) return null; - if (url.hostname === "docs.superdoc.dev") - return /quickstart|getting-started/.test(url.pathname) ? "quickstart" : "docs"; - if (!["superdoc.dev", "www.superdoc.dev"].includes(url.hostname)) return null; - if (url.hash === "#pricing" || url.pathname === "/pricing") return "pricing"; - if (url.pathname === "/business-signup") return "signup"; - if (url.pathname === "/" && !url.hash) return "superdoc_home"; - return null; + let url; + try { url = new URL(href, origin); } catch { return null; } + if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; + if (url.hostname === 'docs.superdoc.dev') return /quickstart|getting-started/.test(url.pathname) ? 'quickstart' : 'docs'; + if (!['superdoc.dev', 'www.superdoc.dev'].includes(url.hostname)) return null; + if (url.hash === '#pricing' || url.pathname === '/pricing') return 'pricing'; + if (url.pathname === '/business-signup') return 'signup'; + if (url.pathname === '/' && !url.hash) return 'superdoc_home'; + return null; } export function ctaPayload({ sourceProject, measurementId, destination, placement }) { - if ( - !PROJECTS[sourceProject] || - !/^G-[A-Z0-9]+$/.test(measurementId ?? "") || - !["superdoc_home", "docs", "quickstart", "pricing", "signup"].includes(destination) || - !PLACEMENTS.has(placement) - ) - return null; - return Object.freeze({ - send_to: measurementId, - event_version: "growth-v1", - source_project: sourceProject, - destination, - placement, - }); + if (!PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '') || + !['superdoc_home', 'docs', 'quickstart', 'pricing', 'signup'].includes(destination) || + !PLACEMENTS.has(placement)) return null; + return Object.freeze({ send_to: measurementId, event_version: 'growth-v1', + source_project: sourceProject, destination, placement, + // gtag otherwise inherits location, title and referrer from the page. + // These event-scoped constants do not change the site's existing tag config. + page_location: `https://${PROJECTS[sourceProject][0]}/`, + page_title: `SuperDoc / ${sourceProject}`, page_referrer: '' }); } -export function installGrowthAttribution({ - sourceProject, - measurementId, - win = globalThis.window, - doc = globalThis.document, -} = {}) { - if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? "")) - return () => {}; - if (INSTALLED.has(doc)) return () => {}; - const click = (event) => { - try { - if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; - const anchor = event.target?.closest?.("a[href]"); - if (!anchor) return; - const destination = classifyDestination(anchor.getAttribute("href"), win.location.origin); - if (!destination) return; - // Only external referrals or a specific conversion CTA, never ordinary home navigation. - if (sourceProject === "homepage" && destination === "superdoc_home") return; - const explicit = anchor - .closest("[data-growth-placement]") - ?.getAttribute("data-growth-placement"); - const placement = PLACEMENTS.has(explicit) - ? explicit - : anchor.closest("footer") - ? "footer" - : anchor.closest("header, nav") - ? "header" - : "content"; - const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); - if (payload) win.gtag("event", "superdoc_cta_clicked", payload); - } catch { - /* Analytics must never interrupt navigation or the developer tool. */ - } - }; - doc.addEventListener("click", click, { passive: true, capture: true }); - INSTALLED.set(doc, click); - return () => { - if (INSTALLED.get(doc) === click) { - doc.removeEventListener("click", click, true); - INSTALLED.delete(doc); - } - }; +export function installGrowthAttribution({ sourceProject, measurementId, win = globalThis.window, doc = globalThis.document } = {}) { + if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '')) return () => {}; + if (INSTALLED.has(doc)) return () => {}; + const click = event => { + try { + if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; + const anchor = event.target?.closest?.('a[href]'); + if (!anchor) return; + const destination = classifyDestination(anchor.getAttribute('href'), win.location.origin); + if (!destination) return; + // Only external referrals or a specific conversion CTA, never ordinary home navigation. + if (sourceProject === 'homepage' && destination === 'superdoc_home') return; + const explicit = anchor.closest('[data-growth-placement]')?.getAttribute('data-growth-placement'); + const placement = PLACEMENTS.has(explicit) ? explicit : anchor.closest('footer') ? 'footer' : anchor.closest('header, nav') ? 'header' : 'content'; + const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); + if (payload) win.gtag('event', 'superdoc_cta_clicked', payload); + } catch { /* Analytics must never interrupt navigation or the developer tool. */ } + }; + doc.addEventListener('click', click, { passive: true, capture: true }); + INSTALLED.set(doc, click); + return () => { + if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click, true); INSTALLED.delete(doc); } + }; } diff --git a/docs/growth-attribution.md b/docs/growth-attribution.md index 66dd260..a32ee73 100644 --- a/docs/growth-attribution.md +++ b/docs/growth-attribution.md @@ -5,11 +5,18 @@ It does not add another analytics tag, page_view, cookie, identifier or outreach trigger. Existing UTM links remain unchanged. Anonymous engagement never creates a named lead. These are CTA clicks, not signups, activation or successful payment. -Payload: event_version, source_project, bounded destination and placement, send_to. -No URLs, queries, referrers, anchor text, identity, file names, document contents, -OOXML, prompts or free text are sent. Only exact production hostnames emit events. +Payload: event_version, source_project, bounded destination and placement, send_to, +plus event-scoped page_location, page_title and page_referrer overrides. Location +is the fixed canonical project root, title is a fixed project label, and referrer +is empty. Observed page/link URLs, queries, fragments, referrers, anchor text, +identity, file names, document contents, OOXML, prompts and free text are not copied +into this event. Only exact production hostnames emit events. GPC, DNT and `window.superdocAnalyticsOptOut` suppress these added events; existing GA consent controls continue to apply. This change does not alter existing tags. +The existing Google tag may attach its normal consent-dependent technical/session +metadata; this is not a claim of anonymous collection or a change to other events. +See Google's [configuration reference](https://developers.google.com/analytics/devguides/collection/ga4/reference/config) +for the inherited page-context defaults these event-scoped constants replace. ## Reviewer validation diff --git a/tests/growth-attribution.test.mjs b/tests/growth-attribution.test.mjs index 0cdbafe..0d76a08 100644 --- a/tests/growth-attribution.test.mjs +++ b/tests/growth-attribution.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { analyticsAllowed, classifyDestination, ctaPayload, installGrowthAttribution } from '../apps/web/src/lib/growth-attribution.mjs'; +import { analyticsAllowed, classifyDestination, ctaPayload, installGrowthAttribution } from "../apps/web/src/lib/growth-attribution.mjs"; function surface() { const calls = []; const listeners = new Map(); const win = { location: { hostname: 'ooxml.dev', origin: 'https://ooxml.dev', pathname: '/' }, navigator: {}, gtag: (...args) => calls.push(args) }; @@ -15,7 +15,9 @@ test('classifies exact destinations and excludes malformed/credentialed/unrelate }); test('payload excludes arbitrary keys and only accepts bounded enums', () => { const value = ctaPayload({ sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer', email: 'private@example.com', url: 'secret' }); - assert.deepEqual(Object.keys(value).sort(), ['send_to', 'event_version', 'source_project', 'destination', 'placement'].sort()); + assert.deepEqual(Object.keys(value).sort(), ['send_to', 'event_version', 'source_project', 'destination', 'placement', 'page_location', 'page_title', 'page_referrer'].sort()); + assert.equal(value.page_location, 'https://ooxml.dev/'); + assert.equal(value.page_referrer, ''); assert.equal(ctaPayload({ sourceProject: 'user@example.com', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer' }), null); }); test('respects opt-out, DNT, GPC and private/non-production surfaces', () => { @@ -27,9 +29,23 @@ test('single listener preserves navigation and never emits raw link/query data', const s = surface(); const stop = installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); assert.equal(s.listeners.size, 1); s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); - assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|https:|utm_/); + assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|utm_/); stop(); assert.equal(s.listeners.size, 0); }); +test('event context replaces inherited page URL, title and referrer with constants', () => { + const s = surface(); + s.win.location.href = 'https://ooxml.dev/private-file?email=private@example.com#secret'; + s.doc.title = 'Private document title'; + s.doc.referrer = 'https://customer.example/private-case'; + const inherited = { page_location: s.win.location.href, page_title: s.doc.title, page_referrer: s.doc.referrer }; + s.win.gtag = (_command, _name, payload) => s.calls.push({ ...inherited, ...payload }); + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.listeners.get('click')(s.click); + assert.equal(s.calls[0].page_location, 'https://ooxml.dev/'); + assert.equal(s.calls[0].page_title, 'SuperDoc / ooxml_dev'); + assert.equal(s.calls[0].page_referrer, ''); + assert.doesNotMatch(JSON.stringify(s.calls), /private|Private|secret|customer\.example/); +}); test('client-side router prevention does not hide CTA intent', () => { const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); s.click.defaultPrevented = true; s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); From b001f8e1bcb7a7980c211f96824cf7c48f920a7a Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 17:05:30 -0500 Subject: [PATCH 5/6] Format analytics context safeguards --- apps/web/src/lib/growth-attribution.mjs | 142 +++++++++++++++--------- 1 file changed, 89 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs index 5d0eaae..c105ff3 100644 --- a/apps/web/src/lib/growth-attribution.mjs +++ b/apps/web/src/lib/growth-attribution.mjs @@ -1,70 +1,106 @@ // Growth contract v1: aggregate first-party CTA signals, never document/user content. const PROJECTS = Object.freeze({ - homepage: ['superdoc.dev', 'www.superdoc.dev'], - ooxml_dev: ['ooxml.dev', 'www.ooxml.dev'], - docx_tools: ['docx.tools', 'www.docx.tools'], - docx_corpus: ['docxcorp.us', 'www.docxcorp.us'], - docs: ['docs.superdoc.dev'], + homepage: ["superdoc.dev", "www.superdoc.dev"], + ooxml_dev: ["ooxml.dev", "www.ooxml.dev"], + docx_tools: ["docx.tools", "www.docx.tools"], + docx_corpus: ["docxcorp.us", "www.docxcorp.us"], + docs: ["docs.superdoc.dev"], }); -const PLACEMENTS = new Set(['header', 'footer', 'hero', 'content', 'pricing']); +const PLACEMENTS = new Set(["header", "footer", "hero", "content", "pricing"]); const INSTALLED = new WeakMap(); export function analyticsAllowed(win, sourceProject) { - return Boolean(PROJECTS[sourceProject]?.includes(win?.location?.hostname) && - typeof win?.gtag === 'function' && - win?.navigator?.globalPrivacyControl !== true && - !['1', 'yes'].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && - win?.superdocAnalyticsOptOut !== true && - !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? '')); + return Boolean( + PROJECTS[sourceProject]?.includes(win?.location?.hostname) && + typeof win?.gtag === "function" && + win?.navigator?.globalPrivacyControl !== true && + !["1", "yes"].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && + win?.superdocAnalyticsOptOut !== true && + !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? ""), + ); } // Classify locally. Observed URLs, queries, fragments, referrers, anchor text // and document IDs are never copied into this event. export function classifyDestination(href, origin) { - let url; - try { url = new URL(href, origin); } catch { return null; } - if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; - if (url.hostname === 'docs.superdoc.dev') return /quickstart|getting-started/.test(url.pathname) ? 'quickstart' : 'docs'; - if (!['superdoc.dev', 'www.superdoc.dev'].includes(url.hostname)) return null; - if (url.hash === '#pricing' || url.pathname === '/pricing') return 'pricing'; - if (url.pathname === '/business-signup') return 'signup'; - if (url.pathname === '/' && !url.hash) return 'superdoc_home'; - return null; + let url; + try { + url = new URL(href, origin); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + if (url.hostname === "docs.superdoc.dev") + return /quickstart|getting-started/.test(url.pathname) ? "quickstart" : "docs"; + if (!["superdoc.dev", "www.superdoc.dev"].includes(url.hostname)) return null; + if (url.hash === "#pricing" || url.pathname === "/pricing") return "pricing"; + if (url.pathname === "/business-signup") return "signup"; + if (url.pathname === "/" && !url.hash) return "superdoc_home"; + return null; } export function ctaPayload({ sourceProject, measurementId, destination, placement }) { - if (!PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '') || - !['superdoc_home', 'docs', 'quickstart', 'pricing', 'signup'].includes(destination) || - !PLACEMENTS.has(placement)) return null; - return Object.freeze({ send_to: measurementId, event_version: 'growth-v1', - source_project: sourceProject, destination, placement, - // gtag otherwise inherits location, title and referrer from the page. - // These event-scoped constants do not change the site's existing tag config. - page_location: `https://${PROJECTS[sourceProject][0]}/`, - page_title: `SuperDoc / ${sourceProject}`, page_referrer: '' }); + if ( + !PROJECTS[sourceProject] || + !/^G-[A-Z0-9]+$/.test(measurementId ?? "") || + !["superdoc_home", "docs", "quickstart", "pricing", "signup"].includes(destination) || + !PLACEMENTS.has(placement) + ) + return null; + return Object.freeze({ + send_to: measurementId, + event_version: "growth-v1", + source_project: sourceProject, + destination, + placement, + // gtag otherwise inherits location, title and referrer from the page. + // These event-scoped constants do not change the site's existing tag config. + page_location: `https://${PROJECTS[sourceProject][0]}/`, + page_title: `SuperDoc / ${sourceProject}`, + page_referrer: "", + }); } -export function installGrowthAttribution({ sourceProject, measurementId, win = globalThis.window, doc = globalThis.document } = {}) { - if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? '')) return () => {}; - if (INSTALLED.has(doc)) return () => {}; - const click = event => { - try { - if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; - const anchor = event.target?.closest?.('a[href]'); - if (!anchor) return; - const destination = classifyDestination(anchor.getAttribute('href'), win.location.origin); - if (!destination) return; - // Only external referrals or a specific conversion CTA, never ordinary home navigation. - if (sourceProject === 'homepage' && destination === 'superdoc_home') return; - const explicit = anchor.closest('[data-growth-placement]')?.getAttribute('data-growth-placement'); - const placement = PLACEMENTS.has(explicit) ? explicit : anchor.closest('footer') ? 'footer' : anchor.closest('header, nav') ? 'header' : 'content'; - const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); - if (payload) win.gtag('event', 'superdoc_cta_clicked', payload); - } catch { /* Analytics must never interrupt navigation or the developer tool. */ } - }; - doc.addEventListener('click', click, { passive: true, capture: true }); - INSTALLED.set(doc, click); - return () => { - if (INSTALLED.get(doc) === click) { doc.removeEventListener('click', click, true); INSTALLED.delete(doc); } - }; +export function installGrowthAttribution({ + sourceProject, + measurementId, + win = globalThis.window, + doc = globalThis.document, +} = {}) { + if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? "")) + return () => {}; + if (INSTALLED.has(doc)) return () => {}; + const click = (event) => { + try { + if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; + const anchor = event.target?.closest?.("a[href]"); + if (!anchor) return; + const destination = classifyDestination(anchor.getAttribute("href"), win.location.origin); + if (!destination) return; + // Only external referrals or a specific conversion CTA, never ordinary home navigation. + if (sourceProject === "homepage" && destination === "superdoc_home") return; + const explicit = anchor + .closest("[data-growth-placement]") + ?.getAttribute("data-growth-placement"); + const placement = PLACEMENTS.has(explicit) + ? explicit + : anchor.closest("footer") + ? "footer" + : anchor.closest("header, nav") + ? "header" + : "content"; + const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); + if (payload) win.gtag("event", "superdoc_cta_clicked", payload); + } catch { + /* Analytics must never interrupt navigation or the developer tool. */ + } + }; + doc.addEventListener("click", click, { passive: true, capture: true }); + INSTALLED.set(doc, click); + return () => { + if (INSTALLED.get(doc) === click) { + doc.removeEventListener("click", click, true); + INSTALLED.delete(doc); + } + }; } From cf5c51e9306ea3c7ec70a59fafb463cd948cb346 Mon Sep 17 00:00:00 2001 From: jelkes1 Date: Sun, 1 Nov 2026 19:28:27 -0500 Subject: [PATCH 6/6] Honor independent privacy signals and verify bounded CTA attribution --- .github/workflows/ci.yml | 3 ++ apps/web/src/lib/growth-attribution.mjs | 24 +++++++-- package.json | 3 +- tests/growth-attribution.test.mjs | 65 ++++++++++++++++++++----- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9c812..8f5fbdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: - run: bunx --package vite-plus@0.2.9 vp install --frozen-lockfile + - name: Growth attribution privacy tests + run: bun run test:growth + - name: Check run: bun run check diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs index c105ff3..eb04ca1 100644 --- a/apps/web/src/lib/growth-attribution.mjs +++ b/apps/web/src/lib/growth-attribution.mjs @@ -11,10 +11,13 @@ const INSTALLED = new WeakMap(); export function analyticsAllowed(win, sourceProject) { return Boolean( + Object.hasOwn(PROJECTS, sourceProject) && PROJECTS[sourceProject]?.includes(win?.location?.hostname) && typeof win?.gtag === "function" && win?.navigator?.globalPrivacyControl !== true && - !["1", "yes"].includes(String(win?.navigator?.doNotTrack ?? win?.doNotTrack).toLowerCase()) && + ![win?.navigator?.doNotTrack, win?.doNotTrack].some((value) => + ["1", "yes"].includes(String(value).trim().toLowerCase()), + ) && win?.superdocAnalyticsOptOut !== true && !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? ""), ); @@ -31,7 +34,14 @@ export function classifyDestination(href, origin) { } if (url.protocol !== "https:" || url.username || url.password || url.port) return null; if (url.hostname === "docs.superdoc.dev") - return /quickstart|getting-started/.test(url.pathname) ? "quickstart" : "docs"; + return [ + "/quickstart", + "/getting-started", + "/getting-started/quickstart", + "/editor/quickstart", + ].includes(url.pathname.replace(/\/+$/, "") || "/") + ? "quickstart" + : "docs"; if (!["superdoc.dev", "www.superdoc.dev"].includes(url.hostname)) return null; if (url.hash === "#pricing" || url.pathname === "/pricing") return "pricing"; if (url.pathname === "/business-signup") return "signup"; @@ -41,7 +51,7 @@ export function classifyDestination(href, origin) { export function ctaPayload({ sourceProject, measurementId, destination, placement }) { if ( - !PROJECTS[sourceProject] || + !Object.hasOwn(PROJECTS, sourceProject) || !/^G-[A-Z0-9]+$/.test(measurementId ?? "") || !["superdoc_home", "docs", "quickstart", "pricing", "signup"].includes(destination) || !PLACEMENTS.has(placement) @@ -67,7 +77,12 @@ export function installGrowthAttribution({ win = globalThis.window, doc = globalThis.document, } = {}) { - if (!win || !doc || !PROJECTS[sourceProject] || !/^G-[A-Z0-9]+$/.test(measurementId ?? "")) + if ( + !win || + !doc || + !Object.hasOwn(PROJECTS, sourceProject) || + !/^G-[A-Z0-9]+$/.test(measurementId ?? "") + ) return () => {}; if (INSTALLED.has(doc)) return () => {}; const click = (event) => { @@ -79,6 +94,7 @@ export function installGrowthAttribution({ if (!destination) return; // Only external referrals or a specific conversion CTA, never ordinary home navigation. if (sourceProject === "homepage" && destination === "superdoc_home") return; + if (sourceProject === "docs" && ["docs", "quickstart"].includes(destination)) return; const explicit = anchor .closest("[data-growth-placement]") ?.getAttribute("data-growth-placement"); diff --git a/package.json b/package.json index a52d53a..4175353 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "pdf:test": "python3 -m unittest discover -s tests/ingest-pdf && bun test tests/ingest-pdf/chunk-pages.test.ts", "xsd:fetch": "bun scripts/ingest-xsd/fetch.ts", "xsd:ingest": "bun scripts/ingest-xsd/ingest.ts", - "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/web/ && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/" + "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/web/ && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/", + "test:growth": "node --test tests/growth-attribution.test.mjs" }, "devDependencies": { "@changesets/cli": "3.0.0", diff --git a/tests/growth-attribution.test.mjs b/tests/growth-attribution.test.mjs index 0d76a08..ff24f8a 100644 --- a/tests/growth-attribution.test.mjs +++ b/tests/growth-attribution.test.mjs @@ -2,12 +2,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { analyticsAllowed, classifyDestination, ctaPayload, installGrowthAttribution } from "../apps/web/src/lib/growth-attribution.mjs"; function surface() { - const calls = []; const listeners = new Map(); - const win = { location: { hostname: 'ooxml.dev', origin: 'https://ooxml.dev', pathname: '/' }, navigator: {}, gtag: (...args) => calls.push(args) }; - const doc = { addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) }; + const calls = [], listeners = new Map(); + const navigation = { prevented: 0, stopped: 0, assigned: 0, replaced: 0 }; + const win = { location: { hostname: 'ooxml.dev', origin: 'https://ooxml.dev', pathname: '/', href: 'https://ooxml.dev/', assign() { navigation.assigned++; }, replace() { navigation.replaced++; } }, navigator: {}, gtag: (...args) => calls.push(args) }; + const doc = { + addEventListener(name, fn) { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); }, + removeEventListener(name, fn) { const callbacks = listeners.get(name); callbacks?.delete(fn); if (!callbacks?.size) listeners.delete(name); }, + }; const anchor = { getAttribute: () => 'https://superdoc.dev/?utm_source=ooxml.dev&email=private@example.com', closest: () => null }; - const click = { target: { closest: () => anchor }, button: 0, defaultPrevented: false }; - return { calls, listeners, win, doc, click }; + const click = { target: { closest: () => anchor }, button: 0, defaultPrevented: false, + preventDefault() { navigation.prevented++; this.defaultPrevented = true; }, stopPropagation() { navigation.stopped++; } }; + const dispatch = () => { for (const callback of [...(listeners.get('click') ?? [])]) callback(click); }; + const listenerCount = () => [...listeners.values()].reduce((count, callbacks) => count + callbacks.size, 0); + return { calls, listeners, win, doc, click, anchor, navigation, dispatch, listenerCount }; } test('classifies exact destinations and excludes malformed/credentialed/unrelated URLs', () => { assert.equal(classifyDestination('https://docs.superdoc.dev/getting-started/quickstart?secret=x', 'https://ooxml.dev'), 'quickstart'); @@ -28,9 +35,17 @@ test('respects opt-out, DNT, GPC and private/non-production surfaces', () => { test('single listener preserves navigation and never emits raw link/query data', () => { const s = surface(); const stop = installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); - assert.equal(s.listeners.size, 1); s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); + assert.equal(s.listenerCount(), 1); s.dispatch(); assert.equal(s.calls.length, 1); assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|utm_/); - stop(); assert.equal(s.listeners.size, 0); + assert.equal(s.click.defaultPrevented, false); + assert.deepEqual(s.navigation, { prevented: 0, stopped: 0, assigned: 0, replaced: 0 }); + assert.equal(s.win.location.href, 'https://ooxml.dev/'); + let otherClicks = 0; const otherListener = () => { otherClicks++; }; + s.doc.addEventListener('click', otherListener); + assert.equal(s.listenerCount(), 2); + stop(); assert.equal(s.listenerCount(), 1); + s.dispatch(); assert.equal(otherClicks, 1); assert.equal(s.calls.length, 1); + s.doc.removeEventListener('click', otherListener); assert.equal(s.listenerCount(), 0); }); test('event context replaces inherited page URL, title and referrer with constants', () => { const s = surface(); @@ -40,7 +55,7 @@ test('event context replaces inherited page URL, title and referrer with constan const inherited = { page_location: s.win.location.href, page_title: s.doc.title, page_referrer: s.doc.referrer }; s.win.gtag = (_command, _name, payload) => s.calls.push({ ...inherited, ...payload }); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); - s.listeners.get('click')(s.click); + s.dispatch(); assert.equal(s.calls[0].page_location, 'https://ooxml.dev/'); assert.equal(s.calls[0].page_title, 'SuperDoc / ooxml_dev'); assert.equal(s.calls[0].page_referrer, ''); @@ -48,10 +63,38 @@ test('event context replaces inherited page URL, title and referrer with constan }); test('client-side router prevention does not hide CTA intent', () => { const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); - s.click.defaultPrevented = true; s.listeners.get('click')(s.click); assert.equal(s.calls.length, 1); + s.click.defaultPrevented = true; s.dispatch(); assert.equal(s.calls.length, 1); }); test('provider errors and privacy changes after installation never break a click', () => { const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); - s.win.gtag = () => { throw new Error('blocked'); }; assert.doesNotThrow(() => s.listeners.get('click')(s.click)); - s.win.superdocAnalyticsOptOut = true; s.win.gtag = (...args) => s.calls.push(args); s.listeners.get('click')(s.click); assert.equal(s.calls.length, 0); + s.win.gtag = () => { throw new Error('blocked'); }; assert.doesNotThrow(() => s.dispatch()); + s.win.superdocAnalyticsOptOut = true; s.win.gtag = (...args) => s.calls.push(args); s.dispatch(); assert.equal(s.calls.length, 0); +}); + +test('either modern or legacy DNT opt-out suppresses the added event', () => { + for (const [modern, legacy] of [['', '1'], ['0', 'yes'], ['unspecified', 'YES'], ['1', '0']]) { + const s = surface(); s.win.navigator.doNotTrack = modern; s.win.doNotTrack = legacy; + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.dispatch(); assert.equal(s.calls.length, 0); + } +}); +test('inherited project names are rejected at each analytics boundary', () => { + for (const sourceProject of ['constructor', '__proto__', 'toString']) { + const s = surface(); assert.equal(analyticsAllowed(s.win, sourceProject), false); + assert.equal(ctaPayload({ sourceProject, measurementId: 'G-SYNTHETIC', destination: 'pricing', placement: 'footer' }), null); + assert.doesNotThrow(() => installGrowthAttribution({ ...s, sourceProject, measurementId: 'G-SYNTHETIC' })); + assert.equal(s.listenerCount(), 0); + } +}); +test('only explicit quickstart paths count as quickstart, including the current editor route', () => { + for (const path of ['/editor/quickstart', '/editor/quickstart/', '/getting-started/quickstart']) assert.equal(classifyDestination('https://docs.superdoc.dev' + path + '?private=x', 'https://ooxml.dev'), 'quickstart'); + for (const path of ['/resources/quickstart-notes', '/editor/quickstart-other', '/resources/getting-started-security']) assert.equal(classifyDestination('https://docs.superdoc.dev' + path, 'https://ooxml.dev'), 'docs'); +}); +test('a docs install does not turn ordinary in-docs navigation into referral traffic', () => { + for (const href of ['https://docs.superdoc.dev/editor/comments', 'https://docs.superdoc.dev/editor/quickstart']) { + const s = surface(); s.win.location.hostname = 'docs.superdoc.dev'; s.win.location.origin = 'https://docs.superdoc.dev'; + s.anchor.getAttribute = () => href; + installGrowthAttribution({ ...s, sourceProject: 'docs', measurementId: 'G-SYNTHETIC' }); + s.dispatch(); assert.equal(s.calls.length, 0); + } });