[Story] Reading list page #9

Merged
kpcto merged 6 commits from feature/8 into main 2026-08-25 12:33:22 +00:00
9 changed files with 627 additions and 1 deletions
+26
View File
@@ -6,9 +6,32 @@ vanilla JavaScript.
## Pages
- `index.html` — home page
- `reading.html` — reading list page (curated links grouped by category,
rendered from `data/reading-list.js`)
- `contact.html` — contact page (opens the visitor's mail client with the form
fields pre-filled via a `mailto:` link)
## Reading list
The reading list is a single data file: `data/reading-list.js`. The page code
renders whatever is in it, so it can grow to hundreds of entries without code
changes.
To add a link, edit `data/reading-list.js` only — add an entry with a `title`,
a `url`, a `category`, and an optional one-line `note`:
```js
{
category: "Engineering",
title: "A new article",
url: "https://example.com/article",
note: "Optional one-liner shown under the title.",
},
```
Categories appear on the page in the order they are first used; entries keep
the order they are listed in.
## Development
Serve the directory with any static file server, e.g.:
@@ -31,10 +54,13 @@ npm test
```text
index.html Home page
reading.html Reading list page
contact.html Contact page
css/style.css Global + responsive styles
data/reading-list.js Curated reading list data (edit to add links)
js/mailto.js Pure mailto: URL builder (unit tested)
js/contact.js Contact form wiring (browser + tests)
js/reading-list.js Reading list renderer: data file -> grouped HTML (tested)
tests/ Node built-in test suite
.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main
```
+1
View File
@@ -12,6 +12,7 @@
<a class="brand" href="index.html">My Blog</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="reading.html">Reading</a></li>
<li><a href="contact.html" aria-current="page">Contact</a></li>
</ul>
</nav>
+53
View File
@@ -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);
+82
View File
@@ -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.",
},
];
+1
View File
@@ -12,6 +12,7 @@
<a class="brand" href="index.html">My Blog</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="reading.html">Reading</a></li>
<li><a href="contact.html">Contact</a></li>
</ul>
</nav>
+111
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/** 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
? `<p class="reading-note">${escapeHtml(entry.note)}</p>`
: "";
return (
`<li class="reading-entry">` +
`<a class="reading-link" href="${escapeHtml(entry.url)}">${escapeHtml(entry.title)}</a>` +
note +
`</li>`
);
}
/**
* 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 '<p class="reading-empty">The reading list is empty.</p>';
}
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(
`<section class="reading-category" aria-labelledby="${id}">` +
`<h2 id="${id}">${escapeHtml(category)}</h2>` +
`<ul class="reading-list">${items.map(renderEntry).join("")}</ul>` +
`</section>`
);
}
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);
}
}
+1 -1
View File
@@ -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"
}
}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reading list | My Personal Blog</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<header class="site-header">
<nav class="site-nav" aria-label="Main navigation">
<a class="brand" href="index.html">My Blog</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="reading.html" aria-current="page">Reading</a></li>
<li><a href="contact.html">Contact</a></li>
</ul>
</nav>
</header>
<main>
<h1>Reading list</h1>
<p>Articles and links I&rsquo;ve found worth keeping, grouped by category.</p>
<div id="reading-list">
<noscript><p class="reading-empty">This page needs JavaScript to render the reading list.</p></noscript>
</div>
</main>
<footer class="site-footer">
<p>&copy; 2026 My Personal Blog</p>
</footer>
<script type="module" src="js/reading-list.js"></script>
</body>
</html>
+320
View File
@@ -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 class="reading-link" href="([^"]*)">([^<]*)<\/a>/g;
const ENTRY_LI_RE = /<li class="reading-entry">/g;
const SECTION_RE = /<section class="reading-category"/g;
/** Build a deterministic fixture with `count` entries across `categories`. */
function makeFixture(count, categories) {
const entries = [];
for (let i = 1; i <= count; i++) {
entries.push({
category: categories[i % categories.length],
title: `Entry ${String(i).padStart(3, "0")}`,
url: `https://example.com/articles/${i}`,
...(i % 3 === 0 ? { note: `Note ${i}` } : {}),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Component tests: the reading list page is wired up and reachable
// ---------------------------------------------------------------------------
test("reading page renders a reading list container and loads the renderer module", () => {
assert.match(readingHtml, /<div[^>]*id="reading-list"/);
assert.match(
readingHtml,
/<script[^>]*type="module"[^>]*src="js\/reading-list\.js"/
);
});
test("home and contact pages link to the reading list page from the nav", () => {
assert.match(indexHtml, /<a href="reading\.html">Reading<\/a>/);
assert.match(contactHtml, /<a href="reading\.html">Reading<\/a>/);
});
test("reading page nav marks the reading link as the current page", () => {
assert.match(
readingHtml,
/<a href="reading\.html"[^>]*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)}</h2>`),
`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(/<p class="reading-note">/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(/<p class="reading-note">/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(/<section class="reading-category"[^>]*>/).slice(1);
assert.equal(sections.length, expectedOrder.length);
sections.forEach((section, index) => {
const category = expectedOrder[index];
const heading = section.match(/<h2[^>]*>([^<]*)<\/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 <img src=x onerror=alert(1)>',
title: '<script>alert("x")</script>',
url: "https://example.com/?a=1&b=2",
note: 'note "quoted" & <b>bold</b>',
},
];
const html = renderReadingList(nasty);
assert.ok(!html.includes("<script>"), "raw script tag must not appear");
assert.ok(html.includes("&lt;script&gt;"), "title is escaped");
assert.ok(!html.includes("<img"), "raw img tag must not appear");
assert.ok(html.includes("&lt;img"), "category is escaped");
assert.ok(html.includes('&quot;quoted&quot; &amp; &lt;b&gt;bold&lt;/b&gt;'));
});
test("skips invalid entries and shows an empty-state message when nothing renders", () => {
const mixed = [
{ category: "Good", title: "Fine", url: "https://example.com/fine" },
{ category: "Good", title: "Bad protocol", url: "javascript:alert(1)" },
{ category: "Good", title: "Empty url", url: "" },
{ category: "Good", title: "", url: "https://example.com/empty-title" },
{ category: "Good", title: "Ftp url", url: "ftp://example.com/x" },
null,
];
const html = renderReadingList(mixed);
assert.ok(html.includes("Fine"));
assert.ok(!html.includes("Bad protocol"));
assert.ok(!html.includes("Empty url"));
assert.ok(!html.includes("Ftp url"));
assert.ok(!html.includes("javascript:"));
assert.equal((html.match(ENTRY_LI_RE) || []).length, 1);
assert.equal(
renderReadingList([]),
'<p class="reading-empty">The reading list is empty.</p>'
);
assert.equal(
renderReadingList([{ title: "No url" }]),
'<p class="reading-empty">The reading list is empty.</p>'
);
});
test("a new link can be added by editing only the data file", () => {
const original = renderReadingList(READING_LIST);
assert.ok(!original.includes("https://example.com/brand-new"));
// Simulate an author adding a link to the data file — nothing else changes.
const extended = [
...READING_LIST,
{
category: "Engineering",
title: "A brand new link",
url: "https://example.com/brand-new",
note: "added later, no code changes",
},
];
const html = renderReadingList(extended);
assert.ok(html.includes("https://example.com/brand-new"));
assert.ok(html.includes("A brand new link"));
assert.ok(html.includes("added later, no code changes"));
});
// ---------------------------------------------------------------------------
// Unit tests: helpers
// ---------------------------------------------------------------------------
test("escapeHtml escapes the five HTML-sensitive characters", () => {
assert.equal(escapeHtml(`&<>"'`), "&amp;&lt;&gt;&quot;&#39;");
assert.equal(escapeHtml("plain text"), "plain text");
});
test("isValidUrl accepts http(s) and rejects everything else", () => {
assert.ok(isValidUrl("https://example.com"));
assert.ok(isValidUrl("http://example.com/path?q=1"));
assert.ok(!isValidUrl("javascript:alert(1)"));
assert.ok(!isValidUrl("ftp://example.com"));
assert.ok(!isValidUrl("/relative/path"));
assert.ok(!isValidUrl(""));
});
test("isValidEntry requires a non-empty title and a valid url", () => {
assert.ok(isValidEntry({ title: "T", url: "https://example.com" }));
assert.ok(!isValidEntry(null));
assert.ok(!isValidEntry({}));
assert.ok(!isValidEntry({ title: "T" }));
assert.ok(!isValidEntry({ url: "https://example.com" }));
assert.ok(!isValidEntry({ title: "", url: "https://example.com" }));
assert.ok(!isValidEntry({ title: "T", url: "javascript:alert(1)" }));
});
test("groupByCategory preserves first-seen order and falls back to Uncategorized", () => {
const groups = groupByCategory([
{ title: "A", url: "https://example.com/a", category: "Zebra" },
{ title: "B", url: "https://example.com/b", category: "Alpha" },
{ title: "C", url: "https://example.com/c" },
{ title: "D", url: "https://example.com/d", category: "Zebra" },
{ title: "Broken", url: "javascript:alert(1)", category: "Zebra" },
]);
assert.deepEqual([...groups.keys()], ["Zebra", "Alpha", "Uncategorized"]);
assert.deepEqual(
groups.get("Zebra").map((entry) => entry.title),
["A", "D"]
);
assert.deepEqual(
groups.get("Uncategorized").map((entry) => entry.title),
["C"]
);
});