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",