diff --git a/scripts/build.mjs b/scripts/build.mjs index e215d7e..d5718d4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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(); @@ -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/'); diff --git a/src/analytics.js b/src/analytics.js index 6663c9d..8406005 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -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 { @@ -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}; @@ -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); }; @@ -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 @@ -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 = () => { diff --git a/test/analytics.test.mjs b/test/analytics.test.mjs index 192e368..fec2450 100644 --- a/test/analytics.test.mjs +++ b/test/analytics.test.mjs @@ -3,8 +3,9 @@ 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(){}}); } @@ -12,13 +13,22 @@ function harness({hostname='www.javascriptin30words.com',choice=null,storageBloc 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',()=>{ @@ -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]); @@ -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); diff --git a/test/site.test.mjs b/test/site.test.mjs index 1dbbec6..c175555 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -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(/