From f8d1cf754fe6fb2fd049462b51c7bb5121cac0e3 Mon Sep 17 00:00:00 2001 From: Dan Goosewin Date: Wed, 14 Oct 2026 23:01:55 -0700 Subject: [PATCH 1/3] feat: pass utm params through to dashboard sign-up --- utm.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 utm.js diff --git a/utm.js b/utm.js new file mode 100644 index 00000000..67f81990 --- /dev/null +++ b/utm.js @@ -0,0 +1,100 @@ +/* + * First-touch UTM passthrough to dashboard sign-up. + * + * Launch posts link straight to docs pages with UTM params, but sign-ups are + * tracked on the dashboard, so the params are lost at the hop. Store them on + * landing and append them to dashboard links on the way out. kernel/website + * writes the same kernel_utm cookie, so the first touch on either site wins. + * Mintlify loads every .js file in this directory on every docs page. + */ +(function () { + var COOKIE_NAME = "kernel_utm"; + var COOKIE_DOMAIN = "kernel.sh"; + var MAX_AGE = 60 * 60 * 24 * 30; + var DASHBOARD_HOST = "dashboard.onkernel.com"; + var UTM_KEYS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", + ]; + + function readStored() { + try { + var match = document.cookie.match( + new RegExp("(?:^|;\\s*)" + COOKIE_NAME + "=([^;]*)") + ); + if (!match) return null; + var parsed = JSON.parse(decodeURIComponent(match[1])); + return parsed && typeof parsed === "object" ? parsed : null; + } catch (e) { + return null; + } + } + + // Shared across www.kernel.sh and the docs. Off kernel.sh (local preview) + // the browser would reject the domain, so fall back to a host-only cookie. + function cookieAttributes() { + var host = location.hostname; + var attrs = "; path=/; max-age=" + MAX_AGE + "; SameSite=Lax"; + if (host === COOKIE_DOMAIN || host.endsWith("." + COOKIE_DOMAIN)) { + attrs += "; domain=." + COOKIE_DOMAIN; + } + if (location.protocol === "https:") attrs += "; Secure"; + return attrs; + } + + function captureFirstTouch() { + if (readStored()) return; + + var params = new URLSearchParams(location.search); + var utm = {}; + var found = false; + UTM_KEYS.forEach(function (key) { + var value = params.get(key); + if (value) { + utm[key] = value; + found = true; + } + }); + if (!found) return; + + document.cookie = + COOKIE_NAME + + "=" + + encodeURIComponent(JSON.stringify(utm)) + + cookieAttributes(); + } + + // Delegated so it covers the navbar buttons and links rendered after + // client-side navigation. Capture phase rewrites the href before the + // browser follows it. + function decorate(event) { + var link = event.target.closest && event.target.closest("a[href]"); + if (!link) return; + + var url; + try { + url = new URL(link.href, location.href); + } catch (e) { + return; + } + if (url.hostname !== DASHBOARD_HOST) return; + + var keys = Array.from(url.searchParams.keys()); + if (keys.some(function (key) { return key.indexOf("utm_") === 0; })) return; + + var utm = readStored(); + if (!utm) return; + + UTM_KEYS.forEach(function (key) { + if (utm[key]) url.searchParams.set(key, utm[key]); + }); + link.href = url.toString(); + } + + captureFirstTouch(); + document.addEventListener("click", decorate, { capture: true }); + document.addEventListener("auxclick", decorate, { capture: true }); +})(); From 36298b637e38fc6af49ec83a5f89d0dc3f6f5b7a Mon Sep 17 00:00:00 2001 From: Dan Goosewin Date: Tue, 20 Oct 2026 21:37:22 -0700 Subject: [PATCH 2/3] fix: gate utm passthrough on dnt and marketing consent --- utm.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/utm.js b/utm.js index 67f81990..1be21364 100644 --- a/utm.js +++ b/utm.js @@ -6,12 +6,17 @@ * landing and append them to dashboard links on the way out. kernel/website * writes the same kernel_utm cookie, so the first touch on either site wins. * Mintlify loads every .js file in this directory on every docs page. + * + * This is attribution, so it follows the same rules as conceptual.js: nothing + * runs under Do Not Track, and otherwise it needs marketing consent. */ (function () { var COOKIE_NAME = "kernel_utm"; var COOKIE_DOMAIN = "kernel.sh"; var MAX_AGE = 60 * 60 * 24 * 30; var DASHBOARD_HOST = "dashboard.onkernel.com"; + var CONSENT_CATEGORY = "marketing"; + var JURISDICTION_URL = "/api/c15t/show-consent-banner"; var UTM_KEYS = [ "utm_source", "utm_medium", @@ -20,6 +25,38 @@ "utm_term", ]; + function doNotTrackEnabled() { + return ( + navigator.doNotTrack === "1" || + navigator.msDoNotTrack === "1" || + window.doNotTrack === "1" + ); + } + + // true granted, false declined, null no decision recorded yet. c15t omits + // declined categories from its record, so see conceptual.js for why a + // record without the category means no. + function storedConsent() { + try { + var cookie = document.cookie.match(/(?:^|;\s*)c15t=([^;]*)/); + if (cookie && cookie[1]) { + return cookie[1].indexOf("c." + CONSENT_CATEGORY + ":1") !== -1; + } + } catch (e) {} + + try { + var raw = localStorage.getItem("c15t"); + if (raw) { + var parsed = JSON.parse(raw); + if (parsed && parsed.consents) { + return parsed.consents[CONSENT_CATEGORY] === true; + } + } + } catch (e) {} + + return null; + } + function readStored() { try { var match = document.cookie.match( @@ -35,36 +72,33 @@ // Shared across www.kernel.sh and the docs. Off kernel.sh (local preview) // the browser would reject the domain, so fall back to a host-only cookie. - function cookieAttributes() { + function writeCookie(value, maxAge) { var host = location.hostname; - var attrs = "; path=/; max-age=" + MAX_AGE + "; SameSite=Lax"; + var cookie = + COOKIE_NAME + + "=" + + value + + "; path=/; max-age=" + + maxAge + + "; SameSite=Lax"; if (host === COOKIE_DOMAIN || host.endsWith("." + COOKIE_DOMAIN)) { - attrs += "; domain=." + COOKIE_DOMAIN; + cookie += "; domain=." + COOKIE_DOMAIN; } - if (location.protocol === "https:") attrs += "; Secure"; - return attrs; + if (location.protocol === "https:") cookie += "; Secure"; + document.cookie = cookie; } - function captureFirstTouch() { - if (readStored()) return; - + function landingParams() { var params = new URLSearchParams(location.search); - var utm = {}; - var found = false; + var utm = null; UTM_KEYS.forEach(function (key) { var value = params.get(key); if (value) { + utm = utm || {}; utm[key] = value; - found = true; } }); - if (!found) return; - - document.cookie = - COOKIE_NAME + - "=" + - encodeURIComponent(JSON.stringify(utm)) + - cookieAttributes(); + return utm; } // Delegated so it covers the navbar buttons and links rendered after @@ -82,6 +116,13 @@ } if (url.hostname !== DASHBOARD_HOST) return; + // Consent can be withdrawn on the marketing site while a docs page stays + // open, so re-read it rather than trusting the decision made at load. + if (storedConsent() === false) { + writeCookie("", 0); + return; + } + var keys = Array.from(url.searchParams.keys()); if (keys.some(function (key) { return key.indexOf("utm_") === 0; })) return; @@ -94,7 +135,35 @@ link.href = url.toString(); } - captureFirstTouch(); - document.addEventListener("click", decorate, { capture: true }); - document.addEventListener("auxclick", decorate, { capture: true }); + // First touch wins: a later campaign never replaces the stored one. + function start(landing) { + if (landing && !readStored()) { + writeCookie(encodeURIComponent(JSON.stringify(landing)), MAX_AGE); + } + document.addEventListener("click", decorate, { capture: true }); + document.addEventListener("auxclick", decorate, { capture: true }); + } + + if (doNotTrackEnabled()) return; + + // Read before the consent check: the jurisdiction lookup can resolve after a + // client-side navigation has dropped the query. + var landing = landingParams(); + + var stored = storedConsent(); + if (stored !== null) { + if (stored) start(landing); + return; + } + + // No decision yet, the normal case for a visitor whose first page is a docs + // page. Apply c15t's rule: it auto-grants outside regulated jurisdictions. + fetch(JURISDICTION_URL, { credentials: "same-origin" }) + .then(function (response) { + return response.ok ? response.json() : null; + }) + .then(function (data) { + if (data && data.showConsentBanner === false) start(landing); + }) + .catch(function () {}); })(); From 3ca980cd40e079c2c885fae9fca175b4acf5a188 Mon Sep 17 00:00:00 2001 From: Dan Goosewin Date: Tue, 20 Oct 2026 21:40:25 -0700 Subject: [PATCH 3/3] fix: move utm passthrough into conceptual.js --- conceptual.js | 174 ++++++++++++++++++++++++++++++++++++++++++-------- utm.js | 169 ------------------------------------------------ 2 files changed, 148 insertions(+), 195 deletions(-) delete mode 100644 utm.js diff --git a/conceptual.js b/conceptual.js index a9a6d80b..4d368fca 100644 --- a/conceptual.js +++ b/conceptual.js @@ -5,6 +5,11 @@ * are served under www.kernel.sh/docs via a rewrite from the marketing site, so * both the c15t consent cookie and the c15t API are same-origin here and the * two halves of the site can share one consent decision. + * + * It also carries the first-touch campaign to dashboard sign-up. Launch posts + * link straight to docs pages with UTM params, but sign-ups are tracked on the + * dashboard, so the params are lost at the hop. That's attribution too, so it + * runs under the same Do Not Track and consent rules as the pixel. */ (function () { var CONSENT_CATEGORY = "marketing"; @@ -22,6 +27,23 @@ // invitee.created webhook instead. The UTM slots carry real campaign data. var CALENDLY_DOMAIN = "calendly.com"; var CALENDLY_PARAM = "salesforce_uuid"; + // kernel/website (src/lib/utm.ts) reads and writes the same cookie, so the + // name and format are shared: the UTM params as a query string. First touch + // on either site wins. + var UTM_COOKIE = "kernel_utm"; + var UTM_COOKIE_DOMAIN = "kernel.sh"; + var UTM_MAX_AGE = 30 * 24 * 60 * 60; + var UTM_HOST = "dashboard.onkernel.com"; + var UTM_KEYS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", + ]; + // Marks links we added the campaign to, so a withdrawal strips only ours + // and hand-tagged links keep theirs. + var UTM_DECORATED_ATTR = "data-utm-passthrough"; // Honouring Do Not Track has to cover the whole integration, not just the // vendor's events. respectDNT stops the pixel sending, but it doesn't stop @@ -51,23 +73,102 @@ return null; } + // Parses both the landing URL and the stored cookie, so the two can't + // disagree about what counts as a campaign. + function utmParamsFrom(search) { + var query = new URLSearchParams(search); + var params = new URLSearchParams(); + UTM_KEYS.forEach(function (key) { + var value = (query.get(key) || "").trim(); + if (value) params.set(key, value); + }); + return params.toString() ? params : null; + } + + function storedUtm() { + var cookie = document.cookie.match(/(?:^|;\s*)kernel_utm=([^;]*)/); + return cookie ? utmParamsFrom(cookie[1]) : null; + } + + // Shared across kernel.sh subdomains. Anywhere else (local preview) the + // browser rejects a .kernel.sh cookie, so it stays host-only there. + function writeUtmCookie(value, maxAge) { + var host = window.location.hostname; + var cookie = + UTM_COOKIE + + "=" + + value + + "; Path=/; Max-Age=" + + maxAge + + "; SameSite=Lax"; + if (host === UTM_COOKIE_DOMAIN || host.endsWith("." + UTM_COOKIE_DOMAIN)) { + cookie += "; Domain=." + UTM_COOKIE_DOMAIN; + } + if (window.location.protocol === "https:") cookie += "; Secure"; + document.cookie = cookie; + } + + // First touch wins: a later campaign never replaces the stored one. + function storeFirstTouch(landing) { + if (landing && !storedUtm()) { + writeUtmCookie(landing.toString(), UTM_MAX_AGE); + } + } + + // Appends the stored campaign to a dashboard URL, unless the link was + // tagged by hand. Returns whether it added anything. + function addUtm(url) { + if (url.hostname !== UTM_HOST) return false; + + var keys = Array.from(url.searchParams.keys()); + for (var i = 0; i < keys.length; i++) { + if (keys[i].indexOf("utm_") === 0) return false; + } + + var utm = storedUtm(); + if (!utm) return false; + + utm.forEach(function (value, key) { + url.searchParams.set(key, value); + }); + return true; + } + // A click rewrites the anchor in place, so a decorated href outlives it. A - // later decline stops new rewrites but leaves the identifier sitting in the - // DOM, so it has to be taken back out. + // later decline stops new rewrites but leaves the identifier and campaign + // sitting in the DOM, so they have to be taken back out. function undecorateLinks() { var links = document.querySelectorAll("a[href]"); for (var i = 0; i < links.length; i++) { try { var url = new URL(links[i].href, window.location.href); + var before = url.toString(); + + // delete() reserializes the whole query even when the key is absent. var param = handoffParamFor(url); - if (!param || !url.searchParams.has(param)) continue; + if (param && url.searchParams.has(param)) { + url.searchParams.delete(param); + } - url.searchParams.delete(param); - links[i].href = url.toString(); + if (links[i].hasAttribute(UTM_DECORATED_ATTR)) { + links[i].removeAttribute(UTM_DECORATED_ATTR); + UTM_KEYS.forEach(function (key) { + url.searchParams.delete(key); + }); + } + + if (url.toString() !== before) links[i].href = url.toString(); } catch (e) {} } } + // Declined or withdrawn: drop the stored campaign along with anything + // already written into links. + function withdraw() { + writeUtmCookie("", 0); + undecorateLinks(); + } + // true granted, false declined, null no decision recorded yet. // // c15t omits false values from its cookie rather than writing them, so a @@ -144,7 +245,7 @@ // open. There's no consent UI here to react to, so every navigation // re-reads the decision rather than trusting the one made at page load. if (storedConsent() === false) { - undecorateLinks(); + withdraw(); return; } @@ -165,40 +266,61 @@ // The device ID is stored per-domain, and the docs link to dashboard sign-up // in several places, so without this the ad click that led to a signup is - // never credited. Hand ours over on the way out; the dashboard reads it back. - function handOffDeviceIdOnNavigation() { - document.addEventListener( - "click", - function (event) { - var link = event.target.closest && event.target.closest("a[href]"); - if (!link || typeof window.ca.getDeviceId !== "function") return; - if (storedConsent() === false) { - undecorateLinks(); - return; - } + // never credited. Hand ours over on the way out, with the campaign that + // brought the visitor in; the dashboard reads both back. + function handOffOnNavigation() { + function decorate(event) { + var link = event.target.closest && event.target.closest("a[href]"); + if (!link) return; + if (storedConsent() === false) { + withdraw(); + return; + } - var url = new URL(link.href, window.location.href); - var param = handoffParamFor(url); - if (!param) return; + var url; + try { + url = new URL(link.href, window.location.href); + } catch (e) { + return; + } + var before = url.toString(); + var param = handoffParamFor(url); + if (param && typeof window.ca.getDeviceId === "function") { url.searchParams.set(param, window.ca.getDeviceId()); - link.href = url.toString(); - }, - { capture: true } - ); + } + if (addUtm(url)) link.setAttribute(UTM_DECORATED_ATTR, ""); + + if (url.toString() !== before) link.href = url.toString(); + } + + // auxclick covers a middle-click opening sign-up in a new tab. + document.addEventListener("click", decorate, { capture: true }); + document.addEventListener("auxclick", decorate, { capture: true }); } function start() { + storeFirstTouch(landingUtm); load(); trackNavigations(); - handOffDeviceIdOnNavigation(); + handOffOnNavigation(); + } + + // Nothing loads under Do Not Track, and a campaign stored before it was + // turned on is dropped. + if (doNotTrackEnabled()) { + writeUtmCookie("", 0); + return; } - if (doNotTrackEnabled()) return; + // Read before the consent check: the jurisdiction lookup below can resolve + // after a client-side navigation has dropped the query. + var landingUtm = utmParamsFrom(window.location.search); var stored = storedConsent(); if (stored !== null) { if (stored) start(); + else writeUtmCookie("", 0); return; } diff --git a/utm.js b/utm.js deleted file mode 100644 index 1be21364..00000000 --- a/utm.js +++ /dev/null @@ -1,169 +0,0 @@ -/* - * First-touch UTM passthrough to dashboard sign-up. - * - * Launch posts link straight to docs pages with UTM params, but sign-ups are - * tracked on the dashboard, so the params are lost at the hop. Store them on - * landing and append them to dashboard links on the way out. kernel/website - * writes the same kernel_utm cookie, so the first touch on either site wins. - * Mintlify loads every .js file in this directory on every docs page. - * - * This is attribution, so it follows the same rules as conceptual.js: nothing - * runs under Do Not Track, and otherwise it needs marketing consent. - */ -(function () { - var COOKIE_NAME = "kernel_utm"; - var COOKIE_DOMAIN = "kernel.sh"; - var MAX_AGE = 60 * 60 * 24 * 30; - var DASHBOARD_HOST = "dashboard.onkernel.com"; - var CONSENT_CATEGORY = "marketing"; - var JURISDICTION_URL = "/api/c15t/show-consent-banner"; - var UTM_KEYS = [ - "utm_source", - "utm_medium", - "utm_campaign", - "utm_content", - "utm_term", - ]; - - function doNotTrackEnabled() { - return ( - navigator.doNotTrack === "1" || - navigator.msDoNotTrack === "1" || - window.doNotTrack === "1" - ); - } - - // true granted, false declined, null no decision recorded yet. c15t omits - // declined categories from its record, so see conceptual.js for why a - // record without the category means no. - function storedConsent() { - try { - var cookie = document.cookie.match(/(?:^|;\s*)c15t=([^;]*)/); - if (cookie && cookie[1]) { - return cookie[1].indexOf("c." + CONSENT_CATEGORY + ":1") !== -1; - } - } catch (e) {} - - try { - var raw = localStorage.getItem("c15t"); - if (raw) { - var parsed = JSON.parse(raw); - if (parsed && parsed.consents) { - return parsed.consents[CONSENT_CATEGORY] === true; - } - } - } catch (e) {} - - return null; - } - - function readStored() { - try { - var match = document.cookie.match( - new RegExp("(?:^|;\\s*)" + COOKIE_NAME + "=([^;]*)") - ); - if (!match) return null; - var parsed = JSON.parse(decodeURIComponent(match[1])); - return parsed && typeof parsed === "object" ? parsed : null; - } catch (e) { - return null; - } - } - - // Shared across www.kernel.sh and the docs. Off kernel.sh (local preview) - // the browser would reject the domain, so fall back to a host-only cookie. - function writeCookie(value, maxAge) { - var host = location.hostname; - var cookie = - COOKIE_NAME + - "=" + - value + - "; path=/; max-age=" + - maxAge + - "; SameSite=Lax"; - if (host === COOKIE_DOMAIN || host.endsWith("." + COOKIE_DOMAIN)) { - cookie += "; domain=." + COOKIE_DOMAIN; - } - if (location.protocol === "https:") cookie += "; Secure"; - document.cookie = cookie; - } - - function landingParams() { - var params = new URLSearchParams(location.search); - var utm = null; - UTM_KEYS.forEach(function (key) { - var value = params.get(key); - if (value) { - utm = utm || {}; - utm[key] = value; - } - }); - return utm; - } - - // Delegated so it covers the navbar buttons and links rendered after - // client-side navigation. Capture phase rewrites the href before the - // browser follows it. - function decorate(event) { - var link = event.target.closest && event.target.closest("a[href]"); - if (!link) return; - - var url; - try { - url = new URL(link.href, location.href); - } catch (e) { - return; - } - if (url.hostname !== DASHBOARD_HOST) return; - - // Consent can be withdrawn on the marketing site while a docs page stays - // open, so re-read it rather than trusting the decision made at load. - if (storedConsent() === false) { - writeCookie("", 0); - return; - } - - var keys = Array.from(url.searchParams.keys()); - if (keys.some(function (key) { return key.indexOf("utm_") === 0; })) return; - - var utm = readStored(); - if (!utm) return; - - UTM_KEYS.forEach(function (key) { - if (utm[key]) url.searchParams.set(key, utm[key]); - }); - link.href = url.toString(); - } - - // First touch wins: a later campaign never replaces the stored one. - function start(landing) { - if (landing && !readStored()) { - writeCookie(encodeURIComponent(JSON.stringify(landing)), MAX_AGE); - } - document.addEventListener("click", decorate, { capture: true }); - document.addEventListener("auxclick", decorate, { capture: true }); - } - - if (doNotTrackEnabled()) return; - - // Read before the consent check: the jurisdiction lookup can resolve after a - // client-side navigation has dropped the query. - var landing = landingParams(); - - var stored = storedConsent(); - if (stored !== null) { - if (stored) start(landing); - return; - } - - // No decision yet, the normal case for a visitor whose first page is a docs - // page. Apply c15t's rule: it auto-grants outside regulated jurisdictions. - fetch(JURISDICTION_URL, { credentials: "same-origin" }) - .then(function (response) { - return response.ok ? response.json() : null; - }) - .then(function (data) { - if (data && data.showConsentBanner === false) start(landing); - }) - .catch(function () {}); -})();