Skip to content

Commit 6104f15

Browse files
Add RSS/Atom feed functionality for blog (#246)
- Implement RSS 2.0 and Atom 1.0 feed templates for Hugo - Add Next.js API routes for migration compatibility - Configure feed output formats in config.toml - Support blog post metadata including preview images
1 parent 73578a8 commit 6104f15

6 files changed

Lines changed: 285 additions & 0 deletions

File tree

config.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ unsafe = true
2828
posts = '/posts/:year/:month/:day/:slug/'
2929
[permalinks.section]
3030
posts = '/posts'
31+
32+
[outputs]
33+
home = ["HTML", "RSS", "Atom"]
34+
page = ["HTML"]
35+
section = ["HTML", "RSS", "Atom"]
36+
37+
[mediaTypes."application/atom+xml"]
38+
suffixes = ["xml"]
39+
40+
[outputFormats.Atom]
41+
mediaType = "application/atom+xml"
42+
baseName = "atom"
43+
isPlainText = false
44+
45+
[services.rss]
46+
limit = 20

layouts/_default/atom.xml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{{- $pctx := . -}}
2+
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
3+
{{- $pages := slice -}}
4+
{{- if or .IsHome .IsSection -}}
5+
{{- $pages = $pctx.RegularPages -}}
6+
{{- else -}}
7+
{{- $pages = $pctx.Pages -}}
8+
{{- end -}}
9+
{{- $limit := .Site.Config.Services.RSS.Limit -}}
10+
{{- if ge $limit 1 -}}
11+
{{- $pages = $pages | first $limit -}}
12+
{{- end -}}
13+
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
14+
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="{{ .Site.Language.Lang }}">
15+
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}</title>
16+
<link href="{{ .Permalink }}" rel="self"/>
17+
<link href="{{ .Site.BaseURL }}"/>
18+
<updated>{{ .Date.Format "2006-01-02T15:04:05-07:00" | safeHTML }}</updated>
19+
<id>{{ .Permalink }}</id>
20+
{{- with .Site.Author.name }}
21+
<author>
22+
<name>{{.}}</name>
23+
{{- with $.Site.Author.email }}
24+
<email>{{.}}</email>
25+
{{- end }}
26+
</author>
27+
{{- end }}
28+
{{- with .Site.Copyright }}
29+
<rights>{{ . }}</rights>
30+
{{- end }}
31+
{{ range $pages }}
32+
{{- if and (ne .Layout "search") (ne .Layout "archives") }}
33+
<entry>
34+
<title>{{ .Title }}</title>
35+
<link href="{{ .Permalink }}"/>
36+
<id>{{ .Permalink }}</id>
37+
<updated>{{ .Lastmod.Format "2006-01-02T15:04:05-07:00" | safeHTML }}</updated>
38+
<published>{{ .Date.Format "2006-01-02T15:04:05-07:00" | safeHTML }}</published>
39+
{{- with .Site.Author.name }}
40+
<author>
41+
<name>{{.}}</name>
42+
</author>
43+
{{- end }}
44+
<summary type="html">{{ .Summary | html }}</summary>
45+
{{- if .Params.preview_image }}
46+
<content type="html">&lt;img src="{{ .Site.BaseURL }}{{ .Params.preview_image }}" alt="{{ .Title }}" /&gt;&lt;br/&gt;{{ .Content | html }}</content>
47+
{{- else }}
48+
<content type="html">{{ .Content | html }}</content>
49+
{{- end }}
50+
</entry>
51+
{{- end }}
52+
{{ end }}
53+
</feed>

