Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 148 additions & 26 deletions conceptual.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
Loading