Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export async function generateMetadata({
de: 'Open Source, aber richtig - Open Elements ist ein modernes Unternehmen mit einem Fokus auf Open Source und Java',
};

const feedTitle =
locale === 'de' ? 'Open Elements – Artikel' : 'Open Elements – Articles';

return {
title: titles[locale as keyof typeof titles] || titles.en,
description:
Expand All @@ -47,6 +50,11 @@ export async function generateMetadata({
'open source Support',
'Java Support',
],
alternates: {
types: {
'application/rss+xml': [{ url: '/feed.xml', title: feedTitle }],
},
},
openGraph: {
type: 'website',
url:
Expand Down Expand Up @@ -92,6 +100,7 @@ export default async function LocaleLayout({
suppressHydrationWarning>
<head>
<link rel="icon" href="/icons/favicon.ico" />
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
</head>
<body
className={`${montserrat.className} bg-blue`}
Expand Down
12 changes: 12 additions & 0 deletions src/app/feed.xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { buildPostsRssFeed, rssResponseHeaders } from '@/lib/rss';

export const dynamic = 'force-static';

export function GET() {
const xml = buildPostsRssFeed('/feed.xml');
return new NextResponse(xml, {
status: 200,
headers: rssResponseHeaders(),
});
}
9 changes: 9 additions & 0 deletions src/app/posts/index.xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { permanentRedirect } from 'next/navigation';

export const dynamic = 'force-static';

// Permanent redirect alias to the canonical feed at /feed.xml.
// Keeps existing subscribers working while consolidating to a single source of truth.
export function GET() {
permanentRedirect('/feed.xml');
}
80 changes: 80 additions & 0 deletions src/lib/rss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getAllPosts } from './markdown';

const SITE_URL = 'https://open-elements.com';
const SITE_TITLE = 'Open Elements';
const SITE_DESCRIPTION = 'Open Source made right';

function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

function toRfc822(dateInput: string | Date): string {
const date = new Date(dateInput);
if (Number.isNaN(date.getTime())) {
return new Date(0).toUTCString();
}
return date.toUTCString();
}

/**
* Build an RSS 2.0 feed XML string for the posts of a given locale.
* The `feedPath` is the absolute path at which this feed is served (used for the
* atom self-link), e.g. "/feed.xml" or "/posts/index.xml".
*/
export function buildPostsRssFeed(feedPath: string, locale = 'en'): string {
const posts = getAllPosts(locale);
const localePath = locale === 'en' ? '' : `/${locale}`;
const channelLink = `${SITE_URL}${localePath}/posts`;
const feedUrl = `${SITE_URL}${feedPath}`;
const language = locale === 'de' ? 'de-de' : 'en-us';
const lastBuildDate = posts.length
? toRfc822(posts[0].frontmatter.date)
: new Date().toUTCString();

const items = posts
.map(post => {
const url = `${SITE_URL}${localePath}/posts/${post.slug}`;
const title = escapeXml(post.frontmatter.title ?? '');
const description = escapeXml(post.frontmatter.excerpt ?? '');
const pubDate = toRfc822(post.frontmatter.date);
const author = escapeXml(post.frontmatter.author ?? 'Open Elements');
return [
' <item>',
` <title>${title}</title>`,
` <link>${escapeXml(url)}</link>`,
` <pubDate>${pubDate}</pubDate>`,
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
` <description>${description}</description>`,
` <dc:creator>${author}</dc:creator>`,
' </item>',
].join('\n');
})
.join('\n');

return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
' <channel>',
` <title>${escapeXml(SITE_TITLE)}</title>`,
` <link>${escapeXml(channelLink)}</link>`,
` <description>${escapeXml(SITE_DESCRIPTION)}</description>`,
` <language>${language}</language>`,
` <lastBuildDate>${lastBuildDate}</lastBuildDate>`,
` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`,
items,
' </channel>',
'</rss>',
].join('\n');
}

export function rssResponseHeaders(): HeadersInit {
return {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
};
}
Loading