From 5c7a8aa847ed708a6809f251b93188ab611b89cd Mon Sep 17 00:00:00 2001 From: Oscar Otero Date: Thu, 27 Apr 2023 20:51:23 +0200 Subject: [PATCH] renamed the plugin rss to feed, added support for JSON Feed --- CHANGELOG.md | 2 +- core/utils.ts | 2 +- plugins/feed.ts | 207 ++++++++++++++++++ plugins/metas.ts | 66 ++---- plugins/rss.ts | 96 -------- plugins/utils.ts | 29 +++ .../{rss.test.ts.snap => feed.test.ts.snap} | 62 ++++-- tests/{rss.test.ts => feed.test.ts} | 10 +- tests/plugins.test.ts | 4 +- 9 files changed, 305 insertions(+), 173 deletions(-) create mode 100644 plugins/feed.ts delete mode 100644 plugins/rss.ts create mode 100644 plugins/utils.ts rename tests/__snapshots__/{rss.test.ts.snap => feed.test.ts.snap} (81%) rename tests/{rss.test.ts => feed.test.ts} (68%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d534b5e2..a17a798e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Any BREAKING CHANGE between minor versions will be documented here in upper case ## [1.17.0] - Unreleased ### Added -- RSS Plugin [#413] +- Feed Plugin [#413] - Support for negative tags in `search` plugin. For example: `search.pages("tag1 !tag2")`. - Support for remote files in `sass` plugin. diff --git a/core/utils.ts b/core/utils.ts index aeb62c2b..9b43fef7 100644 --- a/core/utils.ts +++ b/core/utils.ts @@ -11,6 +11,7 @@ export const pluginNames = [ "date", "esbuild", "eta", + "feed", "filter_pages", "imagick", "inline", @@ -35,7 +36,6 @@ export const pluginNames = [ "relative_urls", "remark", "resolve_urls", - "rss", "sass", "sheets", "sitemap", diff --git a/plugins/feed.ts b/plugins/feed.ts new file mode 100644 index 00000000..58c01d5e --- /dev/null +++ b/plugins/feed.ts @@ -0,0 +1,207 @@ +import { + DeepPartial, + getExtension, + getLumeVersion, + merge, +} from "../core/utils.ts"; +import { getDataValue } from "./utils.ts"; +import { $XML, stringify } from "../deps/xml.ts"; +import { Page } from "../core/filesystem.ts"; +import { Search } from "../plugins/search.ts"; + +import type { Data, Site } from "../core.ts"; + +export interface Options { + output: string | string[]; + query: string; + sort: string; + limit: number; + info: { + title: string; + subtitle?: string; + date: Date; + description: string; + lang: string; + generator: string | boolean; + }; + items: { + title: string; + description: string; + date: string; + content: string; + lang: string; + }; +} + +export const defaults: Options = { + output: "/feed.rss", + query: "", + sort: "date=desc", + limit: 10, + info: { + title: "My RSS Feed", + date: new Date(), + description: "", + lang: "en", + generator: true, + }, + items: { + title: "title", + description: "description", + date: "date", + content: "children", + lang: "lang", + }, +}; + +export interface FeedData { + title: string; + url: string; + description: string; + date: Date; + lang: string; + generator?: string; + items: FeedItem[]; +} + +export interface FeedItem { + title: string; + url: string; + description: string; + date: Date; + content: string; + lang: string; +} + +const defaultGenerator = `Lume ${getLumeVersion()}`; + +export default (userOptions?: DeepPartial) => { + const options = merge(defaults, userOptions); + + return (site: Site) => { + const search = new Search(site, true); + + site.addEventListener("afterRender", () => { + const output = Array.isArray(options.output) + ? options.output + : [options.output]; + + const pages = search.pages( + options.query, + options.sort, + options.limit, + ) as Data[]; + const { info } = options; + + const feed: FeedData = { + title: info.title, + description: info.description, + date: info.date, + lang: info.lang, + url: site.url("", true), + generator: info.generator === true + ? defaultGenerator + : info.generator || undefined, + items: pages.map((data): FeedItem => { + return { + title: options.items.title && + getDataValue(data, `=${options.items.title}`), + url: site.url(data.url as string, true), + description: options.items.description && + getDataValue(data, `=${options.items.description}`), + date: options.items.date && + getDataValue(data, `=${options.items.date}`), + content: options.items.content && + getDataValue(data, `=${options.items.content}`)?.toString(), + lang: options.items.lang && + getDataValue(data, `=${options.items.lang}`), + }; + }), + }; + + 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(filename, generateRss(feed, file))); + break; + + case "json": + site.pages.push(Page.create(filename, generateJson(feed, file))); + break; + + default: + throw new Error(`Invalid Feed format "${format}"`); + } + } + }); + }; +}; + +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/", + "@version": "2.0", + channel: { + title: data.title, + link: data.url, + "atom:link": { + "@href": file, + "@rel": "self", + "@type": "application/rss+xml", + }, + description: data.description, + lastBuildDate: data.date.toISOString(), + language: data.lang, + generator: data.generator, + item: data.items.map((item) => ({ + title: item.title, + link: item.url, + guid: { + "@isPermaLink": false, + "#text": item.url, + }, + description: item.description, + "content:encoded": item.content, + pubDate: item.date.toISOString(), + })), + }, + }, + }; + + return stringify(feed); +} + +function generateJson(data: FeedData, file: string): string { + const feed = { + version: "https://jsonfeed.org/version/1", + title: data.title, + home_page_url: data.url, + feed_url: file, + description: data.description, + items: data.items.map((item) => ({ + id: item.url, + url: item.url, + title: item.title, + content_html: item.content, + date_published: item.date.toUTCString(), + })), + }; + + return JSON.stringify(feed); +} diff --git a/plugins/metas.ts b/plugins/metas.ts index 451e30b2..afa043c7 100644 --- a/plugins/metas.ts +++ b/plugins/metas.ts @@ -1,4 +1,5 @@ import { getLumeVersion, merge } from "../core/utils.ts"; +import { getDataValue } from "./utils.ts"; import type { Page, Site } from "../core.ts"; import type { HTMLDocument } from "../deps/dom.ts"; @@ -9,14 +10,6 @@ export interface Options { /** The key name for the transformations definitions */ name: string; - - /** - * Use page data as meta data if the correspond metas value does not exists - * @deprecated Use "=key" syntax instead - */ - defaultPageData?: { - [K in keyof MetaData]?: string; - }; } export interface MetaData { @@ -83,55 +76,26 @@ export default function (userOptions?: Partial) { return; } - const getMetaValue = (key: T) => { - const value = metas[key]; - - // Get the value from the page data - if (typeof value === "string" && value.startsWith("=")) { - const key = value.slice(1); - - if (!key.includes(".")) { - return page.data[key]; - } - - const keys = key.split("."); - let val = page.data; - for (const key of keys) { - val = val[key]; - } - return val; - } else if (value === undefined || value === null) { - // Get the value from the default page data - if (options.defaultPageData && key in options.defaultPageData) { - const pageKey = options.defaultPageData[key]; - if (pageKey) { - return page.data[pageKey] as MetaData[T]; - } - } - } else { - return value; - } - }; - - const { document } = page; - const metaIcon = getMetaValue("icon"); - const metaImage = getMetaValue("image"); + const { document, data } = page; + const metaIcon = getDataValue(data, metas["icon"]); + const metaImage = getDataValue(data, metas["image"]); + const url = site.url(page.data.url as string, true); const icon = metaIcon ? new URL(site.url(metaIcon), url).href : undefined; const image = metaImage ? new URL(site.url(metaImage), url).href : undefined; - const type = getMetaValue("type"); - const site_name = getMetaValue("site"); - const lang = getMetaValue("lang"); - const title = getMetaValue("title"); - const description = getMetaValue("description"); - const twitter = getMetaValue("twitter"); - const keywords = getMetaValue("keywords"); - const robots = getMetaValue("robots"); - const color = getMetaValue("color"); - const generator = getMetaValue("generator"); + const type = getDataValue(data, metas["type"]); + const site_name = getDataValue(data, metas["site"]); + const lang = getDataValue(data, metas["lang"]); + const title = getDataValue(data, metas["title"]); + const description = getDataValue(data, metas["description"]); + const twitter = getDataValue(data, metas["twitter"]); + const keywords = getDataValue(data, metas["keywords"]); + const robots = getDataValue(data, metas["robots"]); + const color = getDataValue(data, metas["color"]); + const generator = getDataValue(data, metas["generator"]); // Open graph addMeta(document, "property", "og:type", type || "website"); diff --git a/plugins/rss.ts b/plugins/rss.ts deleted file mode 100644 index d3936346..00000000 --- a/plugins/rss.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { $XML, stringify } from "../deps/xml.ts"; -import { Page } from "../core/filesystem.ts"; -import { Search } from "../plugins/search.ts"; - -import type { Data, Site } from "../core.ts"; - -export interface Options { - filename: string; - query: string; - sort: string; - title: string; - buildDate: Date; - description: string; - language: string; - generator: string; - data: { - title: string; - description: string; - }; -} - -export const defaults: Options = { - filename: "/feed.rss", - query: "", - sort: "date=desc", - title: "My RSS Feed", - buildDate: new Date(), - description: "", - language: "en", - generator: "https://lume.land", - data: { - title: "title", - description: "description", - }, -}; - -export default (userOptions?: Partial) => { - const options = { - ...defaults, - ...userOptions, - }; - - return (site: Site) => { - site.addEventListener("afterRender", () => { - const feed = Page.create(options.filename, getFeedContent(site)); - site.pages.push(feed); - }); - - const getFeedContent = (site: Site) => { - const search = new Search(site, true); - const pages = search.pages(options.query, options.sort); - const items = pages.map((data: Data) => ({ - title: data[options.data.title], - link: site.url(String(data.url), true), - guid: { - "@isPermaLink": false, - "#text": site.url(String(data.url), true), - }, - description: data[options.data.description], - "content:encoded": data.children?.toString(), - pubDate: data.date?.toUTCString(), - })); - 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/", - "@version": "2.0", - channel: { - title: options.title, - link: site.url("", true), - "atom:link": { - "@href": site.url(options.filename, true), - "@rel": "self", - "@type": "application/rss+xml", - }, - description: options.description, - lastBuildDate: options.buildDate.toUTCString(), - language: options.language, - generator: options.generator, - item: items, - }, - }, - }; - return stringify(feed); - }; - }; -}; diff --git a/plugins/utils.ts b/plugins/utils.ts new file mode 100644 index 00000000..997abd0c --- /dev/null +++ b/plugins/utils.ts @@ -0,0 +1,29 @@ +import type { Data } from "../core.ts"; + +/** + * Common utils used by several plugins + */ + +/** + * Get the value of a page data + * For example, if the value is "=title", it will return the value of the page data "title" + */ +export function getDataValue(data: Data, value?: unknown) { + // Get the value from the page data + if (typeof value === "string" && value.startsWith("=")) { + const key = value.slice(1); + + if (!key.includes(".")) { + return data[key]; + } + + const keys = key.split("."); + let val = data; + for (const key of keys) { + val = val[key]; + } + return val; + } else { + return value; + } +} diff --git a/tests/__snapshots__/rss.test.ts.snap b/tests/__snapshots__/feed.test.ts.snap similarity index 81% rename from tests/__snapshots__/rss.test.ts.snap rename to tests/__snapshots__/feed.test.ts.snap index 93e7e6f4..2e020dee 100644 --- a/tests/__snapshots__/rss.test.ts.snap +++ b/tests/__snapshots__/feed.test.ts.snap @@ -1,6 +1,6 @@ export const snapshot = {}; -snapshot[`RSS plugin 1`] = `7`; +snapshot[`RSS plugin 1`] = `8`; snapshot[`RSS plugin 2`] = ` { @@ -76,6 +76,30 @@ snapshot[`RSS plugin 3`] = ` `; snapshot[`RSS plugin 4`] = ` +{ + content: '{"version":"https://jsonfeed.org/version/1","title":"My RSS Feed","home_page_url":"https://example.com/","feed_url":"https://example.com/feed.json","description":"","items":[{"id":"https://example.com/pages/subpage/page7/","url":"https://example.com/pages/subpage/page7/","content_html":"Content of Page 7","date_published":"Sun, 02 Jan 2022 00:00:00 GMT"},{"id":"https://example.com/pages/page6/","url":"https://example.com/pages/page6/","title":"Page 6","content_html":"

Content of Page 6

\\ +","date_published":"Sat, 01 Jan 2022 00:00:00 GMT"},{"id":"https://example.com/pages/page4/","url":"https://example.com/pages/page4/","title":"Page 4","content_html":"Content of Page 4 in Overrided site name, from the file /pages/2021-01-02-18-32_page4.tmpl.ts","date_published":"Sat, 02 Jan 2021 18:32:00 GMT"},{"id":"https://example.com/overrided-page2/","url":"https://example.com/overrided-page2/","title":"Page 2","content_html":"Content of Page 2","date_published":"Sun, 21 Jun 2020 00:00:00 GMT"}]}', + data: { + children: false, + content: '{"version":"https://jsonfeed.org/version/1","title":"My RSS Feed","home_page_url":"https://example.com/","feed_url":"https://example.com/feed.json","description":"","items":[{"id":"https://example.com/pages/subpage/page7/","url":"https://example.com/pages/subpage/page7/","content_html":"Content of Page 7","date_published":"Sun, 02 Jan 2022 00:00:00 GMT"},{"id":"https://example.com/pages/page6/","url":"https://example.com/pages/page6/","title":"Page 6","content_html":"

Content of Page 6

\\ +","date_published":"Sat, 01 Jan 2022 00:00:00 GMT"},{"id":"https://example.com/pages/page4/","url":"https://example.com/pages/page4/","title":"Page 4","content_html":"Content of Page 4 in Overrided site name, from the file /pages/2021-01-02-18-32_page4.tmpl.ts","date_published":"Sat, 02 Jan 2021 18:32:00 GMT"},{"id":"https://example.com/overrided-page2/","url":"https://example.com/overrided-page2/","title":"Page 2","content_html":"Content of Page 2","date_published":"Sun, 21 Jun 2020 00:00:00 GMT"}]}', + page: undefined, + url: "feed.json", + }, + dest: { + ext: ".json", + path: "feed", + }, + src: { + asset: true, + path: "", + remote: undefined, + slug: "feed", + }, +} +`; + +snapshot[`RSS plugin 5`] = ` { content: ' @@ -84,7 +108,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/ - Wed, 01 Jan 2020 00:00:00 GMT + 2020-01-01T00:00:00.000Z en https://lume.land @@ -93,7 +117,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/pages/subpage/page7/ - Sun, 02 Jan 2022 00:00:00 GMT + 2022-01-02T00:00:00.000Z Page 6 @@ -104,7 +128,7 @@ snapshot[`RSS plugin 4`] = ` Content of Page 6

]]> - Sat, 01 Jan 2022 00:00:00 GMT + 2022-01-01T00:00:00.000Z
Page 4 @@ -112,7 +136,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/pages/page4/ - Sat, 02 Jan 2021 18:32:00 GMT + 2021-01-02T18:32:00.000Z Page 2 @@ -120,7 +144,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/overrided-page2/ - Sun, 21 Jun 2020 00:00:00 GMT + 2020-06-21T00:00:00.000Z
', @@ -133,7 +157,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/ - Wed, 01 Jan 2020 00:00:00 GMT + 2020-01-01T00:00:00.000Z en https://lume.land @@ -142,7 +166,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/pages/subpage/page7/ - Sun, 02 Jan 2022 00:00:00 GMT + 2022-01-02T00:00:00.000Z Page 6 @@ -153,7 +177,7 @@ snapshot[`RSS plugin 4`] = ` Content of Page 6

]]> - Sat, 01 Jan 2022 00:00:00 GMT + 2022-01-01T00:00:00.000Z
Page 4 @@ -161,7 +185,7 @@ snapshot[`RSS plugin 4`] = ` https://example.com/pages/page4/ - Sat, 02 Jan 2021 18:32:00 GMT + 2021-01-02T18:32:00.000Z Page 2 @@ -169,16 +193,16 @@ snapshot[`RSS plugin 4`] = ` https://example.com/overrided-page2/ - Sun, 21 Jun 2020 00:00:00 GMT + 2020-06-21T00:00:00.000Z ', page: undefined, - url: "/feed.rss", + url: "feed.rss", }, dest: { ext: ".rss", - path: "/feed", + path: "feed", }, src: { asset: true, @@ -189,7 +213,7 @@ snapshot[`RSS plugin 4`] = ` } `; -snapshot[`RSS plugin 5`] = ` +snapshot[`RSS plugin 6`] = ` { content: " Content of Page 5", @@ -238,7 +262,7 @@ Content of Page 5", } `; -snapshot[`RSS plugin 6`] = ` +snapshot[`RSS plugin 7`] = ` { content: " Content of Page 2", @@ -318,7 +342,7 @@ Content of Page 2", } `; -snapshot[`RSS plugin 7`] = ` +snapshot[`RSS plugin 8`] = ` { content: " Content of Page 4 in Overrided site name, from the file /pages/2021-01-02-18-32_page4.tmpl.ts", @@ -398,7 +422,7 @@ Content of Page 4 in Overrided site name, from the file /pages/2021-01-02-18-32_ } `; -snapshot[`RSS plugin 8`] = ` +snapshot[`RSS plugin 9`] = ` { content: "

