Skip to content
Open
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
24 changes: 23 additions & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const escapeHTML = (value) => String(value).replace(/[&<>"']/g, (character) => (
const url = (path = '/') => origin + path;
const pathFor = (concept) => `/${concept.slug}/`;
const labelFor = (concept) => concept.id === 'this' ? 'this keyword' : concept.label;
// schema.org only defines Beginner and Expert for proficiencyLevel, which maps
// onto the two groups the concepts are already sorted into.
const proficiencyFor = (concept) => concept.group === 'Advanced' ? 'Expert' : 'Beginner';
const summaryFor = (concept) => [concept.text, ...(concept.definitionItems || [])].filter(Boolean).join(' ');
const searchDescription = (concept) => {
const text = `${concept.label} in JavaScript: ${concept.text || concept.explanation}`.replace(/\s+/g, ' ').trim();
Expand Down Expand Up @@ -140,15 +143,34 @@ function shareMenu(title, path) {

function document({ title, description, path, content, current, noindex = false }) {
const pageTitle = `${title} | ${brand}`;
// WebSite/WebPage/BreadcrumbList only say where a page sits. DefinedTerm says
// what it holds: this URL is the definition of one named term, and the set on
// the home page is the glossary those 35 terms belong to. TechArticle carries
// the prose around the definition.
const structuredData = {
'@context':'https://schema.org',
'@graph':[
{'@type':'WebSite','@id':url('/#website'),url:url('/'),name:brand,inLanguage:'en'},
{'@type':'WebPage','@id':url(path),url:url(path),name:title,description,inLanguage:'en',isPartOf:{'@id':url('/#website')},...(current ? {breadcrumb:{'@id':url(path+'#breadcrumb')}} : {})},
{'@type':'WebPage','@id':url(path),url:url(path),name:title,description,inLanguage:'en',isPartOf:{'@id':url('/#website')},
...(current ? {breadcrumb:{'@id':url(path+'#breadcrumb')},mainEntity:{'@id':url(path+'#article')}} : {}),
...(!current && !noindex ? {mainEntity:{'@id':url('/#glossary')}} : {})},
...(current ? [{'@type':'BreadcrumbList','@id':url(path+'#breadcrumb'),itemListElement:[
{'@type':'ListItem',position:1,name:'All concepts',item:url('/')},
{'@type':'ListItem',position:2,name:labelFor(current),item:url(path)},
]}] : []),
...(current ? [
{'@type':'TechArticle','@id':url(path+'#article'),url:url(path),headline:current.heading,description,
inLanguage:'en',isPartOf:{'@id':url('/#website')},about:{'@id':url(path+'#term')},
proficiencyLevel:proficiencyFor(current),dateModified:lastModified},
{'@type':'DefinedTerm','@id':url(path+'#term'),url:url(path),name:labelFor(current),
description:summaryFor(current),inDefinedTermSet:{'@id':url('/#glossary')},
// The MDN page for the same term, so the definition is tied to the
// reference every reader already trusts.
...(current.reference ? {sameAs:current.reference} : {})},
] : []),
...(!current && !noindex ? [{'@type':'DefinedTermSet','@id':url('/#glossary'),url:url('/'),name:brand,
description,inLanguage:'en',
hasDefinedTerm:definitions.map((item) => ({'@id':url(pathFor(item)+'#term')}))}] : []),
],
};
const practiceURL = new URL('https://www.practice-pad.app/');
Expand Down
31 changes: 29 additions & 2 deletions src/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const campaignKeys = ['utm_source','utm_medium','utm_campaign','utm_content','ut
// 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'];
// How long the vendor bundles may wait for an idle moment after load before we
// stop being polite and fetch them anyway. Long enough to clear the paint, short
// enough that a quick bounce is still recorded.
const idleDeadline = 1500;

export function safePageURL(value) {
try {
Expand Down Expand Up @@ -72,6 +76,23 @@ export function startAnalytics(win = window, doc = document, settings = config)
if (onload) element.onload = onload;
doc.head.append(element);
};
// The vendor bundles are a quarter of a megabyte of parse work, and nothing
// they do matters before the page is visible. Fetching them during the first
// paint is what stretches Largest Contentful Paint on a throttled phone. Both
// vendor queues are built synchronously in start(), so events recorded while
// this wait runs are replayed once the bundles arrive rather than dropped.
const afterPaint = (run) => {
let done = false;
const fire = () => { if (done) return; done = true; run(); };
const schedule = () => {
// Safari below 16.4 has no requestIdleCallback. Load has already fired by
// this point, so the next task is still clear of the paint we protect.
if (typeof win.requestIdleCallback === 'function') win.requestIdleCallback(fire,{timeout:idleDeadline});
else win.setTimeout(fire,0);
};
if (doc.readyState === 'complete') schedule();
else win.addEventListener('load',schedule,{once:true});
};
const track = (event,details = {}) => {
if (consent !== 'granted' || !allowedEvents.has(event)) return;
const props = {...page,...details};
Expand All @@ -84,6 +105,9 @@ export function startAnalytics(win = window, doc = document, settings = config)
const start = () => {
if (started || consent !== 'granted') return;
started = true;
// Collected rather than injected on the spot: the shims and queues below
// must exist immediately, the downloads they feed must not.
const loaders = [];
if (settings.googleMeasurementId) {
win.dataLayer = win.dataLayer || [];
win.gtag = function() { win.dataLayer.push(arguments); };
Expand All @@ -96,7 +120,7 @@ export function startAnalytics(win = window, doc = document, settings = config)
page_location:safePageURL(win.location.href),page_referrer:referrer,
cookie_flags:'SameSite=Lax;Secure',cookie_expires:60 * 60 * 24 * 180,
});
script(`https://www.googletagmanager.com/gtag/js?id=${settings.googleMeasurementId}`);
loaders.push(() => 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
Expand Down Expand Up @@ -127,8 +151,11 @@ export function startAnalytics(win = window, doc = document, settings = config)
},
};
stub._i.push([settings.posthogProjectToken,options,'posthog']);
script(`${apiHost}/static/array.js`);
loaders.push(() => script(`${apiHost}/static/array.js`));
}
afterPaint(() => { for (const load of loaders) load(); });
// Queued synchronously so the view is recorded at the moment it happened,
// not at the moment the bundles finish arriving.
track('$pageview');
};
const clearAnalyticsStorage = () => {
Expand Down
51 changes: 49 additions & 2 deletions test/analytics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@ 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',posthogUiHost:'https://eu.posthog.com',posthogProxyPath:'/e30',productionHosts:['www.javascriptin30words.com']};
function harness({hostname='www.javascriptin30words.com',choice=null,storageBlocked=false,suppressConsentPrompt=false} = {}) {
function harness({hostname='www.javascriptin30words.com',choice=null,storageBlocked=false,suppressConsentPrompt=false,paint=true,idleCallback=true,timers=[]} = {}) {
const scripts = [], events = new Map(), elements = new Map(), stored = new Map(choice ? [[consentKey,choice]] : []);
const idle = [];
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,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),
setTimeout:fn=>timers.push(fn),
...(idleCallback ? {requestIdleCallback:fn=>idle.push(fn)} : {}),
};
const doc = {
readyState:'loading',
querySelector:selector=>elements.get(selector),title:'Closures | JavaScript in 30 Words',referrer:'https://search.example/results?q=private',cookie:'',
createElement:()=>({}),head:{append:element=>scripts.push(element)},addEventListener:(name,fn)=>events.set('document'+name,fn),
};
startAnalytics(win,doc,{...settings,suppressConsentPrompt});
return {win,doc,scripts,stored,elements,events,click:selector=>events.get(selector+'click')()};
// The vendor bundles are held back until the page has painted, so a test that
// wants to see them has to let the page finish loading and then go idle.
// Consent given after load starts the vendors on an already-complete
// document, so readyState flips here the way a real one would.
const finishPaint = () => { doc.readyState = 'complete'; events.get('windowload')?.(); while (idle.length) idle.shift()(); };
if (paint) finishPaint();
return {win,doc,scripts,stored,elements,events,finishPaint,click:selector=>events.get(selector+'click')()};
}

test('analytics strips arbitrary query strings, fragments, and personal data',()=>{
Expand Down Expand Up @@ -118,9 +128,45 @@ test('temporary no-prompt mode initializes analytics while preserving opt-outs a
assert.equal(h.win['ga-disable-G-TEST123'],true);
});

test('vendor bundles wait for the paint, and events raised while they wait are kept',()=>{
const h = harness({choice:'granted',paint:false});
// Nothing has been fetched yet, but both queues already exist.
assert.equal(h.scripts.length,0);
assert.equal(typeof h.win.gtag,'function');
assert.ok(h.win.posthog?.__SV,'The PostHog stub queue is installed up front');
assert.equal(h.win.dataLayer.filter(args=>args[0] === 'event' && args[1] === 'page_view').length,1);

h.finishPaint();
assert.equal(h.scripts.length,2);
const sources = h.scripts.map(element=>element.src);
assert.ok(sources.some(src=>src.includes('googletagmanager.com/gtag/js')));
assert.ok(sources.some(src=>src.endsWith('/e30/static/array.js')));

// PostHog replays what was captured before its bundle arrived.
const [,options] = h.win.posthog._i[0];
const captured = [];
options.loaded({capture:(event,props)=>captured.push([event,props]),opt_out_capturing(){}});
assert.deepEqual(captured.map(([event])=>event),['$pageview']);
});

test('a browser without requestIdleCallback still loads the vendors after the paint',()=>{
const timers = [];
const h = harness({choice:'granted',paint:false,idleCallback:false,timers});
assert.equal(h.scripts.length,0);
h.finishPaint();
assert.equal(h.scripts.length,0,'Safari waits for the timer rather than an idle callback');
while (timers.length) timers.shift()();
assert.equal(h.scripts.length,2);
});

test('consent loads both vendors once with one page view and explicit safe events',()=>{
const h = harness();
h.click('#analytics-allow');
// The page view is queued the moment consent is given, before either bundle
// has been asked for, which is the whole point of holding them back.
assert.equal(h.scripts.length,0);
assert.equal(h.win.dataLayer.filter(args=>args[0] === 'event' && args[1] === 'page_view').length,1);
h.finishPaint();
assert.equal(h.scripts.length,2);
assert.ok(h.scripts.every(script=>script.async && script.referrerPolicy === 'no-referrer'));
const commands = h.win.dataLayer.map(args=>[...args]);
Expand Down Expand Up @@ -165,6 +211,7 @@ test('withdrawal stops a pending PostHog load and clears only analytics storage'
test('consent works with blocked storage and withdrawal synchronizes across tabs',()=>{
const h = harness({storageBlocked:true});
h.click('#analytics-allow');
h.finishPaint();
assert.equal(h.scripts.length,2);
h.events.get('windowstorage')({key:consentKey,newValue:'denied'});
assert.equal(h.win['ga-disable-G-TEST123'],true);
Expand Down
34 changes: 34 additions & 0 deletions test/site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ test('indexable pages have one heading, unique metadata, and accurate structured
assert.match(read('_redirects'),/^https:\/\/javascript-in-30-words\.netlify\.app\/\*/);
});

test('every concept declares itself a defined term inside the glossary the home page sets out',()=>{
const graphOf = (file)=>JSON.parse(read(file).match(/<script type="application\/ld\+json">([\s\S]+?)<\/script>/)[1])['@graph'];
const node = (graph,type)=>graph.find(item=>item['@type'] === type);

const glossary = node(graphOf('index.html'),'DefinedTermSet');
assert.equal(glossary['@id'],expectedOrigin+'/#glossary');
assert.equal(node(graphOf('index.html'),'WebPage').mainEntity['@id'],glossary['@id']);
assert.equal(glossary.hasDefinedTerm.length,definitions.length);

const declared = new Set(glossary.hasDefinedTerm.map(item=>item['@id']));
for (const concept of definitions) {
const path = `/${concept.slug}/`;
const graph = graphOf(concept.slug+'/index.html');
const term = node(graph,'DefinedTerm');
const article = node(graph,'TechArticle');

// The set on the home page and the term on the page must agree, or the
// glossary points at terms that never claim membership.
assert.ok(declared.has(term['@id']),`${concept.slug} is missing from the glossary`);
assert.equal(term['@id'],expectedOrigin+path+'#term');
assert.equal(term.inDefinedTermSet['@id'],glossary['@id']);
assert.equal(term.url,expectedOrigin+path);
assert.ok(term.description.length > 0);
if (concept.reference) assert.equal(term.sameAs,concept.reference);

assert.equal(article['@id'],expectedOrigin+path+'#article');
assert.equal(article.about['@id'],term['@id']);
assert.equal(article.headline,concept.heading);
assert.equal(node(graph,'WebPage').mainEntity['@id'],article['@id']);
assert.ok(['Beginner','Expert'].includes(article.proficiencyLevel));
assert.match(article.dateModified,/^\d{4}-\d{2}-\d{2}$/);
}
});

test('every footer has unique Practice Pad campaign attribution and the contact address',()=>{
const contents = new Set();
for (const file of ['index.html',...definitions.map(item=>`${item.slug}/index.html`)]) {
Expand Down