diff --git a/.gitignore b/.gitignore index 0bc3503..e742860 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +/reports/lighthouse/ # production /build @@ -22,4 +23,4 @@ /.claude npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* diff --git a/README.md b/README.md index 1c9c9d2..eb940d0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,19 @@ Each concept page has a collapsible AI panel. Expand it, connect your OpenAI or Your key stays in page memory and is never sent to our server — requests go straight from your browser to the provider. It clears when you leave or reload the page, unless you choose to remember it for that tab. +The AI code downloads only when you open the panel, keeping the definitions and examples light. + +## Content and checks + +The site now covers 40 concepts. New definitions stay under 30 words, and every code example is tested. + +```sh +npm test +npm run build +``` + +See the [maintenance notes](docs/maintenance.md) for editing content and AI crawler access, and the [Lighthouse audit](docs/lighthouse-audit.md) for performance and SEO results. + --- ## Ready to go further? diff --git a/docs/lighthouse-audit.md b/docs/lighthouse-audit.md new file mode 100644 index 0000000..481cf8a --- /dev/null +++ b/docs/lighthouse-audit.md @@ -0,0 +1,67 @@ +# Lighthouse and SEO audit — 30 September 2026 + +The existing live site already passed Lighthouse's accessibility, best practices, and SEO checks. The live mobile performance scores were 96 for the homepage and 98 for Variables. The updated local production build scored 100 in all four categories on every page/device combination tested below. + +## Measurements + +Lighthouse 13.5.0; a separate headless Chrome session per audit, default simulated mobile throttling or the desktop preset. These are individual laboratory runs, not field Core Web Vitals or measurements from India. Scores can vary by device, location, and run. + +| Page and environment | Performance | Accessibility | Best practices | SEO | LCP | Blocking time | Layout shift | +|---|---:|---:|---:|---:|---:|---:|---:| +| Live homepage · mobile | 96 | 100 | 100 | 100 | 1.8 s | 170 ms | 0 | +| Live Variables · mobile | 98 | 100 | 100 | 100 | 1.0 s | 160 ms | 0 | +| Local Arrays before · mobile | 100 | 100 | 100 | 100 | 1.3 s | 0 ms | 0 | +| Updated homepage · mobile | 100 | 100 | 100 | 100 | 1.1 s | 0 ms | 0 | +| Updated Variables · mobile | 100 | 100 | 100 | 100 | 1.2 s | 0 ms | 0 | +| Updated Arrays · mobile | 100 | 100 | 100 | 100 | 1.2 s | 0 ms | 0 | +| Updated homepage · desktop | 100 | 100 | 100 | 100 | 0.3 s | 0 ms | 0 | +| Updated Arrays · desktop | 100 | 100 | 100 | 100 | 0.3 s | 0 ms | 0 | + +Local analytics are disabled by the site's existing production-host allowlist. The live and local performance scores therefore cannot be treated as a before/after measurement of these edits. The local Arrays before/after comparison uses the same environment. Its score was already 100; the measurable reduction is in first-party JavaScript loaded before interaction: **21,817 bytes → 6,777 bytes (69% less, before compression)**. The two AI bundles are fetched only after opening the panel. The baseline and final network logs verify this. + +The live reports identify Google Analytics and PostHog as the largest remaining JavaScript cost, including about 123 KiB of unused vendor code during initial loading. Analytics behavior and its event collection are unchanged. Post-deployment auditing is needed to measure the changes with the production hosting and analytics together. + +## Changes + +- Load the optional AI panel and provider client on demand through a 457-byte loader. Reopening the panel does not duplicate initialization; failed downloads show a recovery message. +- Use “JavaScript concepts explained simply” as the homepage H1 and a beginner-focused meta description. +- Put each concept's descriptive MDN documentation link outside the collapsed AI panel, available with JavaScript disabled. +- Enforce an 8 KiB ceiling on initial first-party JavaScript and verify generated lazy imports, unique titles, and metadata in the test suite. + +All 40 concept definitions and examples retain their approved wording. The five new definitions remain under 30 words. + +## SEO verification + +The site generates the homepage and all 40 concept pages as static HTML, with one H1 per page, unique titles and descriptions, self-referencing canonical URLs, real internal links, and structured WebSite, WebPage, BreadcrumbList, TechArticle, and DefinedTerm data. All 41 indexable URLs appear in the sitemap; robots.txt points to it. The 404 page is marked noindex. Tests resolve every internal link and execute all examples. + +The live non-www Variables URL redirects to the canonical www URL. At audit time the five new topics and these optimizations remained local; the live Arrays URL returned 404. They need publishing before Google can discover the new pages. + +Lighthouse's SEO category covers technical checks. Search visibility also depends on useful content and links, as described in [Google's SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide). See [Lighthouse documentation](https://developer.chrome.com/docs/lighthouse/overview/) for audit interpretation. + +## Reproduce + +Use Node.js 22 and an installed Chrome browser. Start the production-output preview with `npm run dev`, then substitute its printed port: + +```sh +npm test +mkdir -p reports/lighthouse +npx --yes lighthouse@13.5.0 http://127.0.0.1:PORT/javascript-arrays/ --chrome-flags="--headless --no-sandbox" --output=json --output=html --output-path=reports/lighthouse/arrays-mobile +npx --yes lighthouse@13.5.0 http://127.0.0.1:PORT/javascript-arrays/ --preset=desktop --chrome-flags="--headless --no-sandbox" --output=json --output=html --output-path=reports/lighthouse/arrays-desktop +``` + +Repeat for the homepage and Variables. After publishing, run the same commands with the production URLs to include real hosting and analytics. Reports are ignored by Git. + +## Saved evidence + +The eight HTML and JSON reports are saved locally under `reports/lighthouse/2026-09-30/`: + +- [Live homepage · mobile](../reports/lighthouse/2026-09-30/live-home-mobile.report.html) +- [Live Variables · mobile](../reports/lighthouse/2026-09-30/live-variables-mobile.report.html) +- [Local Arrays before · mobile](../reports/lighthouse/2026-09-30/before-arrays-mobile.report.html) +- [Updated homepage · mobile](../reports/lighthouse/2026-09-30/after-home-mobile.report.html) +- [Updated Variables · mobile](../reports/lighthouse/2026-09-30/after-variables-mobile.report.html) +- [Updated Arrays · mobile](../reports/lighthouse/2026-09-30/after-arrays-mobile.report.html) +- [Updated homepage · desktop](../reports/lighthouse/2026-09-30/after-home-desktop.report.html) +- [Updated Arrays · desktop](../reports/lighthouse/2026-09-30/after-arrays-desktop.report.html) + +Validation: **85 tests passed**; all 40 examples executed; all five updated Lighthouse runs scored 100 in all four categories. The AI panel and connection dialog were also opened successfully in the browser after lazy loading. diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..ed4929b --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,30 @@ +# Site maintenance + +The AI panel and provider client load only when opened. Tests keep initial first-party JavaScript below 8 KiB and verify that repeated opens share one load. + +## Content and checks + +Edit `src/data.js` to change an example, updating `output` to match its console output. Descriptions and `definitionItems` preserve the original site's wording; intentional copy changes must also update the source snapshot in `test/fixtures/original-descriptions.json`. New topics use `test/fixtures/new-descriptions.json` and must keep their complete definition under 30 words. Preserve existing slugs so shared links keep working. + +```sh +npm test +npm run build +``` + +Tests run all 40 examples, compare every description and bullet list with its source snapshot, check the rendered HTML and internal links, and validate the sitemap. The original 35 definitions are preserved even where they exceed 30 words. The five new beginner topics—arrays, objects, if/else, DOM, and events—have definitions of fewer than 30 words, enforced by the tests. Examples marked `environment: 'browser'` run with a document supplied by LinkeDOM during tests; readers can paste them into a browser console. LinkeDOM is only a development dependency and is not shipped to visitors. The generated site is written to `build/`. The archived image assets in `src/assets/` use lossless WebP; they are not shipped with the site. Code examples are selectable text, with JavaScript syntax highlighting generated at build time using highlight.js. + +AI tests use simulated provider responses to check request formats, conversation context, connection validation, storage opt-in, cancellation, and error handling without using a real key or incurring API charges. A live explanation requires a valid visitor-supplied API key and API credits. + +The header and favicon share the optimised 64px `src/logo.webp`, served with a content hash and immutable cache headers. `src/logo.svg` is its editable vector source and is not deployed. When changing the logo, export the vector at 64 × 64 pixels with transparency and encode it using lossless WebP before rebuilding. + +The interface uses a locally served Latin variable subset of [Manrope](https://fonts.google.com/specimen/Manrope), with regular and medium weights. The 24 KB WOFF2 file is preloaded, content-hashed, and cached; no external font request is made by visitors. Code examples keep their monospace font. The font’s SIL Open Font License is in `src/fonts/OFL.txt` and is copied into the generated assets. + +## Lighthouse and SEO + +See [the Lighthouse audit](lighthouse-audit.md) for measured mobile and desktop scores, the audit commands, and the distinction between production and local results. Site tests also verify unique page titles and descriptions, canonical URLs, crawlable links, structured data, the sitemap, and the non-indexable 404 page. + +## AI crawler access + +The current `robots.txt` permits all crawlers, including search and training crawlers. Search visibility and training permissions are separate controls; see the [OpenAI crawler documentation](https://developers.openai.com/api/docs/bots), [Anthropic crawler documentation](https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler), and [Perplexity crawler documentation](https://docs.perplexity.ai/docs/resources/perplexity-crawlers). Keep hosting-level bot restrictions in mind when diagnosing blocked requests; a successful request with a crawler user-agent does not verify access from that provider's actual IP addresses or prove indexing. + +The build generates `/llms.txt` from `src/data.js`, with links to every canonical concept page and its existing definition. Indexable HTML pages point to it with `rel="describedby"`. This follows the optional [llms.txt proposal](https://llmstxt.org/) as a discovery aid and adds no client-side JavaScript. The HTML remains the complete source for examples and expected output. This file does not guarantee citations or rankings; [Google's AI search guidance](https://developers.google.com/search/docs/appearance/ai-features) says no special AI text file or schema is required. diff --git a/package-lock.json b/package-lock.json index 4210648..7e4fd29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "clean-css": "5.3.3", "highlight.js": "11.12.0", + "linkedom": "0.18.13", "terser": "5.44.1" }, "engines": { @@ -62,6 +63,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -95,6 +110,132 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/highlight.js": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", @@ -104,6 +245,160 @@ "node": ">=12.0.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -152,6 +447,13 @@ "engines": { "node": ">=0.4.0" } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" } } } diff --git a/package.json b/package.json index 52f1ecd..9bca2e5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "clean-css": "5.3.3", "highlight.js": "11.12.0", + "linkedom": "0.18.13", "terser": "5.44.1" }, "engines": { diff --git a/scripts/build.mjs b/scripts/build.mjs index e215d7e..f89c5af 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -31,7 +31,25 @@ 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 escapeMarkdown = (value) => String(value).replace(/([\\`*_[\]<>])/g, '\\$1'); +// Optional agent discovery aid, generated from the same copy as the HTML. +// Access permissions remain in robots.txt; this file makes no ranking promises. +const llmIndex = () => `# ${brand} + +> Short JavaScript definitions and code examples for beginners and interview preparation. + +An English-language reference covering ${definitions.length} JavaScript concepts. Each linked HTML page contains the definition, a selectable code example, expected output, and an MDN reference. This content is readable without JavaScript or an AI connection. + +## Overview + +- [All JavaScript concepts](${url('/')}): Browse the complete reference. + +${groups.map(group => `## ${group}\n\n${definitions.filter(item => item.group === group).map(item => `- [${escapeMarkdown(labelFor(item))}](${url(pathFor(item))}): ${escapeMarkdown(summaryFor(item))}`).join('\n')}`).join('\n\n')} +`; 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*$/, '') + '…'; @@ -48,6 +66,9 @@ const aiClientPath = `/assets/ai-client.${createHash('sha256').update(aiClient). const aiPanelSource = (await readFile(new URL('../src/ai-panel.js', import.meta.url), 'utf8')).replace("'./ai-client.js'", JSON.stringify(aiClientPath)); const aiPanel = (await minify(aiPanelSource, {module:true})).code; const aiPanelPath = `/assets/ai-panel.${createHash('sha256').update(aiPanel).digest('hex').slice(0,12)}.js`; +const aiLoaderSource = (await readFile(new URL('../src/ai-loader.js', import.meta.url), 'utf8')).replace("'./ai-panel.js'", JSON.stringify(aiPanelPath)); +const aiLoader = (await minify(aiLoaderSource, {module:true})).code; +const aiLoaderPath = `/assets/ai-loader.${createHash('sha256').update(aiLoader).digest('hex').slice(0,12)}.js`; if (analyticsConfig.googleMeasurementId && !/^G-[A-Z0-9]+$/.test(analyticsConfig.googleMeasurementId)) throw new Error('Invalid public Google measurement ID.'); if (analyticsConfig.posthogProjectToken && !/^phc_[A-Za-z0-9]+$/.test(analyticsConfig.posthogProjectToken)) throw new Error('Use a public PostHog project token, never a personal API key.'); if (!['https://eu.i.posthog.com','https://us.i.posthog.com'].includes(analyticsConfig.posthogHost)) throw new Error('Invalid PostHog ingestion host.'); @@ -70,7 +91,7 @@ function renderAIPanel(concept) { return `

