From ed94a9c0610383d2b5f969c4b806040f764b44f5 Mon Sep 17 00:00:00 2001 From: Demagalawrence Date: Fri, 27 Feb 2026 07:51:07 +0200 Subject: [PATCH] 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 Signed-off-by: Demagalawrence --- config.toml | 16 ++++++++ layouts/_default/atom.xml | 53 +++++++++++++++++++++++++++ layouts/_default/rss.xml | 44 ++++++++++++++++++++++ next-app/api/atom/route.js | 75 ++++++++++++++++++++++++++++++++++++++ next-app/api/rss/route.js | 72 ++++++++++++++++++++++++++++++++++++ next-app/package.json | 25 +++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 layouts/_default/atom.xml create mode 100644 layouts/_default/rss.xml create mode 100644 next-app/api/atom/route.js create mode 100644 next-app/api/rss/route.js create mode 100644 next-app/package.json diff --git a/config.toml b/config.toml index e36c23b2..26b722ac 100644 --- a/config.toml +++ b/config.toml @@ -28,3 +28,19 @@ unsafe = true posts = '/posts/:year/:month/:day/:slug/' [permalinks.section] posts = '/posts' + +[outputs] +home = ["HTML", "RSS", "Atom"] +page = ["HTML"] +section = ["HTML", "RSS", "Atom"] + +[mediaTypes."application/atom+xml"] +suffixes = ["xml"] + +[outputFormats.Atom] +mediaType = "application/atom+xml" +baseName = "atom" +isPlainText = false + +[services.rss] +limit = 20 diff --git a/layouts/_default/atom.xml b/layouts/_default/atom.xml new file mode 100644 index 00000000..a852debf --- /dev/null +++ b/layouts/_default/atom.xml @@ -0,0 +1,53 @@ +{{- $pctx := . -}} +{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}} +{{- $pages := slice -}} +{{- if or .IsHome .IsSection -}} +{{- $pages = $pctx.RegularPages -}} +{{- else -}} +{{- $pages = $pctx.Pages -}} +{{- end -}} +{{- $limit := .Site.Config.Services.RSS.Limit -}} +{{- if ge $limit 1 -}} +{{- $pages = $pages | first $limit -}} +{{- end -}} +{{- printf "" | safeHTML }} + + {{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }} + + + {{ .Date.Format "2006-01-02T15:04:05-07:00" | safeHTML }} + {{ .Permalink }} + {{- with .Site.Author.name }} + + {{.}} + {{- with $.Site.Author.email }} + {{.}} + {{- end }} + + {{- end }} + {{- with .Site.Copyright }} + {{ . }} + {{- end }} + {{ range $pages }} + {{- if and (ne .Layout "search") (ne .Layout "archives") }} + + {{ .Title }} + + {{ .Permalink }} + {{ .Lastmod.Format "2006-01-02T15:04:05-07:00" | safeHTML }} + {{ .Date.Format "2006-01-02T15:04:05-07:00" | safeHTML }} + {{- with .Site.Author.name }} + + {{.}} + + {{- end }} + {{ .Summary | html }} + {{- if .Params.preview_image }} + <img src="{{ .Site.BaseURL }}{{ .Params.preview_image }}" alt="{{ .Title }}" /><br/>{{ .Content | html }} + {{- else }} + {{ .Content | html }} + {{- end }} + + {{- end }} + {{ end }} + diff --git a/layouts/_default/rss.xml b/layouts/_default/rss.xml new file mode 100644 index 00000000..ecf289eb --- /dev/null +++ b/layouts/_default/rss.xml @@ -0,0 +1,44 @@ +{{- $pctx := . -}} +{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}} +{{- $pages := slice -}} +{{- if or .IsHome .IsSection -}} +{{- $pages = $pctx.RegularPages -}} +{{- else -}} +{{- $pages = $pctx.Pages -}} +{{- end -}} +{{- $limit := .Site.Config.Services.RSS.Limit -}} +{{- if ge $limit 1 -}} +{{- $pages = $pages | first $limit -}} +{{- end -}} +{{- printf "" | safeHTML }} + + + {{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }} + {{ .Permalink }} + Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }} + Hugo -- gohugo.io{{ with .Site.LanguageCode }} + {{.}}{{end}}{{ with .Site.Author.email }} + {{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}}{{ with .Site.Author.email }} + {{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}}{{ with .Site.Copyright }} + {{.}}{{end}}{{ if not .Date.IsZero }} + {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}{{ end }} + {{- with .OutputFormats.Get "rss" -}} + {{ printf "" .Permalink .MediaType | safeHTML }} + {{- end -}} + {{ range $pages }} + {{- if and (ne .Layout "search") (ne .Layout "archives") }} + + {{ .Title }} + {{ .Permalink }} + {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{ with .Site.Author.email }}{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}{{end}} + {{ .Permalink }} + {{ .Summary | html }} + {{- if .Params.preview_image }} + + {{- end }} + + {{- end }} + {{ end }} + + diff --git a/next-app/api/atom/route.js b/next-app/api/atom/route.js new file mode 100644 index 00000000..bcfcd6d7 --- /dev/null +++ b/next-app/api/atom/route.js @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; + +const postsDirectory = path.join(process.cwd(), 'content/posts'); + +function getAllPosts() { + const slugs = fs.readdirSync(postsDirectory); + const posts = slugs + .map((slug) => { + const fullPath = path.join(postsDirectory, slug); + const fileContents = fs.readFileSync(fullPath, 'utf8'); + const { data, content } = matter(fileContents); + + return { + slug: slug.replace(/\.md$/, ''), + frontmatter: data, + content, + fullPath, + }; + }) + .filter((post) => post.frontmatter.showInBlog !== false) + .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)); + + return posts; +} + +function generateAtomFeed(posts) { + const siteUrl = 'https://open-elements.com'; + const feed = ` + + Open Elements + + + ${new Date().toISOString()} + ${siteUrl}/ + + Open Elements + + ${posts.map(post => ` + + ${post.frontmatter.title} + + ${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}/ + ${new Date(post.frontmatter.date || post.frontmatter.lastmod).toISOString()} + ${new Date(post.frontmatter.date).toISOString()} + + ${post.frontmatter.author || 'Open Elements'} + + +
` : ''}${post.content}]]>
+
`).join('')} +
`; + + return feed; +} + +export async function GET() { + try { + const posts = getAllPosts().slice(0, 20); // Limit to 20 most recent posts + const atomFeed = generateAtomFeed(posts); + + return new NextResponse(atomFeed, { + status: 200, + headers: { + 'Content-Type': 'application/atom+xml', + 'Cache-Control': 's-maxage=3600, stale-while-revalidate', + }, + }); + } catch (error) { + console.error('Error generating Atom feed:', error); + return new NextResponse('Error generating Atom feed', { status: 500 }); + } +} diff --git a/next-app/api/rss/route.js b/next-app/api/rss/route.js new file mode 100644 index 00000000..b442af5b --- /dev/null +++ b/next-app/api/rss/route.js @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; + +const postsDirectory = path.join(process.cwd(), 'content/posts'); + +function getAllPosts() { + const slugs = fs.readdirSync(postsDirectory); + const posts = slugs + .map((slug) => { + const fullPath = path.join(postsDirectory, slug); + const fileContents = fs.readFileSync(fullPath, 'utf8'); + const { data, content } = matter(fileContents); + + return { + slug: slug.replace(/\.md$/, ''), + frontmatter: data, + content, + fullPath, + }; + }) + .filter((post) => post.frontmatter.showInBlog !== false) + .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)); + + return posts; +} + +function generateRSSFeed(posts) { + const siteUrl = 'https://open-elements.com'; + const feed = ` + + + Open Elements + ${siteUrl} + Open Source made right - Open Elements is a modern company with a clear focus on Open Source and Java + Next.js + en-us + ${new Date().toUTCString()} + + ${posts.map(post => ` + + ${post.frontmatter.title} + ${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}/ + ${new Date(post.frontmatter.date).toUTCString()} + ${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}/ + + ${post.frontmatter.preview_image ? `` : ''} + `).join('')} + +`; + + return feed; +} + +export async function GET() { + try { + const posts = getAllPosts().slice(0, 20); // Limit to 20 most recent posts + const rssFeed = generateRSSFeed(posts); + + return new NextResponse(rssFeed, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml', + 'Cache-Control': 's-maxage=3600, stale-while-revalidate', + }, + }); + } catch (error) { + console.error('Error generating RSS feed:', error); + return new NextResponse('Error generating RSS feed', { status: 500 }); + } +} diff --git a/next-app/package.json b/next-app/package.json new file mode 100644 index 00000000..4745cf4d --- /dev/null +++ b/next-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "open-elements-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "gray-matter": "^4.0.3" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.0.0", + "typescript": "^5" + } +}