From 37f2c9a2f2dda4cedecc27babbaf4c57eb99f312 Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:06 +0000 Subject: [PATCH 1/6] chore: use default test discovery so npm test works across node builds `node --test ` treats a directory argument as a module path on some Node builds (reproduced on v22.23.2), so `npm test` failed before running any tests. `node --test` with no args discovers the same tests/ files portably. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c872dd..a6da0b3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "type": "module", "description": "A small static personal blog with a mailto-based contact page.", "scripts": { - "test": "node --test tests/" + "test": "node --test" } } From 05cfbad4adf5a827b128349541d5c77295b8997d Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:06 +0000 Subject: [PATCH 2/6] feat: add curated reading list data file Single source of truth for the reading list page. Entries carry a title, url, category, and an optional one-line note; adding a link means editing this file only. --- data/reading-list.js | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 data/reading-list.js diff --git a/data/reading-list.js b/data/reading-list.js new file mode 100644 index 0000000..fd06a7c --- /dev/null +++ b/data/reading-list.js @@ -0,0 +1,82 @@ +/** + * Curated reading list — the single source of truth for the reading list page. + * + * Each entry: + * title — link text shown on the page (required) + * url — absolute http(s) URL the title links to (required) + * note — optional one-line note shown under the title + * category — heading the entry is grouped under (required) + * + * Entries appear on the page in the order they are listed here, and categories + * appear in the order they are first used. + * + * To add a link, edit this file only — no page or code changes are needed. + */ +export const READING_LIST = [ + // Design + { + category: "Design", + title: "Refactoring UI", + url: "https://www.refactoringui.com/", + note: "Practical visual-design tactics for developers.", + }, + { + category: "Design", + title: "A List Apart", + url: "https://alistapart.com/", + note: "Long-running essays on web design and development.", + }, + { + category: "Design", + title: "Laws of UX", + url: "https://lawsofux.com/", + note: "Design principles as quick-reference cards.", + }, + + // Engineering + { + category: "Engineering", + title: "The Twelve-Factor App", + url: "https://12factor.net/", + note: "Methodology for building modern, portable web services.", + }, + { + category: "Engineering", + title: "MDN Web Docs", + url: "https://developer.mozilla.org/", + note: "The web reference I reach for first.", + }, + { + category: "Engineering", + title: "Refactoring Guru", + url: "https://refactoring.guru/", + note: "Design patterns and refactoring, with diagrams.", + }, + { + category: "Engineering", + title: "How to Ask Questions the Smart Way", + url: "http://www.catb.org/esr/faqs/smart-questions.html", + }, + + // Typography + { + category: "Typography", + title: "Butterick's Practical Typography", + url: "https://practicaltypography.com/", + note: "Typography rules, explained in plain English.", + }, + { + category: "Typography", + title: "Fonts in Use", + url: "https://fontsinuse.com/", + note: "Real-world examples of type in action.", + }, + + // Writing + { + category: "Writing", + title: "Paul Graham's Essays", + url: "http://www.paulgraham.com/articles.html", + note: "Essays on startups, writing, and thinking.", + }, +]; From ffdcd35cea73d9b021da3e3c646dfb14e2b4e6cf Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:06 +0000 Subject: [PATCH 3/6] feat: add reading list renderer module Renders the data file as category-grouped HTML. Pure string builder with guarded browser wiring so it is unit-testable in Node; escapes all text, skips invalid entries, and handles duplicate category slugs. --- js/reading-list.js | 111 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 js/reading-list.js diff --git a/js/reading-list.js b/js/reading-list.js new file mode 100644 index 0000000..e7a94e4 --- /dev/null +++ b/js/reading-list.js @@ -0,0 +1,111 @@ +/** + * Reading list renderer — turns the curated reading list data file into + * category-grouped HTML. Pure string builder (no DOM), so it can be unit + * tested in Node; the browser wiring at the bottom is guarded accordingly. + */ +import { READING_LIST } from "../data/reading-list.js"; + +/** HTML-escape a value so it is safe to embed in markup. */ +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** True when the value is an absolute http(s) URL. */ +export function isValidUrl(value) { + try { + const url = new URL(String(value)); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +/** True when the entry has everything needed to render. */ +export function isValidEntry(entry) { + return Boolean( + entry && + typeof entry.title === "string" && + entry.title.length > 0 && + isValidUrl(entry.url) + ); +} + +/** + * Group valid entries by category, preserving first-seen category order and + * entry order within each category. Entries without a category fall under + * "Uncategorized". Invalid entries are skipped. + */ +export function groupByCategory(entries) { + const groups = new Map(); + for (const entry of entries) { + if (!isValidEntry(entry)) continue; + const category = entry.category || "Uncategorized"; + if (!groups.has(category)) groups.set(category, []); + groups.get(category).push(entry); + } + return groups; +} + +/** Slugify a category name into a stable, unique HTML id. */ +function slugify(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Render one entry: a title link plus an optional one-line note. */ +function renderEntry(entry) { + const note = entry.note + ? `

${escapeHtml(entry.note)}

` + : ""; + return ( + `
  • ` + + `${escapeHtml(entry.title)}` + + note + + `
  • ` + ); +} + +/** + * Render the full reading list as an HTML string, grouped by category. + * + * @param {Array<{title: string, url: string, note?: string, category?: string}>} entries + * @returns {string} HTML string ready to be injected into `#reading-list` + */ +export function renderReadingList(entries) { + const groups = groupByCategory(entries); + if (groups.size === 0) { + return '

    The reading list is empty.

    '; + } + + const usedIds = new Set(); + const sections = []; + for (const [category, items] of groups) { + let id = slugify(category) || "category"; + while (usedIds.has(id)) id = `${id}-${usedIds.size + 1}`; + usedIds.add(id); + + sections.push( + `
    ` + + `

    ${escapeHtml(category)}

    ` + + `
      ${items.map(renderEntry).join("")}
    ` + + `
    ` + ); + } + return sections.join(""); +} + +// Browser-only wiring — render the data file into the page. Guarded so this +// module stays importable (and testable) in Node. +if (typeof window !== "undefined" && typeof document !== "undefined") { + const container = document.getElementById("reading-list"); + if (container) { + container.innerHTML = renderReadingList(READING_LIST); + } +} From dfaaeaf7f475ab2c617fb924a711f56be23b9448 Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:09 +0000 Subject: [PATCH 4/6] feat: add reading list page and link it from site navigation New reading.html renders the data file into a #reading-list container and marks itself as the current page. Home and contact pages gain a Reading nav link. CSS adds responsive category/list styles (two columns on wide screens) so long lists stay scannable. --- contact.html | 1 + css/style.css | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 1 + reading.html | 32 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 reading.html diff --git a/contact.html b/contact.html index d7d37fc..f442c69 100644 --- a/contact.html +++ b/contact.html @@ -12,6 +12,7 @@ My Blog diff --git a/css/style.css b/css/style.css index 0dc0a2f..eef36d8 100644 --- a/css/style.css +++ b/css/style.css @@ -120,6 +120,59 @@ button[type="submit"]:hover { filter: brightness(1.1); } +/* --- Reading list --- */ +.reading-category { + margin-bottom: 2rem; +} + +.reading-category h2 { + font-size: 1.1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.35rem; + margin-bottom: 0.75rem; +} + +.reading-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.75rem; +} + +.reading-entry { + display: grid; + gap: 0.1rem; +} + +.reading-link { + color: var(--accent); + text-decoration: none; + overflow-wrap: anywhere; +} + +.reading-link:hover, +.reading-link:focus { + text-decoration: underline; +} + +.reading-note { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.reading-empty { + color: var(--muted); +} + +/* Two columns once there is room — keeps long lists scannable. */ +@media (min-width: 40rem) { + .reading-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* --- Footer --- */ .site-footer { border-top: 1px solid var(--border); diff --git a/index.html b/index.html index 28aa417..7fe5e75 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,7 @@ My Blog diff --git a/reading.html b/reading.html new file mode 100644 index 0000000..09d9219 --- /dev/null +++ b/reading.html @@ -0,0 +1,32 @@ + + + + + + Reading list | My Personal Blog + + + + +
    +

    Reading list

    +

    Articles and links I’ve found worth keeping, grouped by category.

    +
    + +
    +
    +
    +

    © 2026 My Personal Blog

    +
    + + + From f278df12148099891d0823a7d057eb43e7365061 Mon Sep 17 00:00:00 2001 From: bot-implementer Date: Tue, 25 Aug 2026 12:30:10 +0000 Subject: [PATCH 5/6] test: component + unit tests for the reading list page Covers: page wiring and nav reachability, data-file integrity, grouped rendering, valid hrefs, optional notes, HTML escaping, invalid-entry skipping, data-only extensibility, and a 500-entry fixture (counts, grouping, and a generous render-time bound). --- tests/reading-list.test.js | 320 +++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/reading-list.test.js diff --git a/tests/reading-list.test.js b/tests/reading-list.test.js new file mode 100644 index 0000000..760d685 --- /dev/null +++ b/tests/reading-list.test.js @@ -0,0 +1,320 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { READING_LIST } from "../data/reading-list.js"; +import { + escapeHtml, + groupByCategory, + isValidEntry, + isValidUrl, + renderReadingList, +} from "../js/reading-list.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const readingHtml = readFileSync(join(root, "reading.html"), "utf8"); +const indexHtml = readFileSync(join(root, "index.html"), "utf8"); +const contactHtml = readFileSync(join(root, "contact.html"), "utf8"); +const rendererSource = readFileSync(join(root, "js/reading-list.js"), "utf8"); + +const ANCHOR_RE = /([^<]*)<\/a>/g; +const ENTRY_LI_RE = /
  • /g; +const SECTION_RE = /
    { + assert.match(readingHtml, /]*id="reading-list"/); + assert.match( + readingHtml, + /]*type="module"[^>]*src="js\/reading-list\.js"/ + ); +}); + +test("home and contact pages link to the reading list page from the nav", () => { + assert.match(indexHtml, /Reading<\/a>/); + assert.match(contactHtml, /Reading<\/a>/); +}); + +test("reading page nav marks the reading link as the current page", () => { + assert.match( + readingHtml, + /]*aria-current="page"[^>]*>Reading<\/a>/ + ); +}); + +test("renderer module loads the reading list data file", () => { + assert.match( + rendererSource, + /import\s*\{\s*READING_LIST\s*\}\s*from\s*"\.\.\/data\/reading-list\.js"/ + ); +}); + +// --------------------------------------------------------------------------- +// Data file tests: every entry is renderable, note stays optional +// --------------------------------------------------------------------------- + +test("every entry in the data file has a title, a category, and a valid http(s) url", () => { + assert.ok(Array.isArray(READING_LIST), "data file exports an array"); + assert.ok(READING_LIST.length > 0, "data file has at least one entry"); + for (const entry of READING_LIST) { + assert.ok( + typeof entry.title === "string" && entry.title.trim().length > 0, + `entry needs a title: ${JSON.stringify(entry)}` + ); + assert.ok( + typeof entry.category === "string" && entry.category.trim().length > 0, + `entry needs a category: ${JSON.stringify(entry)}` + ); + assert.ok( + isValidUrl(entry.url), + `entry url must be an absolute http(s) url: ${entry.url}` + ); + assert.ok( + entry.note === undefined || + (typeof entry.note === "string" && entry.note.length > 0), + `note must be a non-empty string or absent: ${JSON.stringify(entry)}` + ); + } +}); + +test("seed data includes an entry without a note so the optional path is exercised", () => { + assert.ok( + READING_LIST.some((entry) => !entry.note), + "expected at least one entry with no note" + ); +}); + +// --------------------------------------------------------------------------- +// Renderer tests: grouping, links, notes, and robustness +// --------------------------------------------------------------------------- + +test("renders links grouped by category from the data file", () => { + const html = renderReadingList(READING_LIST); + const categories = [...new Set(READING_LIST.map((entry) => entry.category))]; + + assert.equal((html.match(SECTION_RE) || []).length, categories.length); + for (const category of categories) { + assert.ok( + html.includes(`>${escapeHtml(category)}`), + `expected a heading for category ${category}` + ); + } + for (const entry of READING_LIST) { + assert.ok( + html.includes(escapeHtml(entry.title)), + `expected title ${entry.title}` + ); + assert.ok(html.includes(entry.url), `expected url ${entry.url}`); + } +}); + +test("each rendered link has a valid href and shows its title, with the note shown only when present", () => { + const fixture = [ + { + category: "Testing", + title: "With a note", + url: "https://example.com/with-note", + note: "One line of context.", + }, + { + category: "Testing", + title: "Without a note", + url: "https://example.com/without-note", + }, + ]; + const html = renderReadingList(fixture); + + const anchors = [...html.matchAll(ANCHOR_RE)]; + assert.equal(anchors.length, 2); + for (const [, href, title] of anchors) { + assert.ok(isValidUrl(href), `href must be a valid http(s) url: ${href}`); + assert.ok( + fixture.some((entry) => entry.title === title && entry.url === href), + `rendered link ${title} -> ${href} must match a data entry` + ); + } + + assert.ok(html.includes("One line of context."), "note is rendered when present"); + assert.equal( + (html.match(/

    /g) || []).length, + 1, + "no note paragraph for the entry without a note" + ); +}); + +test("renders correctly against a 500+ entry fixture", () => { + const fixture = makeFixture(500, ["A", "B", "C", "D", "E"]); + + const start = performance.now(); + const html = renderReadingList(fixture); + const elapsed = performance.now() - start; + + assert.ok( + elapsed < 5000, + `rendering 500 entries should stay fast, took ${elapsed.toFixed(1)}ms` + ); + + // Every entry rendered exactly once. + assert.equal((html.match(ENTRY_LI_RE) || []).length, fixture.length); + assert.equal((html.match(ANCHOR_RE) || []).length, fixture.length); + assert.ok(html.includes("https://example.com/articles/1")); + assert.ok(html.includes("https://example.com/articles/500")); + + // Notes rendered only for entries that have them. + assert.equal( + (html.match(/

    /g) || []).length, + fixture.filter((entry) => entry.note).length + ); + + // Grouped by category: one section per category, containing exactly that + // category's entries, in the first-seen category order. + const expectedOrder = [...new Set(fixture.map((entry) => entry.category))]; + const expectedCounts = new Map(); + for (const entry of fixture) { + expectedCounts.set(entry.category, (expectedCounts.get(entry.category) || 0) + 1); + } + + const sections = html.split(/

    ]*>/).slice(1); + assert.equal(sections.length, expectedOrder.length); + sections.forEach((section, index) => { + const category = expectedOrder[index]; + const heading = section.match(/]*>([^<]*)<\/h2>/); + assert.ok(heading, `section ${index} should have a heading`); + assert.equal(heading[1], category); + assert.equal( + (section.match(ENTRY_LI_RE) || []).length, + expectedCounts.get(category), + `section for ${category} should contain exactly its own entries` + ); + }); +}); + +test("escapes HTML in titles, notes, and categories", () => { + const nasty = [ + { + category: 'Design ', + title: '', + url: "https://example.com/?a=1&b=2", + note: 'note "quoted" & bold', + }, + ]; + const html = renderReadingList(nasty); + + assert.ok(!html.includes("