AI panel

-

Ask AI to clarify the parts you don’t fully understand, or go more in depth with MDN documentation.

+

Ask AI to clarify the parts you don’t fully understand.

@@ -140,15 +161,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 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/'); @@ -161,6 +201,7 @@ function document({ title, description, path, content, current, noindex = false ${escapeHTML(pageTitle)} ${noindex ? '' : ``} + ${noindex ? '' : ''} @@ -175,7 +216,7 @@ function document({ title, description, path, content, current, noindex = false - ${current ? `` : ''} + ${current ? `` : ''} @@ -213,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('')} `}); } @@ -228,6 +269,7 @@ function conceptPage(concept, index) { @@ -248,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()) { @@ -258,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/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/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/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/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 1dbbec6..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(/

    ([\s\S]*?)<\/ul>/)?.[1] || ''; const example = html.match(/]*>([\s\S]*?)<\/code>/)?.[1]; assert.equal(decode(definition), concept.text); - assert.deepEqual([...definitionList.matchAll(/
  • ([\s\S]*?)<\/li>/g)].map(match => decode(match[1])), originalItems); + assert.deepEqual([...definitionList.matchAll(/
  • ([\s\S]*?)<\/li>/g)].map(match => decode(match[1])), approvedItems); assert.equal(decode(example.replace(/<[^>]+>/g, '')), concept.code); const descriptionIndex = html.indexOf('class="definition'); assert.ok(descriptionIndex >= 0 && descriptionIndex < html.indexOf('/); assert.ok(html.includes(``)); assert.ok(html.includes('aria-current="page"')); - assert.match(html, /