From 5f187ff02899b8e2a1773bf54f49c552ca5ac6e7 Mon Sep 17 00:00:00 2001 From: Michael Sydney Moore Date: Fri, 11 Sep 2026 16:30:15 +0100 Subject: [PATCH 1/3] Declare each concept a defined term in a glossary set The graph on every page described only where a page sat: WebSite, WebPage and a BreadcrumbList. Nothing said what a page held, so a concept page and any other page looked like the same kind of thing to a crawler. Each concept now adds a DefinedTerm carrying the short definition, pointing at the MDN page for the same term through sameAs, and claiming membership of a DefinedTermSet that the home page declares over all thirty-five concepts. A TechArticle wraps the prose around it, with the proficiency level taken from the existing Fundamentals and Advanced split and the modified date from the same content commit the sitemap already uses. This matters more for assistants answering "what is a closure in JavaScript" than for blue links: a page that names itself the definition of a term is far easier to identify as the source than one that only names itself a page. --- scripts/build.mjs | 24 +++++++++++++++++++++++- test/site.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) 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/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(/ - ${current ? `` : ''} + ${current ? `` : ''} @@ -235,8 +254,8 @@ 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.

+ return document({title:'JavaScript Concepts Explained Simply', description:`Learn JavaScript with ${definitions.length} short definitions and code examples. Start with variables, arrays, objects, and functions, or refresh your interview knowledge.`, path:'/', content:` +

JavaScript concepts explained simply

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('')} `}); } @@ -250,6 +269,7 @@ function conceptPage(concept, index) {

${escapeHTML(concept.heading)}

${renderDefinition(concept)}
${hljs.highlight(concept.code, {language:'javascript'}).value}
${concept.output.length ? `
` : ''}
+

Read more about ${escapeHTML(labelFor(concept))} on MDN

${renderAIPanel(concept)}
@@ -270,6 +290,7 @@ await writeFile(new URL(interfaceFontPath.slice(1), output), interfaceFont); await writeFile(new URL('assets/Manrope-OFL.txt', output), await readFile(new URL('../src/fonts/OFL.txt', import.meta.url))); await writeFile(new URL(aiClientPath.slice(1), output), aiClient); await writeFile(new URL(aiPanelPath.slice(1), output), aiPanel); +await writeFile(new URL(aiLoaderPath.slice(1), output), aiLoader); await writeFile(new URL(analyticsPath.slice(1), output), analytics); await writeFile(new URL('index.html', output), home()); for (const [index, concept] of definitions.entries()) { @@ -280,6 +301,7 @@ 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)}${lastModified}`).join('')}\n`); await writeFile(new URL('robots.txt', output), `User-agent: *\nAllow: /\n\nSitemap: ${url('/sitemap.xml')}\n`); +await writeFile(new URL('llms.txt', output), llmIndex()); 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`); diff --git a/src/ai-loader.js b/src/ai-loader.js new file mode 100644 index 0000000..b6ef95d --- /dev/null +++ b/src/ai-loader.js @@ -0,0 +1,22 @@ +// Definitions and examples need no AI code. Fetch the panel and provider client +// only when a reader opens the optional panel, including a restored open panel. +export function prepareAIPanel(panel, load = () => import('./ai-panel.js')) { + if (!panel) return; + let started = false; + const open = async () => { + if (!panel.open || started) return; + started = true; + panel.setAttribute('aria-busy', 'true'); + try { + await load(); + } catch { + panel.querySelector('#ai-error').textContent = 'AI is unavailable. Refresh this page to try again.'; + } finally { + panel.removeAttribute('aria-busy'); + } + }; + panel.addEventListener('toggle', open); + open(); +} + +if (typeof document !== 'undefined') prepareAIPanel(document.querySelector('[data-ai-panel]')); diff --git a/src/data.js b/src/data.js index 68a78be..de9f7c5 100644 --- a/src/data.js +++ b/src/data.js @@ -1,4 +1,4 @@ -// Descriptions preserve the original site copy with approved typo corrections. Code examples are exercised by npm test. +// Original descriptions are preserved; new definitions stay under 30 words. Code examples are exercised by npm test. export const definitions = [ { "id": "variables", @@ -15,6 +15,37 @@ export const definitions = [ "reference": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types", "group": "Fundamentals" }, + { + "id": "arrays", + "label": "Arrays", + "slug": "javascript-arrays", + "heading": "JavaScript Arrays Explained Simply", + "text": "Arrays keep a list of values in order. Use an index to pick an item, starting at zero for the first one.", + "code": "const topics = [\"Variables\", \"Functions\"];\nconsole.log(topics[0]);\n\ntopics.push(\"Arrays\");\nconsole.log(topics.length);\nconsole.log(topics.join(\", \"));", + "output": [ + "Variables", + "3", + "Variables, Functions, Arrays" + ], + "explanation": "Index zero gives us the first topic. push adds another topic at the end, and length tells us how many items the list holds.", + "reference": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array", + "group": "Fundamentals" + }, + { + "id": "objects", + "label": "Objects", + "slug": "javascript-objects", + "heading": "JavaScript Objects Explained Simply", + "text": "Objects keep related information together as named properties. A learner object might hold a name, a score, and a method for updating that score.", + "code": "const learner = {\n name: \"Ada\",\n score: 0,\n completeLesson() {\n this.score += 1;\n }\n};\n\nlearner.completeLesson();\nconsole.log(learner.name);\nconsole.log(learner.score);", + "output": [ + "Ada", + "1" + ], + "explanation": "name and score are properties. completeLesson is a method: a function stored on the object. Calling it adds one to this learner's score.", + "reference": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Working_with_objects", + "group": "Fundamentals" + }, { "id": "functions", "label": "Functions", @@ -95,6 +126,20 @@ export const definitions = [ "< Smaller" ] }, + { + "id": "if-else", + "label": "If and Else", + "slug": "javascript-if-else", + "heading": "JavaScript If Else Explained Simply", + "text": "An if statement checks a condition and runs code when it passes. Add else to say what should happen when it doesn't.", + "code": "const score = 8;\n\nif (score >= 7) {\n console.log(\"You passed!\");\n} else {\n console.log(\"Keep practising\");\n}", + "output": [ + "You passed!" + ], + "explanation": "Eight is at least seven, so the first block runs. Try changing score to five to see the else block run instead.", + "reference": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/if...else", + "group": "Fundamentals" + }, { "id": "con-operations", "label": "Conditional (Ternary) Operator", @@ -268,6 +313,36 @@ export const definitions = [ "reference": "https://developer.mozilla.org/en-US/docs/Glossary/Scope", "group": "Fundamentals" }, + { + "id": "dom", + "label": "DOM", + "slug": "javascript-dom", + "heading": "JavaScript DOM Explained Simply", + "text": "The Document Object Model (DOM) represents a web page as a tree. JavaScript uses it to find elements, change their content, or add new ones.", + "code": "// Run in a browser console.\nconst message = document.createElement(\"p\");\nmessage.textContent = \"Hello, JavaScript!\";\ndocument.body.append(message);\n\nconsole.log(message.textContent);", + "output": [ + "Hello, JavaScript!" + ], + "explanation": "createElement makes a paragraph, textContent fills it with text, and append adds it to the page. The console prints the same message.", + "environment": "browser", + "reference": "https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model", + "group": "Fundamentals" + }, + { + "id": "events", + "label": "Events", + "slug": "javascript-events", + "heading": "JavaScript Events Explained Simply", + "text": "Events tell your code something happened, like a click or a keypress. Add a listener to run a function whenever that event occurs.", + "code": "// Run in a browser console.\nconst button = document.createElement(\"button\");\nbutton.textContent = \"Complete lesson\";\nbutton.addEventListener(\"click\", () => {\n console.log(\"Lesson complete\");\n});\ndocument.body.append(button);\n\nbutton.click();", + "output": [ + "Lesson complete" + ], + "explanation": "addEventListener connects the click to a function. button.click() triggers it immediately; clicking the new button on the page runs it again.", + "environment": "browser", + "reference": "https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Events", + "group": "Fundamentals" + }, { "id": "the-call-stack", "label": "The call stack", diff --git a/src/main.css b/src/main.css index 4d87fa6..10d0364 100644 --- a/src/main.css +++ b/src/main.css @@ -776,6 +776,14 @@ code,samp { font-weight:500; text-decoration:none } +.concept-reference { + margin:.85rem 0 0 +} +.concept-reference a { + display:inline-block; + padding:.3rem 0; + text-decoration:underline +} .next-concepts { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); diff --git a/test/ai-loader.test.mjs b/test/ai-loader.test.mjs new file mode 100644 index 0000000..d8b107b --- /dev/null +++ b/test/ai-loader.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseHTML} from 'linkedom'; +import {prepareAIPanel} from '../src/ai-loader.js'; + +function harness() { + const {document, Event} = parseHTML('
'); + const panel = document.querySelector('details'); + panel.open = false; + return {panel, toggle(open) { panel.open = open; panel.dispatchEvent(new Event('toggle')); }}; +} + +test('reading a definition fetches no AI code; repeated opens share one load', async () => { + const {panel, toggle} = harness(); + let calls = 0, finish; + const loading = new Promise(resolve => { finish = resolve; }); + prepareAIPanel(panel, () => { calls++; return loading; }); + assert.equal(calls, 0); + toggle(false); + assert.equal(calls, 0); + toggle(true); + assert.equal(calls, 1); + assert.equal(panel.getAttribute('aria-busy'), 'true'); + toggle(false); + toggle(true); + assert.equal(calls, 1); + finish(); + await loading; + assert.equal(panel.hasAttribute('aria-busy'), false); + toggle(false); + toggle(true); + assert.equal(calls, 1); +}); + +test('a panel restored open loads immediately', async () => { + const {panel} = harness(); + panel.open = true; + let loaded = false; + prepareAIPanel(panel, async () => { loaded = true; }); + await Promise.resolve(); + assert.equal(loaded, true); + assert.equal(panel.hasAttribute('aria-busy'), false); +}); + +test('a failed download gives readers a recovery message and clears busy state', async () => { + const {panel, toggle} = harness(); + prepareAIPanel(panel, async () => { throw new Error('Network unavailable'); }); + toggle(true); + await Promise.resolve(); + assert.match(panel.querySelector('#ai-error').textContent, /Refresh this page/); + assert.equal(panel.hasAttribute('aria-busy'), false); +}); diff --git a/test/fixtures/new-descriptions.json b/test/fixtures/new-descriptions.json new file mode 100644 index 0000000..ae130af --- /dev/null +++ b/test/fixtures/new-descriptions.json @@ -0,0 +1,30 @@ +{ + "authoredAt": "2026-09-30", + "definitions": [ + { + "id": "arrays", + "text": "Arrays keep a list of values in order. Use an index to pick an item, starting at zero for the first one.", + "bulletPointItems": "" + }, + { + "id": "objects", + "text": "Objects keep related information together as named properties. A learner object might hold a name, a score, and a method for updating that score.", + "bulletPointItems": "" + }, + { + "id": "if-else", + "text": "An if statement checks a condition and runs code when it passes. Add else to say what should happen when it doesn't.", + "bulletPointItems": "" + }, + { + "id": "dom", + "text": "The Document Object Model (DOM) represents a web page as a tree. JavaScript uses it to find elements, change their content, or add new ones.", + "bulletPointItems": "" + }, + { + "id": "events", + "text": "Events tell your code something happened, like a click or a keypress. Add a listener to run a function whenever that event occurs.", + "bulletPointItems": "" + } + ] +} diff --git a/test/site.test.mjs b/test/site.test.mjs index c175555..d214856 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -13,30 +13,40 @@ execFileSync(process.execPath, ['scripts/build.mjs'], {cwd:root}); const expectedOrigin = new URL(process.env.SITE_URL || 'https://www.javascriptin30words.com').origin; const originalCopy = JSON.parse(readFileSync(new URL('./fixtures/original-descriptions.json', import.meta.url), 'utf8')); const originalById = new Map(originalCopy.definitions.map(item => [item.id,item])); +const newCopy = JSON.parse(readFileSync(new URL('./fixtures/new-descriptions.json', import.meta.url), 'utf8')); +const approvedById = new Map([...originalCopy.definitions, ...newCopy.definitions].map(item => [item.id,item])); const normalize = text => text.replace(/\s+/g, ' ').trim(); -test('all original topics have unique descriptive URLs', () => { +test('original topics and five new beginner topics have unique descriptive URLs', () => { const originalIds = ['variables','functions','functional-expressions','operators','comparisons','con-operations','logical-opp','for-loops','while-loops','switch-statements','arrow-functions','array-methods','string-methods','classes','scope','the-call-stack','event-loop','IIFEs','nested-functions','recursion','memoization','closure','hoisting','currying','value-vs-reference','asynchronous-javascript','promises','async-await','global-objects','this','call','apply','bind','prototypal-inheritance','polymorphism']; - assert.deepEqual(definitions.map(item => item.id), originalIds); - assert.equal(new Set(definitions.map(item => item.slug)).size, 35); + assert.deepEqual(definitions.filter(item => originalById.has(item.id)).map(item => item.id), originalIds); + const newTopics = definitions.filter(item => !originalById.has(item.id)); + assert.deepEqual(newTopics.map(item => item.id), ['arrays','objects','if-else','dom','events']); + assert.equal(new Set(definitions.map(item => item.slug)).size, definitions.length); + for (const concept of newTopics) { + assert.equal(concept.slug, `javascript-${concept.id}`); + assert.equal(concept.group, 'Fundamentals'); + const words = normalize([concept.text, ...(concept.definitionItems || [])].join(' ')).split(' '); + assert.ok(words.length > 0 && words.length < 30, `${concept.label} has ${words.length} words; the limit is 29`); + } assert.equal(definitions.find(item => item.id === 'closure').slug, 'javascript-closures'); - assert.equal(originalById.size, definitions.length); + assert.equal(approvedById.size, definitions.length); }); for (const concept of definitions) { test(`${concept.label}: approved description, readable HTML and correct runnable example`, () => { - const original = originalById.get(concept.id); - assert.ok(original, `Original description exists for ${concept.id}`); - // Source copy, including approved typo corrections, takes precedence over the original 30-word target. - assert.equal(concept.text, normalize(original.text)); - const originalItems = original.bulletPointItems.split('.').slice(0,-1).map(normalize); - assert.deepEqual(concept.definitionItems || [], originalItems); + const approved = approvedById.get(concept.id); + assert.ok(approved, `Approved description exists for ${concept.id}`); + // Preserve original copy and check new definitions against their separate snapshot. + assert.equal(concept.text, normalize(approved.text)); + const approvedItems = approved.bulletPointItems.split('.').slice(0,-1).map(normalize); + assert.deepEqual(concept.definitionItems || [], approvedItems); const html = read(`${concept.slug}/index.html`); const definition = html.match(/

([\s\S]*?)<\/p>/)?.[1] || ''; const definitionList = html.match(/