From 4afa300b7b1a0ef5ff0e8a3e286a1f449dbe9a34 Mon Sep 17 00:00:00 2001 From: Michael Sydney Moore Date: Thu, 10 Sep 2026 14:27:05 +0100 Subject: [PATCH] Improve search discovery and refine scrolling and analytics testing --- README.md | 14 ++++++++--- package.json | 1 + scripts/build.mjs | 27 +++++++++++++++++++-- scripts/submit-indexnow.mjs | 19 +++++++++++++++ src/analytics-config.js | 3 +++ src/analytics.js | 1 + src/main.css | 47 +++++++++++++++++++++++-------------- src/search-config.js | 2 ++ test/analytics.test.mjs | 17 ++++++++++++-- test/site.test.mjs | 24 +++++++++++++++++++ 10 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 scripts/submit-indexnow.mjs create mode 100644 src/search-config.js diff --git a/README.md b/README.md index 644d432..07d94d9 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,11 @@ Public browser identifiers live in `src/analytics-config.js`. The GA4 property * PostHog uses the separate **JavaScript in 30 Words** organization and project (`271088`) in EU Cloud, under the existing PostHog login. The free plan is selected with no payment method added. Its public `phc_` project token is configured in `posthogProjectToken`, with `https://eu.i.posthog.com` ingestion. Never use a personal PostHog API key or an AI provider key in this configuration. -Analytics runs only on the production hostname allowlist, after the visitor selects **Allow analytics**. Neither SDK loads before permission or after a remembered decline. **Privacy** in the footer reopens the choice. Withdrawal clears this site’s GA cookies and its PostHog browser storage and reloads to unload both SDKs; the consent choice persists separately. Localhost and Netlify deploy previews never send events, even with remembered consent. The content, AI connection, and tagged links work independently of analytics. +Analytics runs only on the production hostname allowlist. For the owner’s current test, `suppressConsentPrompt: true` starts both SDKs without automatically showing the permission popup. This does not save a consent choice and still respects an existing decline. Set that flag back to `false` to restore the original opt-in behavior, where neither SDK loads before permission. **Privacy** in the footer reopens the choice. Withdrawal clears this site’s GA cookies and its PostHog browser storage and reloads to unload both SDKs; the consent choice persists separately. Localhost and Netlify deploy previews never send events, even with remembered consent. The content, AI connection, and tagged links work independently of analytics. -Tracked events are page views (`page_view` in GA4, `$pageview` in PostHog), `practice_pad_click`, `contact_click`, `output_revealed`, and `ai_panel_opened`. Each includes the page and concept slug. PostHog autocapture, session replay, heatmaps, exception collection, surveys, and person profiles are disabled. Only explicitly permitted properties are sent; URL fragments and unrelated query parameters are removed. Keys, prompts, responses, model selections, and input values are not collected. Google advertising storage, personalization, and signals are disabled. The SDKs load asynchronously after consent. Anonymous analytics will still miss visitors who decline or use blockers. +Tracked events are page views (`page_view` in GA4, `$pageview` in PostHog), `practice_pad_click`, `contact_click`, `output_revealed`, and `ai_panel_opened`. Each includes the page and concept slug. PostHog autocapture, session replay, heatmaps, exception collection, surveys, and person profiles are disabled. Only explicitly permitted properties are sent; URL fragments and unrelated query parameters are removed. Keys, prompts, responses, model selections, and input values are not collected. Google advertising storage, personalization, and signals are disabled. The SDKs load asynchronously when analytics is active. Anonymous analytics will still miss visitors who decline or use blockers. -The property is created, but live traffic will only arrive after the local changes are deployed. Test analytics using intercepted network requests; do not enable tracking on localhost or send test traffic to production reports. +The site and both analytics integrations are deployed to the custom domain. Test analytics using intercepted network requests; do not enable tracking on localhost or send test traffic to production reports. ## Content and checks @@ -73,3 +73,11 @@ The existing Netlify project deploys the `master` branch. `netlify.toml` sets th The GitHub Actions workflow runs a clean install, all tests, and the production build on Node 22 for pull requests and pushes to `master`. Preview and production deployment still use Netlify. Publishing the reviewed branch to `master` replaces the previous site; verify the deployed concept routes, headers, redirects, and analytics consent again after that deployment. No single-page-app fallback is needed: each route has its own `index.html`, and unknown URLs return the custom 404 page. `sitemap.xml` and `robots.txt` are generated with the selected canonical origin. + +## Search discovery + +The sitemap at https://www.javascriptin30words.com/sitemap.xml lists the homepage and all 35 concepts; robots.txt advertises it. Each indexable page has one main heading, a unique description, canonical metadata, a WebSite/WebPage graph, and concept breadcrumb structured data. Visible definitions retain the exact original wording. The old Netlify hostname redirects to the custom domain, preserving paths. Preview deployments retain the production canonical URLs. + +Google Search Console uses the domain property `sc-domain:javascriptin30words.com`, verified through Cloudflare DNS. Keep the Google ownership TXT record in place. Submit the sitemap in Search Console and Bing Webmaster Tools; those services report discovered, crawled, and indexed pages separately. + +After publishing changes, run `npm run indexnow` to notify participating search engines, including Bing, about the 36 sitemap URLs. The script checks that the matching ownership file is publicly accessible before submission. The key in `src/search-config.js` is for public host verification, not a private account credential. IndexNow acceptance and sitemap submission do not guarantee indexing; Google uses Search Console rather than IndexNow. diff --git a/package.json b/package.json index e54219a..52f1ecd 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "dev": "node scripts/serve.mjs", + "indexnow": "node scripts/submit-indexnow.mjs", "test": "node --test test/*.test.mjs" }, "repository": { diff --git a/scripts/build.mjs b/scripts/build.mjs index 728b726..e530007 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,6 +7,7 @@ import javascript from 'highlight.js/lib/languages/javascript'; import { definitions } from '../src/data.js'; import { securityHeaders } from './security.mjs'; import { analyticsConfig } from '../src/analytics-config.js'; +import { indexNowKey } from '../src/search-config.js'; hljs.registerLanguage('javascript', javascript); @@ -21,6 +22,10 @@ const url = (path = '/') => origin + path; const pathFor = (concept) => `/${concept.slug}/`; const labelFor = (concept) => concept.id === 'this' ? 'this keyword' : concept.label; 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(); + return text.length <= 160 ? text : text.slice(0,157).replace(/\s+\S*$/, '') + '…'; +}; const interfaceFont = await readFile(new URL('../src/fonts/manrope-latin-variable.woff2', import.meta.url)); const interfaceFontPath = `/assets/manrope.${createHash('sha256').update(interfaceFont).digest('hex').slice(0,12)}.woff2`; const stylesheetSource = (await readFile(new URL('../src/main.css', import.meta.url), 'utf8')).replace('__UI_FONT_URL__', interfaceFontPath); @@ -115,6 +120,17 @@ function shareMenu(title, path) { function document({ title, description, path, content, current, noindex = false }) { const pageTitle = `${title} | ${brand}`; + 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')}} : {})}, + ...(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)}, + ]}] : []), + ], + }; const practiceURL = new URL('https://www.practice-pad.app/'); practiceURL.search = new URLSearchParams({utm_source:'javascriptin30words', utm_medium:'referral', utm_campaign:'concept_to_practice', utm_content:`footer_${current?.slug || (noindex ? '404' : 'home')}`}).toString(); return ` @@ -130,7 +146,11 @@ function document({ title, description, path, content, current, noindex = false + + + + ${noindex ? '' : ``} @@ -174,7 +194,7 @@ function document({ title, description, path, content, current, noindex = false function home() { return document({title:'JavaScript Concepts Explained Simply', description:`Refresh ${definitions.length} JavaScript concepts with definitions in 30 words or fewer, useful code examples, and clear explanations for interview preparation.`, path:'/', content:` -

