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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
27 changes: 25 additions & 2 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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 `<!doctype html>
Expand All @@ -130,7 +146,11 @@ function document({ title, description, path, content, current, noindex = false
<meta property="og:title" content="${escapeHTML(pageTitle)}">
<meta property="og:description" content="${escapeHTML(description)}">
<meta property="og:url" content="${url(path)}">
<meta property="og:image" content="${url(logoPath)}">
<meta property="og:image:alt" content="JavaScript in 30 Words logo">
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="${url(logoPath)}">
${noindex ? '' : `<script type="application/ld+json">${JSON.stringify(structuredData).replace(/</g, '\\u003c')}</script>`}
<link rel="icon" href="${logoPath}" type="image/webp" sizes="64x64">
<link rel="preload" href="${interfaceFontPath}" as="font" type="font/woff2" crossorigin>
<style>${stylesheet.styles}</style>
Expand Down Expand Up @@ -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:`
<header class="page-heading"><p class="eyebrow">Pre interview prep</p><p class="lead">JavaScript concepts in 30 words or fewer, with code to make them stick. Pick a topic to get started.</p></header>
<header class="page-heading"><h1 class="eyebrow">Pre interview prep</h1><p class="lead">JavaScript concepts in 30 words or fewer, with code to make them stick. Pick a topic to get started.</p></header>
${groups.map((group) => `<section class="topic-section"><h2>${group}</h2><div class="topic-grid">${definitions.filter((item) => item.group === group).map((item) => `<article class="topic-card"><h3><a href="${pathFor(item)}">${escapeHTML(labelFor(item))} <span aria-hidden="true">↗</span></a></h3>${renderDefinition(item, 'topic-definition')}</article>`).join('')}</div></section>`).join('')}
`});
}
Expand All @@ -184,7 +204,7 @@ function conceptPage(concept, index) {
const next = definitions[index + 1];
const previousArrow = '<svg class="concept-direction" aria-hidden="true" viewBox="0 0 20 20"><path d="M16 10H4m5-5-5 5 5 5"></path></svg>';
const nextArrow = '<svg class="concept-direction" aria-hidden="true" viewBox="0 0 20 20"><path d="M4 10h12m-5-5 5 5-5 5"></path></svg>';
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:`
<article class="concept">
<header class="page-heading"><h1>${escapeHTML(concept.heading)}</h1>${renderDefinition(concept)}</header>
<section class="example" aria-label="JavaScript code example"><pre tabindex="0" aria-label="JavaScript code example"><code class="hljs language-javascript">${hljs.highlight(concept.code, {language:'javascript'}).value}</code></pre>${concept.output.length ? `<div class="example-output"><input class="output-toggle" type="checkbox" id="output-${escapeHTML(concept.id)}"><label for="output-${escapeHTML(concept.id)}"><span class="output-heading"><span>Output</span><span class="output-action" aria-hidden="true"></span></span><span class="output-content"><samp>${escapeHTML(concept.output.join('\n'))}</samp></span></label></div>` : ''}</section>
Expand Down Expand Up @@ -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:'<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('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}.`);
19 changes: 19 additions & 0 deletions scripts/submit-indexnow.mjs
Original file line number Diff line number Diff line change
@@ -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>([^<]+)<\/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.`);
3 changes: 3 additions & 0 deletions src/analytics-config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 30 additions & 17 deletions src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/search-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Public ownership key served at the site root for IndexNow verification.
export const indexNowKey = 'df56796636c5a3873f07cfc0adec1eb3';
Loading