import { getExtension } from "../core/utils/path.ts"; import { isPlainObject, merge } from "../core/utils/object.ts"; import { getGenerator } from "../core/utils/lume_version.ts"; import { getDataValue, getPlainDataValue } from "../core/utils/data_values.ts"; import { cdata, stringify } from "../deps/xml.ts"; import { Page } from "../core/file.ts"; import { log } from "../core/utils/log.ts"; import { parseDate } from "../core/utils/date.ts"; import type Site from "../core/site.ts"; import type { Data } from "../core/file.ts"; import type { stringifyable } from "../deps/xml.ts"; export interface Options { /** The output filenames */ output?: string | string[]; /** The query to search the pages */ query?: string; /** The sort order */ sort?: string; /** The maximum number of items */ limit?: number; /** The xml-stylesheet document for styling (only for xml formats) */ stylesheet?: string; /** The feed info */ info?: FeedInfoOptions; /** The feed items configuration */ items?: FeedItemOptions; } export interface FeedInfoOptions { /** The feed title */ title?: string; /** The feed subtitle */ subtitle?: string; /** * The feed published date * @default `new Date()` */ published?: Date; /** The feed description */ description?: string; /** The feed language */ lang?: string; /** The WebSub hubs for the feed */ hubs?: string[]; /** The feed generator. Set `true` to generate automatically */ generator?: string | boolean; /** The feed author name */ authorName?: string; /** The feed author URL */ authorUrl?: string; /** The main image of the site */ image?: string; /** The logotype or icon of the site */ icon?: string; /** The color theme of the site */ color?: string; } export interface FeedItemOptions { /** The item title */ title?: string | ((data: Data) => string | undefined); /** The item description */ description?: string | ((data: Data) => string | undefined); /** The item published date */ published?: string | ((data: Data) => Date | undefined); /** The item updated date */ updated?: string | ((data: Data) => Date | undefined); /** The item content */ content?: string | ((data: Data) => string | undefined); /** The item language */ lang?: string | ((data: Data) => string | undefined); /** The item image */ image?: string | ((data: Data) => string | undefined); /** The item author name */ authorName?: string; /** The item author URL */ authorUrl?: string; } export const defaults: Options = { /** The output filenames */ output: "/feed.rss", /** The query to search the pages */ query: "", /** The sort order */ sort: "date=desc", /** The maximum number of items */ limit: 10, /** The feed info */ info: { title: "My RSS Feed", published: new Date(), description: "", lang: "en", generator: true, }, items: { title: "=title", description: "=description", published: "=date", content: "=children", lang: "=lang", }, }; export interface Author { name?: string; url?: string; } export interface FeedData { title: string; url: string; description: string; published: Date; lang: string; hubs?: string[]; generator?: string; items: FeedItem[]; author?: Author; image?: string; icon?: string; color?: string; } export interface FeedItem { title: string; url: string; description: string; published: Date; updated?: Date; content: string; lang: string; image?: string; author?: Author; } const defaultGenerator = getGenerator(); /** * A plugin to generate RSS, Atom and JSON feeds * @see https://lume.land/plugins/feed/ */ export function feed( userOptionsFn?: Options | Options[] | (() => Options[] | Options), ) { return (site: Site) => { site.process(function processFeed() { const userOptions = typeof userOptionsFn === "function" ? userOptionsFn() : userOptionsFn; const optionsArray = Array.isArray(userOptions) ? userOptions : [userOptions]; for (const opt of optionsArray) { const options = merge(defaults, opt); const output = Array.isArray(options.output) ? options.output : [options.output]; const pages = site.search.pages( options.query, options.sort, options.limit, ) as Data[]; const { info, items } = options; const rootData = site.source.data.get("/") || {}; const feed: FeedData = { title: getPlainDataValue(rootData, info.title), description: getPlainDataValue(rootData, info.description), published: getDataValue(rootData, info.published), lang: getDataValue(rootData, info.lang), hubs: info.hubs, url: site.url("", true), generator: info.generator === true ? defaultGenerator : info.generator || undefined, author: getAuthor(rootData, info), image: info.image, icon: info.icon, color: info.color, items: pages.map((data): FeedItem => { const content = getDataValue(data, items.content)?.toString(); const pageUrl = site.url(data.url, true); const fixedContent = fixUrls(new URL(pageUrl), content || ""); const imagePath = getDataValue(data, items.image); const image = imagePath !== undefined ? site.url(imagePath, true) : undefined; return { title: getPlainDataValue(data, items.title), url: site.url(data.url, true), description: getPlainDataValue(data, items.description), author: getAuthor(data, items), published: toDate(getDataValue(data, items.published)) || new Date(), updated: toDate(getDataValue(data, items.updated)), content: fixedContent, lang: getDataValue(data, items.lang), image, }; }), }; for (const filename of output) { const format = getExtension(filename).slice(1); const file = site.url(filename, true); switch (format) { case "rss": case "feed": case "xml": site.pages.push( Page.create({ url: filename, content: generateRss(feed, file, options.stylesheet), }), ); break; case "json": site.pages.push( Page.create({ url: filename, content: generateJson(feed, file), }), ); break; case "atom": site.pages.push( Page.create({ url: filename, content: generateAtom(feed, file, options.stylesheet), }), ); break; default: log.error(`[feed plugin] Input output format: ${filename}`); } } } }); }; } function getAuthor( data: Partial, info: FeedInfoOptions | FeedItemOptions, ): Author | undefined { const name = getPlainDataValue(data, info.authorName); const url = getDataValue(data, info.authorUrl); if (name || url) { return { name, url }; } } function fixUrls(base: URL, html: string): string { return html.replaceAll( /\s(href|src)="([^"]+)"/g, (_match, attr, value) => ` ${attr}="${new URL(value, base).href}"`, ); } function generateRss( data: FeedData, file: string, stylesheet?: string, ): string { const feed: stringifyable = { "@version": "1.0", "@encoding": "UTF-8", rss: { "@xmlns:content": "http://purl.org/rss/1.0/modules/content/", "@xmlns:wfw": "http://wellformedweb.org/CommentAPI/", "@xmlns:dc": "http://purl.org/dc/elements/1.1/", "@xmlns:atom": "http://www.w3.org/2005/Atom", "@xmlns:sy": "http://purl.org/rss/1.0/modules/syndication/", "@xmlns:slash": "http://purl.org/rss/1.0/modules/slash/", ...( data.image !== undefined || data.icon !== undefined || data.color !== undefined ? { "@xmlns:webfeeds": "http://webfeeds.org/rss/1.0" } : {} ), "@version": "2.0", channel: { title: data.title, link: data.url, "atom:link": [ { "@href": file, "@rel": "self", "@type": "application/rss+xml", }, ...(data.hubs ?? []).map((hub) => ({ "@href": hub, "@rel": "hub", })), ], description: data.description, lastBuildDate: data.published.toUTCString(), language: data.lang, generator: data.generator, author: { name: data.author?.name, uri: data.author?.url, }, "webfeeds:cover": { "@image": data.image, }, "webfeeds:logo": data.icon, "webfeeds:accentColor": data.color, item: data.items.map((item) => ({ title: item.title, link: item.url, guid: { "@isPermaLink": false, "#text": item.url, }, author: { name: item.author?.name, uri: item.author?.url, }, description: item.description, "content:encoded": cdata(item.content), pubDate: item.published.toUTCString(), "atom:updated": item.updated?.toISOString(), meta: item.image ? { "@property": "og:image", "@content": item.image } : undefined, })), }, }, }; if (stylesheet) { feed["#instructions"] = { "xml-stylesheet": { "@type": "text/xsl", "@href": stylesheet, }, }; } return stringify(clean(feed)); } function generateJson(data: FeedData, file: string): string { const feed = { version: "https://jsonfeed.org/version/1.1", title: data.title, home_page_url: data.url, feed_url: file, hubs: data.hubs && data.hubs.map((hub) => ({ "type": "WebSub", "url": hub })), description: data.description, language: data.lang, authors: data.author ? [data.author] : undefined, icon: data.image, favicon: data.icon, items: data.items.map((item) => ({ id: item.url, url: item.url, title: item.title, language: item.lang, authors: item.author ? [item.author] : undefined, content_html: item.content, date_published: item.published.toISOString(), date_modified: item.updated?.toISOString(), image: item.image, })), }; return JSON.stringify(clean(feed)); } function generateAtom( data: FeedData, file: string, stylesheet?: string, ): string { const feed: stringifyable = { "@version": "1.0", "@encoding": "UTF-8", feed: { "@xmlns": "http://www.w3.org/2005/Atom", ...( data.image !== undefined || data.icon !== undefined || data.color !== undefined ? { "@xmlns:webfeeds": "http://webfeeds.org/rss/1.0" } : {} ), "@xml:lang": data.lang, id: file, title: data.title, subtitle: data.description, updated: data.published.toISOString(), link: [ { "@href": data.url, "@rel": "alternate", "@type": "text/html", }, { "@href": file, "@rel": "self", "@type": "application/atom+xml", }, ...(data.hubs ?? []).map((hub) => ({ "@href": hub, "@rel": "hub", })), ], generator: data.generator, author: { name: data.author?.name, uri: data.author?.url, }, icon: data.icon, logo: data.image, "webfeeds:cover": { "@image": data.image, }, "webfeeds:logo": data.icon, "webfeeds:accentColor": data.color, entry: data.items.map((item) => ({ "@xml:lang": item.lang !== data.lang ? item.lang : undefined, id: item.url, title: item.title, updated: item.updated?.toISOString(), published: item.published.toISOString(), link: { "@href": item.url, "@rel": "alternate", "@type": "text/html", }, author: { name: item.author?.name, uri: item.author?.url, }, summary: item.description, content: item.content ? { "@type": "html", "#text": item.content, } : undefined, })), }, }; if (stylesheet) { feed["#instructions"] = { "xml-stylesheet": { "@type": "text/xsl", "@href": stylesheet, }, }; } return stringify(clean(feed)); } /** Remove undefined values of an object recursively */ function clean(obj: Record): Record { return Object.fromEntries( Object.entries(obj) .map(([key, value]): [string, unknown] => { if (isPlainObject(value)) { const cleanValue = clean(value); return [ key, Object.keys(cleanValue).length > 0 ? cleanValue : undefined, ]; } if (Array.isArray(value)) { const cleanValue = value .map((v) => isPlainObject(v) ? clean(v) : v) .filter((v) => v !== undefined); return [ key, cleanValue.length > 0 ? cleanValue : undefined, ]; } return [key, value]; }) .filter(([, value]) => value !== undefined), ); } function toDate(date?: string | number | Date): Date | undefined { if (date instanceof Date) { return date; } if (date === undefined) { return; } return parseDate(date); } export default feed;