Skip to content

Commit 1de6b54

Browse files
authored
Merge pull request #241 from wpengine/rss
feat: add rss and other feeds for subscribing to the Faust Blog
2 parents 3e5a8e5 + 0ba4da0 commit 1de6b54

File tree

6 files changed

+284
-0
lines changed

6 files changed

+284
-0
lines changed

.env.local.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Your WordPress site URL
2+
NEXT_PUBLIC_WORDPRESS_URL=https://faustjstoolkit.wpenginepowered.com/
3+
4+
# Your Next site URL
5+
NEXT_PUBLIC_SITE_URL=http://localhost:3000/
6+
7+
# Plugin secret found in WordPress Settings->Faust
8+
FAUST_SECRET_KEY=secret-key
9+
10+
# Atlas Search endpoint and access token - found in Atlas Search settings
11+
NEXT_PUBLIC_SEARCH_ENDPOINT=search-endpoint
12+
NEXT_SEARCH_ACCESS_TOKEN=search-access-token
13+
14+
# Google Analytics key
15+
NEXT_PUBLIC_GOOGLE_ANALYTICS_KEY=ga-key

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@headlessui/react": "^2.2.0",
2323
"@heroicons/react": "^2.2.0",
2424
"@icons-pack/react-simple-icons": "^11.1.0",
25+
"@js-temporal/polyfill": "^0.4.4",
2526
"@mdx-js/loader": "^3.0.1",
2627
"@mdx-js/react": "^3.0.1",
2728
"@next/mdx": "^15.1.6",
@@ -30,6 +31,7 @@
3031
"classnames": "^2.5.1",
3132
"date-fns": "^4.1.0",
3233
"downshift": "^9.0.8",
34+
"feed": "^4.2.2",
3335
"graphql": "^16.10.0",
3436
"html-to-text": "^9.0.5",
3537
"http-status-codes": "^2.3.0",

pnpm-lock.yaml

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/feed.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { env } from "node:process";
2+
import { URL } from "node:url";
3+
import { gql } from "@apollo/client";
4+
import { Temporal } from "@js-temporal/polyfill";
5+
import { Feed } from "feed";
6+
7+
const SITE_URL = env.NEXT_PUBLIC_SITE_URL;
8+
9+
export const FEED_QUERY = gql`
10+
query BlogFeedQuery {
11+
generalSettings {
12+
title
13+
description
14+
timezone
15+
}
16+
posts(where: { orderby: { field: DATE, order: DESC } }, first: 10) {
17+
nodes {
18+
title
19+
uri
20+
excerpt
21+
content
22+
dateGmt
23+
modifiedGmt
24+
author {
25+
node {
26+
name
27+
url
28+
}
29+
}
30+
categories {
31+
nodes {
32+
name
33+
slug
34+
uri
35+
}
36+
}
37+
}
38+
}
39+
last_modified: posts(
40+
where: { orderby: { field: MODIFIED, order: DESC } }
41+
first: 1
42+
) {
43+
nodes {
44+
modifiedGmt
45+
}
46+
}
47+
}
48+
`;
49+
50+
export function createFeed({ feed_data, last_modified }) {
51+
const feed = new Feed({
52+
title: `${feed_data.generalSettings.title} Blog`,
53+
description: feed_data.generalSettings.description,
54+
id: new URL("/blog/", SITE_URL).href,
55+
link: new URL("/blog/", SITE_URL).href,
56+
language: "en",
57+
image: new URL("/favicon-192x192.png", SITE_URL).href,
58+
favicon: new URL("/favicon-32x32.png", SITE_URL).href,
59+
copyright: Temporal.Now.plainDateISO(
60+
feed_data.generalSettings.timezone,
61+
).year.toString(),
62+
updated: new Date(last_modified.toString()),
63+
feedLinks: {
64+
json: new URL("/api/feeds/feed.json", SITE_URL).href,
65+
atom: new URL("/api/feeds/feed.atom", SITE_URL).href,
66+
rss: new URL("/api/feeds/rss.xml", SITE_URL).href,
67+
},
68+
});
69+
70+
for (const post of feed_data?.posts?.nodes || []) {
71+
const author = post.author.node;
72+
const categories = post.categories.nodes;
73+
74+
feed.addItem({
75+
id: post.id,
76+
title: post.title,
77+
link: new URL(post.uri, SITE_URL).href,
78+
description: post.excerpt,
79+
content: post.content,
80+
date: new Date(post.modifiedGmt),
81+
published: new Date(post.dateGmt),
82+
author: [
83+
{
84+
name: author.name,
85+
link: author.url,
86+
},
87+
],
88+
category: categories.map((category) => {
89+
const link = new URL(category.uri, SITE_URL).href;
90+
return {
91+
term: category.slug,
92+
scheme: link,
93+
domain: link,
94+
name: category.name,
95+
};
96+
}),
97+
});
98+
}
99+
100+
return feed;
101+
}

