From 1df22e4dedb2a1ba3cbd52e09f808c2979528204 Mon Sep 17 00:00:00 2001 From: Michael Sydney Moore Date: Fri, 11 Sep 2026 13:04:48 +0100 Subject: [PATCH 1/2] Date sitemap entries from the last content commit Every sitemap URL now carries a `lastmod`. The date comes from the last commit that touched `src/data.js` rather than from the build clock, so a rebuild that changes nothing does not tell crawlers the whole site was updated. Outside a git checkout the build falls back to today's date. --- scripts/build.mjs | 12 +++++++++++- test/site.test.mjs | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index bdef799..1eb1845 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; @@ -236,7 +246,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/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}/"`)); From 777f598a91ebe0e05d4e6f77fce04891d1445a61 Mon Sep 17 00:00:00 2001 From: Michael Sydney Moore Date: Fri, 11 Sep 2026 13:05:00 +0100 Subject: [PATCH 2/2] Fix the three PostHog installation health warnings PostHog's installation health check flagged three gaps in how the site reports. All three are fixed here. Bounce rate and session duration were unreliable because the site never sent `$pageleave`, so PostHog could not tell a one-page visit from a long read. Turn `capture_pageleave` on and let the event through the sanitizer. Core Web Vitals were not measured at all. Turn on web vitals capture for LCP, CLS, FCP and INP, and allow their numeric readings through. The matching `$web_vitals__event` objects carry attribution detail, including the DOM element behind each measurement, and stay out: the attribution bundle is switched off and the sanitizer's existing rule that only scalars pass keeps them from being sent even if that changes. Requests went straight to `eu.i.posthog.com`, which tracking-protection lists match on by hostname, so an unknown share of visits never arrived. Route both the library and ingestion through `/e30` on the site's own origin, proxied to PostHog by netlify.toml. The path is derived from `window.location.origin` so each production hostname stays same-origin rather than pointing at one canonical domain. Because nothing now loads from the PostHog domains, they come out of the CSP and `'self'` covers them. A stale redirect would silently 404 every event, so the build checks netlify.toml against the configured proxy path and region and fails if they have drifted. --- netlify.toml | 23 ++++++++++++++++++ scripts/build.mjs | 10 ++++++++ scripts/security.mjs | 2 +- src/analytics-config.js | 4 ++++ src/analytics.js | 31 ++++++++++++++++++------ test/analytics.test.mjs | 52 +++++++++++++++++++++++++++++++++++++++-- 6 files changed, 112 insertions(+), 10 deletions(-) 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 1eb1845..e215d7e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -51,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`; 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);