layouts/_default/rss.xml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{{- $pctx := . -}}
2+
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
3+
{{- $pages := slice -}}
4+
{{- if or .IsHome .IsSection -}}
5+
{{- $pages = $pctx.RegularPages -}}
6+
{{- else -}}
7+
{{- $pages = $pctx.Pages -}}
8+
{{- end -}}
9+
{{- $limit := .Site.Config.Services.RSS.Limit -}}
10+
{{- if ge $limit 1 -}}
11+
{{- $pages = $pages | first $limit -}}
12+
{{- end -}}
13+
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
14+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
15+
<channel>
16+
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}</title>
17+
<link>{{ .Permalink }}</link>
18+
<description>Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }}</description>
19+
<generator>Hugo -- gohugo.io</generator>{{ with .Site.LanguageCode }}
20+
<language>{{.}}</language>{{end}}{{ with .Site.Author.email }}
21+
<managingEditor>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</managingEditor>{{end}}{{ with .Site.Author.email }}
22+
<webMaster>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</webMaster>{{end}}{{ with .Site.Copyright }}
23+
<copyright>{{.}}</copyright>{{end}}{{ if not .Date.IsZero }}
24+
<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
25+
{{- with .OutputFormats.Get "rss" -}}
26+
{{ printf "<atom:link href=%q rel=\"self\" type=%q>" .Permalink .MediaType | safeHTML }}
27+
{{- end -}}
28+
{{ range $pages }}
29+
{{- if and (ne .Layout "search") (ne .Layout "archives") }}
30+
<item>
31+
<title>{{ .Title }}</title>
32+
<link>{{ .Permalink }}</link>
33+
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
34+
{{ with .Site.Author.email }}<author>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</author>{{end}}
35+
<guid>{{ .Permalink }}</guid>
36+
<description>{{ .Summary | html }}</description>
37+
{{- if .Params.preview_image }}
38+
<enclosure url="{{ .Site.BaseURL }}{{ .Params.preview_image }}" type="image/svg+xml" />
39+
{{- end }}
40+
</item>
41+
{{- end }}
42+
{{ end }}
43+
</channel>
44+
</rss>

