feat: add reading list renderer module
Renders the data file as category-grouped HTML. Pure string builder with guarded browser wiring so it is unit-testable in Node; escapes all text, skips invalid entries, and handles duplicate category slugs.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user