Skip to content
Merged
Show file tree
Hide file tree
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
23 changes: 23 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 21 additions & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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:'<div class="page-heading"><p class="eyebrow">404</p><h1>That page isn’t here.</h1><p class="lead">Find the explanation you need in the concept library.</p><a class="back-link" href="/">Browse all concepts →</a></div>'}));
await writeFile(new URL('sitemap.xml', output), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', ...definitions.map(pathFor)].map((path) => `<url><loc>${url(path)}</loc></url>`).join('')}</urlset>\n`);
await writeFile(new URL('sitemap.xml', output), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', ...definitions.map(pathFor)].map((path) => `<url><loc>${url(path)}</loc><lastmod>${lastModified}</lastmod></url>`).join('')}</urlset>\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);
Expand Down
2 changes: 1 addition & 1 deletion scripts/security.mjs
Original file line number Diff line number Diff line change
@@ -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'",
};
4 changes: 4 additions & 0 deletions src/analytics-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};
31 changes: 24 additions & 7 deletions src/analytics.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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};
}

Expand Down Expand Up @@ -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 = [];
Expand All @@ -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',
Expand All @@ -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');
};
Expand Down
52 changes: 50 additions & 2 deletions test/analytics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand All @@ -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'});
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions test/site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<loc>/g) || []).length, 36);
assert.equal((sitemap.match(/<lastmod>\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(`<loc>${expectedOrigin}/${concept.slug}/</loc>`));
assert.ok(html.includes(`href="/${concept.slug}/"`));
Expand Down