next-app/api/atom/route.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { NextResponse } from 'next/server';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import matter from 'gray-matter';
5+
6+
const postsDirectory = path.join(process.cwd(), 'content/posts');
7+
8+
function getAllPosts() {
9+
const slugs = fs.readdirSync(postsDirectory);
10+
const posts = slugs
11+
.map((slug) => {
12+
const fullPath = path.join(postsDirectory, slug);
13+
const fileContents = fs.readFileSync(fullPath, 'utf8');
14+
const { data, content } = matter(fileContents);
15+
16+
return {
17+
slug: slug.replace(/\.md$/, ''),
18+
frontmatter: data,
19+
content,
20+
fullPath,
21+
};
22+
})
23+
.filter((post) => post.frontmatter.showInBlog !== false)
24+
.sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date));
25+
26+
return posts;
27+
}
28+
29+
function generateAtomFeed(posts) {
30+
const siteUrl = 'https://open-elements.com';
31+
const feed = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
32+
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
33+
<title>Open Elements</title>
34+
<link href="${siteUrl}/api/atom" rel="self"/>
35+
<link href="${siteUrl}"/>
36+
<updated>${new Date().toISOString()}</updated>
37+
<id>${siteUrl}/</id>
38+
<author>
39+
<name>Open Elements</name>
40+
</author>
41+
${posts.map(post => `
42+
<entry>
43+
<title>${post.frontmatter.title}</title>
44+
<link href="${siteUrl}/posts/${post.frontmatter.date ? post.frontmatter.date.substring(0, 4) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(5, 7) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(8, 10) : ''}/${post.slug}/"/>
45+
<id>${siteUrl}/posts/${post.frontmatter.date ? post.frontmatter.date.substring(0, 4) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(5, 7) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(8, 10) : ''}/${post.slug}/</id>
46+
<updated>${new Date(post.frontmatter.date || post.frontmatter.lastmod).toISOString()}</updated>
47+
<published>${new Date(post.frontmatter.date).toISOString()}</published>
48+
<author>
49+
<name>${post.frontmatter.author || 'Open Elements'}</name>
50+
</author>
51+
<summary type="html"><![CDATA[${post.frontmatter.excerpt || ''}]]></summary>
52+
<content type="html"><![CDATA[${post.frontmatter.preview_image ? `<img src="${siteUrl}${post.frontmatter.preview_image}" alt="${post.frontmatter.title}" /><br/>` : ''}${post.content}]]></content>
53+
</entry>`).join('')}
54+
</feed>`;
55+
56+
return feed;
57+
}
58+
59+
export async function GET() {
60+
try {
61+
const posts = getAllPosts().slice(0, 20); // Limit to 20 most recent posts
62+
const atomFeed = generateAtomFeed(posts);
63+
64+
return new NextResponse(atomFeed, {
65+
status: 200,
66+
headers: {
67+
'Content-Type': 'application/atom+xml',
68+
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
69+
},
70+
});
71+
} catch (error) {
72+
console.error('Error generating Atom feed:', error);
73+
return new NextResponse('Error generating Atom feed', { status: 500 });
74+
}
75+
}

next-app/api/rss/route.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NextResponse } from 'next/server';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import matter from 'gray-matter';
5+
6+
const postsDirectory = path.join(process.cwd(), 'content/posts');
7+
8+
function getAllPosts() {
9+
const slugs = fs.readdirSync(postsDirectory);
10+
const posts = slugs
11+
.map((slug) => {
12+
const fullPath = path.join(postsDirectory, slug);
13+
const fileContents = fs.readFileSync(fullPath, 'utf8');
14+
const { data, content } = matter(fileContents);
15+
16+
return {
17+
slug: slug.replace(/\.md$/, ''),
18+
frontmatter: data,
19+
content,
20+
fullPath,
21+
};
22+
})
23+
.filter((post) => post.frontmatter.showInBlog !== false)
24+
.sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date));
25+
26+
return posts;
27+
}
28+
29+
function generateRSSFeed(posts) {
30+
const siteUrl = 'https://open-elements.com';
31+
const feed = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
32+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
33+
<channel>
34+
<title>Open Elements</title>
35+
<link>${siteUrl}</link>
36+
<description>Open Source made right - Open Elements is a modern company with a clear focus on Open Source and Java</description>
37+
<generator>Next.js</generator>
38+
<language>en-us</language>
39+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
40+
<atom:link href="${siteUrl}/api/rss" rel="self" type="application/rss+xml" />
41+
${posts.map(post => `
42+
<item>
43+
<title>${post.frontmatter.title}</title>
44+
<link>${siteUrl}/posts/${post.frontmatter.date ? post.frontmatter.date.substring(0, 4) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(5, 7) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(8, 10) : ''}/${post.slug}/</link>
45+
<pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate>
46+
<guid>${siteUrl}/posts/${post.frontmatter.date ? post.frontmatter.date.substring(0, 4) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(5, 7) : ''}/${post.frontmatter.date ? post.frontmatter.date.substring(8, 10) : ''}/${post.slug}/</guid>
47+
<description><![CDATA[${post.frontmatter.excerpt || ''}]]></description>
48+
${post.frontmatter.preview_image ? `<enclosure url="${siteUrl}${post.frontmatter.preview_image}" type="image/svg+xml" />` : ''}
49+
</item>`).join('')}
50+
</channel>
51+
</rss>`;
52+
53+
return feed;
54+
}
55+
56+
export async function GET() {
57+
try {
58+
const posts = getAllPosts().slice(0, 20); // Limit to 20 most recent posts
59+
const rssFeed = generateRSSFeed(posts);
60+
61+
return new NextResponse(rssFeed, {
62+
status: 200,
63+
headers: {
64+
'Content-Type': 'application/rss+xml',
65+
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
66+
},
67+
});
68+
} catch (error) {
69+
console.error('Error generating RSS feed:', error);
70+
return new NextResponse('Error generating RSS feed', { status: 500 });
71+
}
72+
}

next-app/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "open-elements-nextjs",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint"
10+
},
11+
"dependencies": {
12+
"next": "14.0.0",
13+
"react": "^18.2.0",
14+
"react-dom": "^18.2.0",
15+
"gray-matter": "^4.0.3"
16+
},
17+
"devDependencies": {
18+
"@types/node": "^20",
19+
"@types/react": "^18",
20+
"@types/react-dom": "^18",
21+
"eslint": "^8",
22+
"eslint-config-next": "14.0.0",
23+
"typescript": "^5"
24+
}
25+
}

0 commit comments

Comments
 (0)