aboutsummaryrefslogtreecommitdiff
path: root/lume/plugins
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2024-08-15 17:32:40 -0400
committerXe Iaso <me@xeiaso.net>2024-08-15 17:32:46 -0400
commitb40fed8e284e03d3b623528a13545e30409612f2 (patch)
tree14d9f804b807003b604de3a1c2e22fa04e66ddba /lume/plugins
parent2e03f38815026a5cfdb1f0331203a213edaf3387 (diff)
downloadxesite-b40fed8e284e03d3b623528a13545e30409612f2.tar.xz
xesite-b40fed8e284e03d3b623528a13545e30409612f2.zip
lume: add xecast RSS feed
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'lume/plugins')
-rw-r--r--lume/plugins/podcast_feed.ts286
1 files changed, 286 insertions, 0 deletions
diff --git a/lume/plugins/podcast_feed.ts b/lume/plugins/podcast_feed.ts
new file mode 100644
index 0000000..ce41e9f
--- /dev/null
+++ b/lume/plugins/podcast_feed.ts
@@ -0,0 +1,286 @@
+import { getExtension } from "lume/core/utils/path.ts";
+import { merge } from "lume/core/utils/object.ts";
+import { getCurrentVersion } from "lume/core/utils/lume_version.ts";
+import { getDataValue } from "lume/core/utils/data_values.ts";
+import { $XML, stringify } from "lume/deps/xml.ts";
+import { Page } from "lume/core/file.ts";
+
+import type Site from "lume/core/site.ts";
+import type { Data } from "lume/core/file.ts";
+import { info } from "lume/deps/log.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 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 feed generator. Set `true` to generate automatically */
+ generator?: string | boolean;
+
+ /** The feed author */
+ author?: 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);
+
+ podcast?: string | ((data: Data) => FeedPodcastItem | undefined);
+}
+
+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,
+ author: "Xe Iaso",
+ },
+ items: {
+ title: "=title",
+ description: "=description",
+ published: "=date",
+ content: "=children",
+ lang: "=lang",
+ },
+};
+
+export interface FeedData {
+ title: string;
+ url: string;
+ description: string;
+ published: Date;
+ lang: string;
+ generator?: string;
+ items: FeedItem[];
+ copyright?: string;
+ author?: string;
+}
+
+export interface FeedItem {
+ title: string;
+ url: string;
+ description: string;
+ published: Date;
+ updated?: Date;
+ content: string;
+ lang: string;
+ podcast?: FeedPodcastItem;
+}
+
+export interface FeedPodcastItem {
+ link: string;
+ length: number;
+}
+
+const defaultGenerator = `Lume ${getCurrentVersion()}`;
+
+export default function (userOptions?: Options) {
+ const options = merge(defaults, userOptions);
+
+ return (site: Site) => {
+ site.addEventListener("beforeSave", () => {
+ 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: getDataValue(rootData, info.title),
+ description: getDataValue(rootData, info.description),
+ published: getDataValue(rootData, info.published),
+ lang: getDataValue(rootData, info.lang),
+ url: site.url("", true),
+ generator: info.generator === true
+ ? defaultGenerator
+ : info.generator || undefined,
+ copyright: `© ${new Date().getFullYear()} ${getDataValue(rootData, info.author)}`,
+ author: getDataValue(rootData, info.author),
+ 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 || "");
+
+ return {
+ title: getDataValue(data, items.title),
+ url: site.url(data.url, true),
+ description: getDataValue(data, items.description),
+ published: getDataValue(data, items.published),
+ updated: getDataValue(data, items.updated),
+ content: fixedContent,
+ lang: getDataValue(data, items.lang),
+ podcast: getDataValue(data, items.podcast),
+ };
+ }),
+ };
+
+ for (const filename of output) {
+ const format = getExtension(filename).slice(1);
+ const file = site.url(filename, true);
+
+ switch (format) {
+ case "rss":
+ case "xml":
+ site.pages.push(
+ Page.create({ url: filename, content: generateRss(feed, file) }),
+ );
+ break;
+
+ default:
+ throw new Error(`Invalid Feed format "${format}"`);
+ }
+ }
+ });
+ };
+}
+
+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): string {
+ const feed = {
+ [$XML]: { cdata: [["rss", "channel", "item", "content:encoded"]] },
+ xml: {
+ "@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/",
+ "@xmlns:itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
+ "@version": "2.0",
+ channel: clean({
+ title: data.title,
+ link: data.url,
+ "atom:link": {
+ "@href": file,
+ "@rel": "self",
+ "@type": "application/rss+xml",
+ },
+ description: data.description,
+ lastBuildDate: data.published.toUTCString(),
+ language: data.lang,
+ generator: data.generator,
+ copyright: data.copyright,
+ "itunes:author": data.author,
+ "itunes:name": data.title,
+ "itunes:category": {
+ "@text": "Technology"
+ },
+ "itunes:explicit": "false",
+ "itunes:image": {
+ "@href": "https://cdn.xeiaso.net/file/christine-static/xecast/itunes-image.jpg",
+ },
+ item: data.items.map((item) =>
+ clean({
+ title: item.title,
+ link: item.url,
+ "itunes:title": item.title,
+ "itunes:summary": item.description,
+ guid: {
+ "@isPermaLink": false,
+ "#text": item.url,
+ },
+ description: item.description,
+ "content:encoded": item.content,
+ pubDate: item.published.toUTCString(),
+ "atom:updated": item.updated?.toISOString(),
+ enclosure: {
+ "@url": item.podcast?.link,
+ "@length": item.podcast?.length,
+ "@type": "audio/mpeg",
+ }
+ })
+ ),
+ }),
+ },
+ };
+
+ return stringify(feed);
+}
+
+/** Remove undefined values of an object */
+function clean(obj: Record<string, unknown>) {
+ return Object.fromEntries(
+ Object.entries(obj).filter(([, value]) => value !== undefined),
+ );
+} \ No newline at end of file