// html-escaper's five-entity table is XML-safe: `'` becomes the numeric // `'` reference rather than `'`, which XML accepts equally. import { escape as escapeXml } from "html-escaper"; import { normalizeBasePath, withBasePath } from "../core/base-path.ts"; import type { BlumeProject } from "../core/project-graph.ts"; import { siteRoot } from "../core/site-url.ts"; import type { PageRecord } from "../core/types.ts"; /** A single feed entry derived from a content page. */ export interface RssItem { title: string; /** Absolute item URL. */ link: string; description?: string; /** Publish date, when the page declares one. */ date?: Date; } /** A resolved RSS feed for one content type. */ export interface RssFeed { /** Content type the feed covers, e.g. `blog`. */ type: string; /** Feed URL path, e.g. `/blog/rss.xml`. */ path: string; /** Channel title, e.g. `Blume — Blog`. */ title: string; /** Absolute site link. */ link: string; description?: string; items: RssItem[]; } const capitalize = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1); /** Publish date for a page: top-level `date`, else `changelog.date`. */ const pageDate = (page: PageRecord): Date | undefined => { const raw = page.meta.date ?? page.meta.changelog?.date; if (!raw) { return; } const date = new Date(raw); return Number.isNaN(date.getTime()) ? undefined : date; }; /** * Build an RSS feed per configured content type that has publishable pages. * Returns an empty list when RSS is disabled or no `deployment.site` is set — * absolute URLs are required for a valid feed, mirroring `buildSitemap`. */ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => { const { config } = project; const { rss } = config.seo; const { site } = config.deployment; if (!(rss.enabled && site)) { return []; } const base = siteRoot(site); // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top. // The feed's own `link`/self URL points at the docs root under that base, while // `path` stays base-less (it's also the on-disk output location). const deployBase = normalizeBasePath(config.deployment.base); const rootLink = `${base}${deployBase}`; const feeds: RssFeed[] = []; for (const type of rss.types) { const pages = project.graph.pages.filter( (page) => page.contentType === type && !(page.meta.draft || page.meta.sidebar.hidden) ); if (pages.length === 0) { continue; } const items: RssItem[] = pages .map((page) => ({ date: pageDate(page), description: page.description, // Encode like the sitemap does: a route with spaces or non-ASCII // must still yield a valid / URL after XML decoding. link: encodeURI(`${base}${withBasePath(deployBase, page.route)}`), title: page.title, })) .toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0)) .slice(0, rss.limit); feeds.push({ description: config.description, items, link: rootLink, path: `/${type}/rss.xml`, title: `${config.title} — ${capitalize(type)}`, type, }); } return feeds; }; const renderItem = (item: RssItem): string => { const parts = [ ` ${escapeXml(item.title)}`, ` ${escapeXml(item.link)}`, ` ${escapeXml(item.link)}`, ]; if (item.description) { parts.push(` ${escapeXml(item.description)}`); } if (item.date) { parts.push(` ${item.date.toUTCString()}`); } return ` \n${parts.join("\n")}\n `; }; /** Serialize a resolved feed into an RSS 2.0 XML document. */ export const renderRssFeed = (feed: RssFeed): string => { const feedSelfHref = `${feed.link}${feed.path}`; const channel = [ ` ${escapeXml(feed.title)}`, ` ${escapeXml(feed.link)}`, ` ${escapeXml(feed.description ?? feed.title)}`, ` `, ]; const items = feed.items.map(renderItem).join("\n"); return ` ${channel.join("\n")} ${items} `; };