src/pages/_document.jsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { env } from "node:process";
2+
import { URL } from "node:url";
13
import { Html, Head, Main, NextScript } from "next/document";
24
import { GA_TRACKING_ID } from "@/lib/gtag";
35

6+
const SITE_URL = env.NEXT_PUBLIC_SITE_URL;
7+
48
export default function Document() {
59
return (
610
<Html lang="en">
@@ -25,6 +29,24 @@ export default function Document() {
2529
/>
2630
<link href="/images/favicon-32x32.png" rel="icon" sizes="32x32" />
2731
<link href="/images/favicon-192x192.png" rel="icon" sizes="192x192" />
32+
<link
33+
href={new URL("/api/feeds/feed.json", SITE_URL).href}
34+
rel="alternate"
35+
type="application/feed+json"
36+
title="WPGraphQL Blog JSON Feed"
37+
/>
38+
<link
39+
href={new URL("/api/feeds/rss.xml", SITE_URL).href}
40+
rel="alternate"
41+
type="application/rss+xml"
42+
title="WPGraphQL Blog XML Feed"
43+
/>
44+
<link
45+
href={new URL("/api/feeds/feed.atom", SITE_URL).href}
46+
rel="alternate"
47+
type="application/atom+xml"
48+
title="WPGraphQL Blog Atom Feed"
49+
/>
2850
</Head>
2951
<body className="bg-gray-900 text-gray-200">
3052
<Main />

src/pages/api/feeds/[feed-type].js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createHash } from "node:crypto";
2+
import { getApolloClient } from "@faustwp/core/dist/mjs/client";
3+
import { Temporal } from "@js-temporal/polyfill";
4+
import { StatusCodes, getReasonPhrase } from "http-status-codes";
5+
import { FEED_QUERY, createFeed } from "@/lib/feed";
6+
7+
const client = getApolloClient();
8+
9+
export default async function HandleFeeds(req, res) {
10+
try {
11+
// Get needed Request info
12+
const if_modified_since = req.headers["if-modified-since"];
13+
const if_none_match = req.headers["if-none-match"];
14+
const { "feed-type": feedType } = req.query;
15+
16+
// Fetch content from WP
17+
const { data: feed_data } = await client.query({
18+
query: FEED_QUERY,
19+
});
20+
21+
const last_modified = Temporal.PlainDateTime.from(
22+
feed_data.last_modified.nodes[0].modifiedGmt,
23+
{
24+
overflow: "constrain",
25+
},
26+
);
27+
28+
// Create feed
29+
const feed = createFeed({ feed_data, last_modified });
30+
31+
// Generate Response Body and Content-Type
32+
let resp;
33+
34+
switch (feedType) {
35+
case "feed.json": {
36+
resp = {
37+
content_type: "application/feed+json",
38+
body: feed.json1(),
39+
};
40+
break;
41+
}
42+
43+
case "feed.atom": {
44+
resp = {
45+
content_type: "application/atom+xml",
46+
body: feed.atom1(),
47+
};
48+
break;
49+
}
50+
51+
case "rss.xml": {
52+
resp = {
53+
content_type: "application/rss+xml",
54+
body: feed.rss2(),
55+
};
56+
break;
57+
}
58+
59+
default: {
60+
const error = new Error(getReasonPhrase(StatusCodes.NOT_FOUND));
61+
error.status = StatusCodes.NOT_FOUND;
62+
throw error;
63+
}
64+
}
65+
66+
// Spec specifies hash being in quotes
67+
const etag_for_body = `"${createHash("md5")
68+
.update(resp.body)
69+
.digest("hex")}"`;
70+
71+
res.setHeader("Vary", "if-modified-since, if-none-match");
72+
res.setHeader(
73+
"Cache-Control",
74+
"max-age=0, must-revalidate, stale-if-error=86400",
75+
);
76+
res.setHeader("Content-Type", resp.content_type);
77+
res.setHeader("ETag", etag_for_body);
78+
res.setHeader("Last-Modified", last_modified.toString());
79+
80+
if (
81+
// Checks if the `if_none_match` header matches current response' etag
82+
if_none_match === etag_for_body ||
83+
// Checks `if_modified_since` is after `last_modified`
84+
(if_modified_since &&
85+
Temporal.PlainDateTime.compare(last_modified, if_modified_since) < 0)
86+
) {
87+
res.status(StatusCodes.NOT_MODIFIED);
88+
res.end();
89+
} else {
90+
res.status(StatusCodes.OK);
91+
res.end(resp.body);
92+
}
93+
} catch (error) {
94+
if (error.message && error.status) {
95+
res.status(error.status);
96+
res.send(error.message);
97+
} else {
98+
console.error(error);
99+
res.status(StatusCodes.INTERNAL_SERVER_ERROR);
100+
res.send(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR));
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)