/** * 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 ( `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( `