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

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