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.
112 lines
3.3 KiB
JavaScript
112 lines
3.3 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|