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}/"`));