Merge pull request '[Story] Reading list page' (#9) from feature/8 into main
CI / Run tests (push) Has been cancelled
CI / Run tests (push) Has been cancelled
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
@@ -6,9 +6,32 @@ vanilla JavaScript.
|
|||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
- `index.html` — home page
|
- `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
|
- `contact.html` — contact page (opens the visitor's mail client with the form
|
||||||
fields pre-filled via a `mailto:` link)
|
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
|
## Development
|
||||||
|
|
||||||
Serve the directory with any static file server, e.g.:
|
Serve the directory with any static file server, e.g.:
|
||||||
@@ -31,10 +54,13 @@ npm test
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
index.html Home page
|
index.html Home page
|
||||||
|
reading.html Reading list page
|
||||||
contact.html Contact page
|
contact.html Contact page
|
||||||
css/style.css Global + responsive styles
|
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/mailto.js Pure mailto: URL builder (unit tested)
|
||||||
js/contact.js Contact form wiring (browser + tests)
|
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
|
tests/ Node built-in test suite
|
||||||
.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main
|
.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<a class="brand" href="index.html">My Blog</a>
|
<a class="brand" href="index.html">My Blog</a>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="index.html">Home</a></li>
|
<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>
|
<li><a href="contact.html" aria-current="page">Contact</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -120,6 +120,59 @@ button[type="submit"]:hover {
|
|||||||
filter: brightness(1.1);
|
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 --- */
|
/* --- Footer --- */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<a class="brand" href="index.html">My Blog</a>
|
<a class="brand" href="index.html">My Blog</a>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="reading.html">Reading</a></li>
|
||||||
<li><a href="contact.html">Contact</a></li>
|
<li><a href="contact.html">Contact</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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
|
||||||
|
? `<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
@@ -5,6 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "A small static personal blog with a mailto-based contact page.",
|
"description": "A small static personal blog with a mailto-based contact page.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/"
|
"test": "node --test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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’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>© 2026 My Personal Blog</p>
|
||||||
|
</footer>
|
||||||
|
<script type="module" src="js/reading-list.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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("<script>"), "title is escaped");
|
||||||
|
assert.ok(!html.includes("<img"), "raw img tag must not appear");
|
||||||
|
assert.ok(html.includes("<img"), "category is escaped");
|
||||||
|
assert.ok(html.includes('"quoted" & <b>bold</b>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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(`&<>"'`), "&<>"'");
|
||||||
|
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"]
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user