diff --git a/netlify.toml b/netlify.toml
index ffa4c5f..294bb12 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -7,3 +7,26 @@
SITE_URL = "https://www.javascriptin30words.com"
# Static directory routes resolve without a client-side router or fallback.
+
+# First-party proxy for PostHog, so tracking-protection lists that match the
+# vendor hostname cannot silently drop analytics traffic. Paths and upstream
+# hosts are checked against src/analytics-config.js at build time.
+# The absolute URL in `to` sets the upstream host; Netlify drops a `host` key.
+# The asset rules must stay above the catch-all: order decides the match.
+[[redirects]]
+ from = "/e30/static/*"
+ to = "https://eu-assets.i.posthog.com/static/:splat"
+ status = 200
+ force = true
+
+[[redirects]]
+ from = "/e30/array/*"
+ to = "https://eu-assets.i.posthog.com/array/:splat"
+ status = 200
+ force = true
+
+[[redirects]]
+ from = "/e30/*"
+ to = "https://eu.i.posthog.com/:splat"
+ status = 200
+ force = true
diff --git a/scripts/build.mjs b/scripts/build.mjs
index bdef799..e215d7e 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -1,5 +1,6 @@
import { mkdir, rm, writeFile, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
import CleanCSS from 'clean-css';
import { minify } from 'terser';
import hljs from 'highlight.js/lib/core';
@@ -12,6 +13,15 @@ import { indexNowKey } from '../src/search-config.js';
hljs.registerLanguage('javascript', javascript);
const output = new URL('../build/', import.meta.url);
+// Crawlers only trust lastmod when it tracks real content changes, so it comes
+// from the last commit touching the copy rather than from the build clock.
+const lastModified = (() => {
+ try {
+ const committed = execFileSync('git', ['log', '-1', '--format=%cI', '--', 'src/data.js'], {cwd:new URL('../', import.meta.url), encoding:'utf8', stdio:['ignore','pipe','ignore']}).trim();
+ if (committed) return committed.slice(0,10);
+ } catch { /* Not a git checkout; fall back to today. */ }
+ return new Date().toISOString().slice(0,10);
+})();
const requestedOrigin = new URL(process.env.SITE_URL || 'https://www.javascriptin30words.com');
if (!['http:', 'https:'].includes(requestedOrigin.protocol)) throw new Error('SITE_URL must be an HTTP(S) URL.');
const origin = requestedOrigin.origin;
@@ -41,6 +51,16 @@ const aiPanelPath = `/assets/ai-panel.${createHash('sha256').update(aiPanel).dig
if (analyticsConfig.googleMeasurementId && !/^G-[A-Z0-9]+$/.test(analyticsConfig.googleMeasurementId)) throw new Error('Invalid public Google measurement ID.');
if (analyticsConfig.posthogProjectToken && !/^phc_[A-Za-z0-9]+$/.test(analyticsConfig.posthogProjectToken)) throw new Error('Use a public PostHog project token, never a personal API key.');
if (!['https://eu.i.posthog.com','https://us.i.posthog.com'].includes(analyticsConfig.posthogHost)) throw new Error('Invalid PostHog ingestion host.');
+if (!/^\/[a-z0-9-]{2,20}$/.test(analyticsConfig.posthogProxyPath)) throw new Error('PostHog proxy path must be a single lowercase path segment.');
+// The browser talks only to posthogProxyPath, so a missing or stale redirect
+// would silently 404 every event. Fail the build instead of the analytics.
+const netlifyConfig = await readFile(new URL('../netlify.toml', import.meta.url), 'utf8');
+const assetsHost = analyticsConfig.posthogHost.replace('.i.posthog.com','-assets.i.posthog.com');
+for (const [from, to] of [[`${analyticsConfig.posthogProxyPath}/static/*`, `${assetsHost}/static/:splat`],
+ [`${analyticsConfig.posthogProxyPath}/array/*`, `${assetsHost}/array/:splat`],
+ [`${analyticsConfig.posthogProxyPath}/*`, `${analyticsConfig.posthogHost}/:splat`]]) {
+ if (!netlifyConfig.includes(`from = "${from}"`) || !netlifyConfig.includes(`to = "${to}"`)) throw new Error(`netlify.toml is missing the PostHog proxy rule ${from} -> ${to}`);
+}
const analyticsSource = (await readFile(new URL('../src/analytics.js', import.meta.url), 'utf8')).replace("import {analyticsConfig as config} from './analytics-config.js';", `const config = ${JSON.stringify(analyticsConfig)};`);
const analytics = (await minify(analyticsSource, {module:true})).code;
const analyticsPath = `/assets/analytics.${createHash('sha256').update(analytics).digest('hex').slice(0,12)}.js`;
@@ -236,7 +256,7 @@ for (const [index, concept] of definitions.entries()) {
await writeFile(new URL('index.html', directory), conceptPage(concept,index));
}
await writeFile(new URL('404.html', output), document({title:'Page Not Found', description:'Find a JavaScript concept in our quick reference.', path:'/404.html', noindex:true, content:'
404
That page isn’t here.
Find the explanation you need in the concept library.
Browse all concepts → '}));
-await writeFile(new URL('sitemap.xml', output), `\n${['/', ...definitions.map(pathFor)].map((path) => `${url(path)}`).join('')}\n`);
+await writeFile(new URL('sitemap.xml', output), `\n${['/', ...definitions.map(pathFor)].map((path) => `${url(path)}${lastModified}`).join('')}\n`);
await writeFile(new URL('robots.txt', output), `User-agent: *\nAllow: /\n\nSitemap: ${url('/sitemap.xml')}\n`);
if (!/^[a-f0-9]{32}$/.test(indexNowKey)) throw new Error('Invalid IndexNow verification key.');
await writeFile(new URL(`${indexNowKey}.txt`, output), indexNowKey);
diff --git a/scripts/security.mjs b/scripts/security.mjs
index 8a2f888..726d3b7 100644
--- a/scripts/security.mjs
+++ b/scripts/security.mjs
@@ -1,5 +1,5 @@
export const securityHeaders = {
'X-Content-Type-Options':'nosniff',
'Referrer-Policy':'strict-origin-when-cross-origin',
- 'Content-Security-Policy':"default-src 'self'; script-src 'self' https://www.googletagmanager.com https://eu-assets.i.posthog.com https://us-assets.i.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://api.openai.com https://api.anthropic.com https://*.google-analytics.com https://www.googletagmanager.com https://eu.i.posthog.com https://us.i.posthog.com https://eu-assets.i.posthog.com https://us-assets.i.posthog.com; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+ 'Content-Security-Policy':"default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://api.openai.com https://api.anthropic.com https://*.google-analytics.com https://www.googletagmanager.com; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
};
diff --git a/src/analytics-config.js b/src/analytics-config.js
index ee51124..6832e15 100644
--- a/src/analytics-config.js
+++ b/src/analytics-config.js
@@ -6,5 +6,9 @@ export const analyticsConfig = {
googleMeasurementId:'G-9W8VGXXL0G',
posthogProjectToken:'phc_kYNSabXwPMuoR48Kmb6oUy7MfwPgBx88pWqHS3T8vpAn',
posthogHost:'https://eu.i.posthog.com',
+ posthogUiHost:'https://eu.posthog.com',
+ // Same-origin path that netlify.toml proxies to posthogHost. Deliberately not
+ // a guessable word like "analytics", which blocker lists match on directly.
+ posthogProxyPath:'/e30',
productionHosts:['www.javascriptin30words.com','javascriptin30words.com','javascript-in-30-words.netlify.app'],
};
diff --git a/src/analytics.js b/src/analytics.js
index d2fcf2b..f4da148 100644
--- a/src/analytics.js
+++ b/src/analytics.js
@@ -1,8 +1,11 @@
import {analyticsConfig as config} from './analytics-config.js';
export const consentKey = 'js30.analytics-consent.v1';
-const allowedEvents = new Set(['$pageview','practice_pad_click','contact_click','output_revealed','ai_panel_opened']);
+const allowedEvents = new Set(['$pageview','$pageleave','$web_vitals','practice_pad_click','contact_click','output_revealed','ai_panel_opened']);
const campaignKeys = ['utm_source','utm_medium','utm_campaign','utm_content','utm_term'];
+// Core Web Vitals PostHog charts. Declared once so the metrics we ask the
+// browser for and the properties we let through stay the same list.
+const webVitalsMetrics = ['LCP','CLS','FCP','INP'];
export function safePageURL(value) {
try {
@@ -24,10 +27,18 @@ export function sanitizePosthogEvent(event) {
'$lib','$lib_version','$browser','$browser_version','$os','$os_version','$device_type',
'$screen_height','$screen_width','$viewport_height','$viewport_width','$timezone',
'$host','$pathname','$title','$is_identified','$process_person_profile','page_path','page_title',
- 'concept','placement','destination',...campaignKeys]);
+ 'concept','placement','destination',...campaignKeys,
+ ...webVitalsMetrics.map(name => `$web_vitals_${name}_value`)]);
const properties = Object.fromEntries(Object.entries(event.properties || {}).filter(([key,value]) => keep.has(key) && ['string','number','boolean'].includes(typeof value)));
properties.$current_url = safePageURL(event.properties?.$current_url || '');
- try { properties.$referrer = new URL(event.properties?.$referrer).origin; } catch { properties.$referrer = ''; }
+ const referrer = event.properties?.$referrer;
+ if (referrer === '$direct') { properties.$referrer = '$direct'; properties.$referring_domain = '$direct'; }
+ else try {
+ const source = new URL(referrer);
+ properties.$referrer = source.origin;
+ // Derived rather than copied so a spoofed property can never reach PostHog.
+ properties.$referring_domain = source.hostname;
+ } catch { properties.$referrer = ''; properties.$referring_domain = ''; }
return {...event,properties};
}
@@ -85,6 +96,9 @@ export function startAnalytics(win = window, doc = document, settings = config)
script(`https://www.googletagmanager.com/gtag/js?id=${settings.googleMeasurementId}`);
}
if (settings.posthogProjectToken) {
+ // A same-origin path rather than the PostHog domain: blocker lists match on
+ // the vendor hostname, and traffic they drop never reaches the reports.
+ const apiHost = win.location.origin + settings.posthogProxyPath;
// The official snippet's initialization queue, loaded only after consent.
const stub = [];
stub._i = [];
@@ -93,9 +107,12 @@ export function startAnalytics(win = window, doc = document, settings = config)
stub.toString = () => 'posthog (stub)';
win.posthog = stub;
const options = {
- api_host:settings.posthogHost,defaults:'2026-05-30',
- autocapture:false,capture_pageview:false,capture_pageleave:false,
- capture_dead_clicks:false,capture_heatmaps:false,capture_performance:false,
+ api_host:apiHost,ui_host:settings.posthogUiHost,defaults:'2026-05-30',
+ autocapture:false,capture_pageview:false,capture_pageleave:true,
+ // Web vitals only: no resource timing, and no attribution bundle, which
+ // would collect the DOM element behind each measurement.
+ capture_performance:{web_vitals:true,network_timing:false,web_vitals_attribution:false,web_vitals_allowed_metrics:webVitalsMetrics},
+ capture_dead_clicks:false,capture_heatmaps:false,
capture_exceptions:false,disable_session_recording:true,disable_surveys:true,
enable_recording_console_log:false,advanced_disable_feature_flags:true,
person_profiles:'never',persistence:'localStorage',
@@ -107,7 +124,7 @@ export function startAnalytics(win = window, doc = document, settings = config)
},
};
stub._i.push([settings.posthogProjectToken,options,'posthog']);
- script(`${settings.posthogHost.replace('.i.posthog.com','-assets.i.posthog.com')}/static/array.js`);
+ script(`${apiHost}/static/array.js`);
}
track('$pageview');
};
diff --git a/test/analytics.test.mjs b/test/analytics.test.mjs
index 9b0777c..49dbd0d 100644
--- a/test/analytics.test.mjs
+++ b/test/analytics.test.mjs
@@ -2,14 +2,14 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {startAnalytics,safePageURL,sanitizePosthogEvent,consentKey} from '../src/analytics.js';
-const settings = {googleMeasurementId:'G-TEST123',posthogProjectToken:'phc_test',posthogHost:'https://eu.i.posthog.com',productionHosts:['www.javascriptin30words.com']};
+const settings = {googleMeasurementId:'G-TEST123',posthogProjectToken:'phc_test',posthogHost:'https://eu.i.posthog.com',posthogUiHost:'https://eu.posthog.com',posthogProxyPath:'/e30',productionHosts:['www.javascriptin30words.com']};
function harness({hostname='www.javascriptin30words.com',choice=null,storageBlocked=false,suppressConsentPrompt=false} = {}) {
const scripts = [], events = new Map(), elements = new Map(), stored = new Map(choice ? [[consentKey,choice]] : []);
for (const selector of ['#analytics-consent','#analytics-preferences','#analytics-allow','#analytics-decline','.output-toggle','[data-ai-panel]','[data-analytics-page]']) {
elements.set(selector,{hidden:true,dataset:{analyticsPage:'/javascript-closures/',analyticsConcept:'javascript-closures'},addEventListener:(name,fn)=>events.set(selector+name,fn),focus(){}});
}
const win = {
- location:{protocol:hostname === '127.0.0.1' ? 'http:' : 'https:',hostname,href:`https://${hostname}/javascript-closures/?api_key=private#secret`,reload(){win.reloaded=true;}},
+ location:{protocol:hostname === '127.0.0.1' ? 'http:' : 'https:',hostname,origin:`https://${hostname}`,href:`https://${hostname}/javascript-closures/?api_key=private#secret`,reload(){win.reloaded=true;}},
localStorage:{getItem:key=>{if(storageBlocked) throw Error('blocked');return stored.get(key) ?? null;},setItem:(key,value)=>{if(storageBlocked) throw Error('blocked');stored.set(key,value);},get length(){return stored.size;},key:index=>[...stored.keys()][index],removeItem:key=>stored.delete(key)},
addEventListener:(name,fn)=>events.set('window'+name,fn),
};
@@ -30,11 +30,58 @@ test('analytics strips arbitrary query strings, fragments, and personal data',()
assert.equal(event.properties.$set,undefined);
assert.equal(event.properties.$current_url,'https://site.example/');
assert.equal(event.properties.$referrer,'https://search.example');
+ assert.equal(event.properties.$referring_domain,'search.example');
assert.equal(event.properties.token,'phc_test');
assert.equal(sanitizePosthogEvent({event:'$snapshot',properties:{}}),null);
assert.equal(sanitizePosthogEvent({event:'$autocapture',properties:{}}),null);
});
+test('referrer attribution survives sanitizing so PostHog can group traffic by channel',()=>{
+ const direct = sanitizePosthogEvent({event:'$pageview',properties:{$referrer:'$direct',$referring_domain:'$direct'}});
+ assert.equal(direct.properties.$referrer,'$direct','Direct visits stay distinguishable from stripped ones');
+ assert.equal(direct.properties.$referring_domain,'$direct');
+ const search = sanitizePosthogEvent({event:'$pageview',properties:{$referrer:'https://www.google.com/search?q=private',$referring_domain:'www.google.com'}});
+ assert.equal(search.properties.$referrer,'https://www.google.com','Query strings never leave the browser');
+ assert.equal(search.properties.$referring_domain,'www.google.com');
+ const spoofed = sanitizePosthogEvent({event:'$pageview',properties:{$referrer:'https://github.com/msmfa',$referring_domain:'evil.example'}});
+ assert.equal(spoofed.properties.$referring_domain,'github.com','Domain is derived from the referrer, never copied');
+ const missing = sanitizePosthogEvent({event:'$pageview',properties:{}});
+ assert.equal(missing.properties.$referrer,'');
+ assert.equal(missing.properties.$referring_domain,'');
+ assert.ok(sanitizePosthogEvent({event:'$pageleave',properties:{}}),'Pageleave is needed for session duration and bounce rate');
+});
+
+test('web vitals are measured but their attribution payloads never leave the browser',()=>{
+ const h = harness({choice:'granted'});
+ const performance = h.win.posthog._i[0][1].capture_performance;
+ assert.equal(performance.web_vitals,true,'Core Web Vitals feed the PostHog performance charts');
+ assert.equal(performance.network_timing,false,'Resource timing is not needed and is not collected');
+ assert.equal(performance.web_vitals_attribution,false,'Attribution would record the DOM element behind each metric');
+ const event = sanitizePosthogEvent({event:'$web_vitals',properties:{
+ $web_vitals_LCP_value:2350.5,$web_vitals_CLS_value:0.02,$web_vitals_FCP_value:900,$web_vitals_INP_value:120,
+ $web_vitals_LCP_event:{name:'LCP',attribution:{element:'#hero > img',url:'https://site.example/?key=secret'}},
+ $current_url:'https://site.example/javascript-closures/?api_key=private',
+ }});
+ assert.equal(event.properties.$web_vitals_LCP_value,2350.5);
+ assert.equal(event.properties.$web_vitals_CLS_value,0.02);
+ assert.equal(event.properties.$web_vitals_FCP_value,900);
+ assert.equal(event.properties.$web_vitals_INP_value,120);
+ assert.equal(event.properties.$web_vitals_LCP_event,undefined,'The attribution object carries DOM selectors and URLs');
+ assert.equal(event.properties.$current_url,'https://site.example/javascript-closures/');
+ assert.deepEqual(performance.web_vitals_allowed_metrics.map(name=>`$web_vitals_${name}_value`).filter(key=>!(key in event.properties)),[],
+ 'Every metric we request also survives sanitizing');
+});
+
+test('PostHog loads and ingests through the same origin so blockers cannot drop it',()=>{
+ const h = harness({choice:'granted'});
+ const options = h.win.posthog._i[0][1];
+ assert.equal(options.api_host,'https://www.javascriptin30words.com/e30');
+ assert.equal(options.ui_host,'https://eu.posthog.com','Links into PostHog still point at the real app');
+ const loader = h.scripts.map(element=>element.src).find(src=>src.includes('/e30/'));
+ assert.equal(loader,'https://www.javascriptin30words.com/e30/static/array.js');
+ assert.ok(!h.scripts.some(element=>element.src.includes('posthog.com')),'No request reveals the vendor hostname');
+});
+
test('local and deploy preview visits never load analytics, even with remembered consent',()=>{
for (const hostname of ['127.0.0.1','localhost','deploy-preview-19--javascript-in-30-words.netlify.app','javascriptin30words.com.evil.example']) {
const h = harness({hostname,choice:'granted'});
@@ -84,6 +131,7 @@ test('consent loads both vendors once with one page view and explicit safe event
assert.equal(phOptions.autocapture,false);
assert.equal(phOptions.disable_session_recording,true);
assert.equal(phOptions.capture_exceptions,false);
+ assert.equal(phOptions.capture_pageleave,true);
const captures = [];
h.win.posthog = {capture:(...args)=>captures.push(args),opt_out_capturing(){}};
phOptions.loaded(h.win.posthog);
diff --git a/test/site.test.mjs b/test/site.test.mjs
index 34ca5b5..1dbbec6 100644
--- a/test/site.test.mjs
+++ b/test/site.test.mjs
@@ -70,6 +70,7 @@ test('sitemap and homepage expose every concept to crawlers', () => {
const sitemap = read('sitemap.xml');
const html = read('index.html');
assert.equal((sitemap.match(//g) || []).length, 36);
+ assert.equal((sitemap.match(/\d{4}-\d{2}-\d{2}<\/lastmod>/g) || []).length, 36, 'Every URL carries a crawlable lastmod');
for (const concept of definitions) {
assert.ok(sitemap.includes(`${expectedOrigin}/${concept.slug}/`));
assert.ok(html.includes(`href="/${concept.slug}/"`));