Pre interview prep

JavaScript concepts in 30 words or fewer, with code to make them stick. Pick a topic to get started.

+

Pre interview prep

JavaScript concepts in 30 words or fewer, with code to make them stick. Pick a topic to get started.

${groups.map((group) => `

${group}

${definitions.filter((item) => item.group === group).map((item) => ``).join('')}
`).join('')} `}); } @@ -184,7 +204,7 @@ function conceptPage(concept, index) { const next = definitions[index + 1]; const previousArrow = ''; const nextArrow = ''; - return document({title:concept.heading, description:summaryFor(concept), path:pathFor(concept), current:concept, content:` + return document({title:concept.heading, description:searchDescription(concept), path:pathFor(concept), current:concept, content:`

${escapeHTML(concept.heading)}

${renderDefinition(concept)}
${hljs.highlight(concept.code, {language:'javascript'}).value}
${concept.output.length ? `
` : ''}
@@ -218,5 +238,8 @@ for (const [index, concept] of definitions.entries()) { 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('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); +await writeFile(new URL('_redirects', output), `https://javascript-in-30-words.netlify.app/* ${origin}/:splat 301!\n`); await writeFile(new URL('_headers', output), `/*\n${Object.entries(securityHeaders).map(([key,value]) => ` ${key}: ${value}`).join('\n')}\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n`); console.log(`Built ${definitions.length} concept pages, the index, sitemap and 404 page for ${origin}.`); diff --git a/scripts/submit-indexnow.mjs b/scripts/submit-indexnow.mjs new file mode 100644 index 0000000..f5f9bce --- /dev/null +++ b/scripts/submit-indexnow.mjs @@ -0,0 +1,19 @@ +import {readFile} from 'node:fs/promises'; +import {indexNowKey} from '../src/search-config.js'; + +// Run only after the matching production release is live. Preview builds never +// submit URLs, and the deployed ownership file must match before any submission. +const origin = 'https://www.javascriptin30words.com'; +const sitemap = await readFile(new URL('../build/sitemap.xml',import.meta.url),'utf8'); +const urlList = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map(match=>match[1]); +if (!urlList.length || urlList.some(value=>new URL(value).origin !== origin)) throw new Error('Build the production sitemap before submitting.'); +const keyLocation = `${origin}/${indexNowKey}.txt`; +const verification = await fetch(keyLocation,{signal:AbortSignal.timeout(30000)}); +if (!verification.ok || (await verification.text()).trim() !== indexNowKey) throw new Error('Publish the IndexNow verification file before submitting.'); +const response = await fetch('https://api.indexnow.org/indexnow',{ + method:'POST',headers:{'Content-Type':'application/json; charset=utf-8'}, + body:JSON.stringify({host:new URL(origin).host,key:indexNowKey,keyLocation,urlList}), + signal:AbortSignal.timeout(30000), +}); +if (![200,202].includes(response.status)) throw new Error(`IndexNow rejected the submission (HTTP ${response.status}). ${await response.text()}`); +console.log(`IndexNow received ${urlList.length} URLs (HTTP ${response.status}). This is a crawl notification, not confirmation that pages are indexed.`); diff --git a/src/analytics-config.js b/src/analytics-config.js index 2c47645..ee51124 100644 --- a/src/analytics-config.js +++ b/src/analytics-config.js @@ -1,5 +1,8 @@ // Public browser identifiers only. Never put personal API keys here. export const analyticsConfig = { + // Temporary owner-requested testing mode. Keep the integrations active without + // automatically opening the permission panel; existing opt-outs still apply. + suppressConsentPrompt:true, googleMeasurementId:'G-9W8VGXXL0G', posthogProjectToken:'phc_kYNSabXwPMuoR48Kmb6oUy7MfwPgBx88pWqHS3T8vpAn', posthogHost:'https://eu.i.posthog.com', diff --git a/src/analytics.js b/src/analytics.js index 65d0269..d2fcf2b 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -49,6 +49,7 @@ export function startAnalytics(win = window, doc = document, settings = config) const pending = []; try { consent = win.localStorage.getItem(consentKey); } catch { /* Memory-only choice when storage is unavailable. */ } if (!['granted','denied'].includes(consent)) consent = null; + if (!consent && settings.suppressConsentPrompt) consent = 'granted'; const script = (src,onload) => { const element = doc.createElement('script'); element.async = true; diff --git a/src/main.css b/src/main.css index 2e53d55..4d87fa6 100644 --- a/src/main.css +++ b/src/main.css @@ -333,40 +333,42 @@ p { flex:1; min-height:0; flex-direction:column; - width:calc(100% - 2 * var(--page-gutter)); - max-width:1440px; + width:100%; + max-width:none; margin:0 auto; padding-top:.85rem; overflow:hidden } .scroll-region { position:relative; - display:flex; + display:block; flex:1; min-height:0; - overflow:hidden + overflow-y:auto; + overscroll-behavior:contain; + scrollbar-gutter:stable; + scrollbar-color:#cfd7dc transparent; + scrollbar-width:thin } .scroll-region::before { content:""; - position:absolute; + position:sticky; + display:block; z-index:3; - inset:0 0 auto; + top:0; height:24px; + margin-bottom:-24px; pointer-events:none; background:linear-gradient(180deg,#fff 0%,rgba(255,255,255,.92) 42%,rgba(255,255,255,0) 100%) } main { - flex:1; min-width:0; min-height:0; - width:100%; + width:calc(100% - 2 * var(--page-gutter)); max-width:1280px; margin:0 auto; padding:1.5rem .8rem 1.5rem; - overflow-y:auto; - overscroll-behavior:contain; - scrollbar-color:#cfd7dc transparent; - scrollbar-width:thin + overflow:visible } .page-heading { margin-bottom:2.5rem @@ -398,6 +400,7 @@ main { gap:.85rem } .topic-card { + position:relative; min-width:0; padding:1.2rem; background:linear-gradient(#fff,#fff) padding-box,var(--gradient-border) border-box; @@ -409,6 +412,16 @@ main { border-color:transparent; box-shadow:0 10px 28px rgba(20,33,50,.075) } +.topic-card a::after { + content:""; + position:absolute; + inset:0; + border-radius:inherit +} +.topic-card:focus-within { + outline:3px solid rgba(37,117,230,.42); + outline-offset:3px +} .topic-card h3 { margin-bottom:.55rem; font-size:1rem @@ -434,7 +447,7 @@ main { flex:0 0 auto; flex-wrap:wrap; gap:.55rem; - width:100%; + width:calc(100% - 2 * var(--page-gutter)); max-width:1280px; margin:0 auto; padding:.45rem .8rem; @@ -826,7 +839,7 @@ code,samp { } .footer-inner { display:grid; - grid-template-columns:minmax(0,1fr) auto; + grid-template-columns:minmax(0,1fr) minmax(160px,28%); align-items:center; gap:1.5rem; width:100%; @@ -859,7 +872,7 @@ code,samp { font-size:.93rem } .footer-practice p { - max-width:57ch; + max-width:none; margin:0; color:#626e81; font-size:.75rem; @@ -948,14 +961,14 @@ code,samp { border-radius:10px; text-decoration:none } -@container(max-width:900px) { +@container(max-width:1100px) { .footer-links { flex-direction:column; align-items:flex-end; gap:.15rem } } -@container(max-width:520px) { +@container(max-width:760px) { .footer-inner { grid-template-columns:1fr; gap:.35rem diff --git a/src/search-config.js b/src/search-config.js new file mode 100644 index 0000000..7a67639 --- /dev/null +++ b/src/search-config.js @@ -0,0 +1,2 @@ +// Public ownership key served at the site root for IndexNow verification. +export const indexNowKey = 'df56796636c5a3873f07cfc0adec1eb3'; diff --git a/test/analytics.test.mjs b/test/analytics.test.mjs index 940f65d..9b0777c 100644 --- a/test/analytics.test.mjs +++ b/test/analytics.test.mjs @@ -3,7 +3,7 @@ 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']}; -function harness({hostname='www.javascriptin30words.com',choice=null,storageBlocked=false} = {}) { +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(){}}); @@ -17,7 +17,7 @@ function harness({hostname='www.javascriptin30words.com',choice=null,storageBloc 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); + startAnalytics(win,doc,{...settings,suppressConsentPrompt}); return {win,doc,scripts,stored,elements,events,click:selector=>events.get(selector+'click')()}; } @@ -56,6 +56,19 @@ test('no vendor requests or tracking before consent, or after declining',()=>{ assert.equal(harness({choice:'denied'}).scripts.length,0); }); +test('temporary no-prompt mode initializes analytics while preserving opt-outs and preview exclusions',()=>{ + const h = harness({suppressConsentPrompt:true}); + assert.equal(h.scripts.length,2); + assert.equal(h.elements.get('#analytics-consent').hidden,true); + assert.equal(h.stored.get(consentKey),undefined,'Testing mode does not manufacture a saved consent choice'); + assert.equal(harness({suppressConsentPrompt:true,choice:'denied'}).scripts.length,0); + assert.equal(harness({suppressConsentPrompt:true,hostname:'localhost'}).scripts.length,0); + h.click('#analytics-preferences'); + assert.equal(h.elements.get('#analytics-consent').hidden,false,'Privacy controls remain available on demand'); + h.click('#analytics-decline'); + assert.equal(h.win['ga-disable-G-TEST123'],true); +}); + test('consent loads both vendors once with one page view and explicit safe events',()=>{ const h = harness(); h.click('#analytics-allow'); diff --git a/test/site.test.mjs b/test/site.test.mjs index 2682928..074b537 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { definitions } from '../src/data.js'; +import {indexNowKey} from '../src/search-config.js'; const root = fileURLToPath(new URL('../', import.meta.url)); const read = (path) => readFileSync(new URL(`../build/${path}`, import.meta.url), 'utf8'); @@ -78,6 +79,29 @@ test('sitemap and homepage expose every concept to crawlers', () => { assert.ok(read('robots.txt').includes(`Sitemap: ${expectedOrigin}/sitemap.xml`)); }); +test('indexable pages have one heading, unique metadata, and accurate structured breadcrumbs',()=>{ + const descriptions = new Set(); + for (const path of ['/',...definitions.map(item=>`/${item.slug}/`)]) { + const html = read(path.slice(1)+'index.html'); + assert.equal((html.match(/ 30 && description.length <= 160); + descriptions.add(description); + assert.ok(!html.includes('content="noindex"')); + const graph = JSON.parse(html.match(/