diff --git a/lume/_config.ts b/lume/_config.ts index 4b9453dba..dae61fdfd 100644 --- a/lume/_config.ts +++ b/lume/_config.ts @@ -12,6 +12,7 @@ import readInfo from "lume/plugins/reading_info.ts"; import annotateYear from "./plugins/annotate_year.ts"; import feed from "./plugins/feed.ts"; +import podcast_feed from "./plugins/podcast_feed.ts"; //import pagefind from "lume/plugins/pagefind.ts"; //import _ from "npm:@pagefind/linux-x64"; @@ -96,6 +97,23 @@ site.use(feed({ },*/ }, })); + +site.use(podcast_feed({ + output: ["/xecast.rss"], + query: "is_xecast=true", + info: { + title: "Xecast", + description: "Thoughts and musings from Xe Iaso, now in podcast form", + published: new Date(), + lang: "en", + }, + items: { + title: "=title", + description: "=desc", + podcast: "=podcast", + }, +})) + site.use(mdx({ components: { "BlockQuote": BlockQuote, diff --git a/lume/plugins/podcast_feed.ts b/lume/plugins/podcast_feed.ts new file mode 100644 index 000000000..ce41e9f65 --- /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) { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ); +} \ No newline at end of file diff --git a/lume/src/xecast/001.mdx b/lume/src/xecast/001.mdx index fbc5fd129..dc40d5e5c 100644 --- a/lume/src/xecast/001.mdx +++ b/lume/src/xecast/001.mdx @@ -2,6 +2,10 @@ title: "Xecast Episode 1: Origins and Techaro" date: 2024-07-28 image: "xecast/episodes/001" +desc: "Xe Iaso talks about their background in tech, the Techaro series, and AI." +podcast: + link: "https://cdn.xeiaso.net/file/christine-static/xecast/episodes/001.mp3" + length: "46339200" --- diff --git a/lume/src/xecast/002.mdx b/lume/src/xecast/002.mdx index 5f4659428..5e115d44d 100644 --- a/lume/src/xecast/002.mdx +++ b/lume/src/xecast/002.mdx @@ -2,6 +2,10 @@ title: "Xecast Episode 2: Conferences, homelabs, and AI" date: 2024-08-11 image: "xecast/episodes/002" +desc: "Xe Iaso shares their DevOpsDays MSP experience, home studio upgrades, and AI musings." +podcast: + link: "https://cdn.xeiaso.net/file/christine-static/xecast/episodes/002.mp3" + length: "49996935" ---