/** * 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)}

    ` + `` + `
    ` ); } 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); } }