Content of Page 6

@@ -481,7 +505,7 @@ snapshot[`RSS plugin 8`] = ` } `; -snapshot[`RSS plugin 9`] = ` +snapshot[`RSS plugin 10`] = ` { content: "Content of Page 3", data: { @@ -560,7 +584,7 @@ snapshot[`RSS plugin 9`] = ` } `; -snapshot[`RSS plugin 10`] = ` +snapshot[`RSS plugin 11`] = ` { content: " Content of Page 7", diff --git a/tests/rss.test.ts b/tests/feed.test.ts similarity index 68% rename from tests/rss.test.ts rename to tests/feed.test.ts index 32489d06..c4a48687 100644 --- a/tests/rss.test.ts +++ b/tests/feed.test.ts @@ -1,5 +1,5 @@ import { assertSiteSnapshot, build, getSite } from "./utils.ts"; -import rss from "../plugins/rss.ts"; +import feed from "../plugins/feed.ts"; Deno.test("RSS plugin", async (t) => { const site = getSite({ @@ -11,8 +11,12 @@ Deno.test("RSS plugin", async (t) => { }); site.use( - rss({ - buildDate: new Date("2020-01-01"), + feed({ + output: ["feed.json", "feed.rss"], + info: { + date: new Date("2020-01-01"), + generator: "https://lume.land", + }, }), ); site.ignore("static.yml"); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 23ec655c..6e6d8536 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -4,7 +4,7 @@ import { pluginNames } from "../core/utils.ts"; const totalPlugins = Array.from(Deno.readDirSync("plugins")).length; Deno.test("Plugins list in init", () => { - equals(pluginNames.length, totalPlugins - 8); + equals(pluginNames.length, totalPlugins - 9); equals(pluginNames, [ "attributes", @@ -13,6 +13,7 @@ Deno.test("Plugins list in init", () => { "date", "esbuild", "eta", + "feed", "filter_pages", "imagick", "inline", @@ -37,7 +38,6 @@ Deno.test("Plugins list in init", () => { "relative_urls", "remark", "resolve_urls", - "rss", "sass", "sheets", "sitemap",