From df97a30d57c0ef57385ce4a37f0af59b0b3dc3fb Mon Sep 17 00:00:00 2001 From: Luca Matei Pintilie Date: Wed, 11 Feb 2026 22:39:13 +0100 Subject: [PATCH] feat(www): add rss and atom feeds --- apps/www/app/_utils/config/generate-feeds.ts | 134 +++++++++++++++++++ apps/www/app/_utils/config/get-blog-posts.ts | 81 +++++++++++ apps/www/app/_utils/config/rfc-3339.ts | 32 +++++ apps/www/app/_utils/config/rfc-822.ts | 31 +++++ apps/www/app/_utils/metadata.ts | 2 +- apps/www/app/i18next.server.ts | 6 +- apps/www/app/routes/blog/blog.tsx | 94 ++++--------- apps/www/react-router.config.ts | 3 + 8 files changed, 309 insertions(+), 74 deletions(-) create mode 100644 apps/www/app/_utils/config/generate-feeds.ts create mode 100644 apps/www/app/_utils/config/get-blog-posts.ts create mode 100644 apps/www/app/_utils/config/rfc-3339.ts create mode 100644 apps/www/app/_utils/config/rfc-822.ts diff --git a/apps/www/app/_utils/config/generate-feeds.ts b/apps/www/app/_utils/config/generate-feeds.ts new file mode 100644 index 0000000000..4b0b3406b1 --- /dev/null +++ b/apps/www/app/_utils/config/generate-feeds.ts @@ -0,0 +1,134 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cwd } from 'node:process'; +import { getBlogPosts } from './get-blog-posts'; +import { buildRFC822Date } from './rfc-822'; +import { rfc3339 } from './rfc-3339'; + +const baseUrl = 'https://designsystemet.no'; +const ttl = 60 * 24 * 7; // 1 week + +// Source - https://stackoverflow.com/a/27979933 +// Posted by hgoebl, modified by community. See post 'Timeline' for change history +// Retrieved 2026-02-11, License - CC BY-SA 4.0 +function escapeXml(unsafe: string): string { + return unsafe.replace(/[<>&'"]/g, (c) => { + switch (c) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case "'": + return '''; + case '"': + return '"'; + } + return c; + }); +} + +export async function generateFeeds(lang: string): Promise { + const dirname = cwd(); + const now = new Date(); + + try { + const rssSource = `${baseUrl}/${lang}/blog/feed.rss`; + const atomSource = `${baseUrl}/${lang}/blog/feed.atom`; + + const blogPosts = await getBlogPosts(lang); + const title = blogPosts.metadata[0].title; // + const description = blogPosts.metadata[1].content; // <description /> + const url = blogPosts.metadata[5].content; // <meta name="og:url" /> + const image = blogPosts.metadata[2].content; // <meta name="og:image" /> + + const rssOutput = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> +<channel> + <title>${escapeXml(title)} + ${escapeXml(description)} + ${escapeXml(url)} + ${lang} + + ${escapeXml(title)} + ${escapeXml(url)} + ${escapeXml(`${baseUrl}/${image}`)} + + ${ttl} + ${buildRFC822Date(now)} + ${buildRFC822Date(now)} + +${blogPosts.posts + .map( + ({ url, description, title, date, searchTerms }) => ` + ${escapeXml(title)} + ${escapeXml(`${baseUrl}/${lang}/blog/${url}`)} + ${escapeXml(`${baseUrl}/${lang}/blog/${url}`)} + ${buildRFC822Date(date)} + ${escapeXml(description)} + ${escapeXml(title)} +${searchTerms.map((term) => ` ${escapeXml(term.trim())}`).join('\n')} + `, + ) + .join('\n')} + +`; + + const atomOutput = ` + + ${escapeXml(title)} + ${escapeXml(description)} + + + ${escapeXml(image)} + ${escapeXml(atomSource)} + ${escapeXml(rfc3339(now))} + +${blogPosts.posts + .map( + ({ url, description, title, date, author, searchTerms }) => ` + ${escapeXml(title)} + + + ${escapeXml(`${baseUrl}/${lang}/blog/${url}`)} + ${escapeXml(rfc3339(new Date(date)))} + ${escapeXml(description)} + + ${escapeXml(author)} + +${searchTerms.map((term) => ` `).join('\n')} + `, + ) + .join('\n')} +`; + + const rssClientPath = join( + dirname, + 'dist', + 'client', + lang, + 'blog', + 'feed.rss', + ); + console.log( + `Writing feed.rss to ${rssClientPath} with ${blogPosts.posts.length} URLs`, + ); + writeFileSync(rssClientPath, rssOutput); + + const atomClientPath = join( + dirname, + 'dist', + 'client', + lang, + 'blog', + 'feed.atom', + ); + console.log( + `Writing feed.atom to ${atomClientPath} with ${blogPosts.posts.length} URLs`, + ); + writeFileSync(atomClientPath, atomOutput); + } catch (error) { + console.error(`Error generating feeds: ${error}`); + } +} diff --git a/apps/www/app/_utils/config/get-blog-posts.ts b/apps/www/app/_utils/config/get-blog-posts.ts new file mode 100644 index 0000000000..61ec539443 --- /dev/null +++ b/apps/www/app/_utils/config/get-blog-posts.ts @@ -0,0 +1,81 @@ +import { join } from 'node:path'; +import { bundleMDX } from 'mdx-bundler'; +import i18n from '../../i18next.server'; +import { getFileFromContentDir, getFilesFromContentDir } from '../files.server'; +import { generateMetadata } from '../metadata'; + +export interface BlogPosts { + lang: string; + posts: { + title: string; + author: string; + description: string; + url: string; + date: string; + image: { + src: string; + alt: string; + }; + searchTerms: string[]; + }[]; + metadata: ReturnType; +} + +export async function getBlogPosts(lang: string): Promise { + /* Get all files in /content/blog for the lang we have selected */ + const files = getFilesFromContentDir(join('blog', lang)); + + /* Filter out files that are not .mdx */ + const mdxFiles = files.filter((file) => file.relativePath.endsWith('.mdx')); + + /* Get titles and URLs for all files */ + const posts: BlogPosts['posts'] = []; + + /* Map over files with mdx parser to get title */ + for (const file of mdxFiles) { + const fileContent = getFileFromContentDir( + join('blog', lang, file.relativePath), + ); + const result = await bundleMDX({ + source: fileContent, + }); + + const title = + result.frontmatter.title || file.relativePath.replace('.mdx', ''); + const url = file.relativePath.replace('.mdx', ''); + const searchTerms: string[] = []; + if (typeof result.frontmatter.search_terms === 'string') + result.frontmatter.search_terms + .split(',') + .map((term) => searchTerms.push(term)); + + posts.push({ + title, + author: result.frontmatter.author || 'Unknown Author', + description: result.frontmatter.description || 'No description available', + url, + date: result.frontmatter.date || '2000-01-01', + image: { + src: result.frontmatter.imageSrc || '', + alt: result.frontmatter.imageAlt || '', + }, + searchTerms, + }); + } + + /* Sort posts by date */ + posts.sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + + const t = await i18n.getFixedT(lang); + + return { + lang, + posts, + metadata: generateMetadata({ + title: t('blog.title'), + description: t('blog.description'), + }), + }; +} diff --git a/apps/www/app/_utils/config/rfc-3339.ts b/apps/www/app/_utils/config/rfc-3339.ts new file mode 100644 index 0000000000..4a0b11cf21 --- /dev/null +++ b/apps/www/app/_utils/config/rfc-3339.ts @@ -0,0 +1,32 @@ +// Authored by @pjdietz on GitHub +// https://gist.github.com/pjdietz/e0545332e2fc67a9a460 + +export function rfc3339(d: Date): string { + function pad(n: number): string | number { + return n < 10 ? '0' + n : n; + } + + function timezoneOffset(offset: number): string { + if (offset === 0) { + return 'Z'; + } + const sign = offset > 0 ? '-' : '+'; + offset = Math.abs(offset); + return sign + pad(Math.floor(offset / 60)) + ':' + pad(offset % 60); + } + + return ( + d.getFullYear() + + '-' + + pad(d.getMonth() + 1) + + '-' + + pad(d.getDate()) + + 'T' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + + ':' + + pad(d.getSeconds()) + + timezoneOffset(d.getTimezoneOffset()) + ); +} diff --git a/apps/www/app/_utils/config/rfc-822.ts b/apps/www/app/_utils/config/rfc-822.ts new file mode 100644 index 0000000000..ce8ef5248f --- /dev/null +++ b/apps/www/app/_utils/config/rfc-822.ts @@ -0,0 +1,31 @@ +/** + * MIT License Copyright (c) 2022 Salma Alam-Naylor + * https://github.com/whitep4nth3r/rfc-822/blob/781aee2019a6a05d2fe91631bce00b41fc17a80e/index.js + */ + +const weekdayFormat = new Intl.DateTimeFormat('en-US', { weekday: 'short' }) + .format; +const monthFormat = new Intl.DateTimeFormat('en-US', { month: 'short' }).format; + +// add a leading 0 to a number if it is only one digit +function addLeadingZero(num: string | number): string { + num = num.toString(); + while (num.length < 2) num = '0' + num; + return num; +} + +export function buildRFC822Date(dateString: string | Date) { + const date = + dateString instanceof Date ? dateString : new Date(Date.parse(dateString)); + // Convert to GMT + date.setTime(date.getTime() + date.getTimezoneOffset() * 60_000); + + const day = weekdayFormat(date); + const dayNumber = addLeadingZero(date.getDate()); + const month = monthFormat(date); + const year = date.getFullYear(); + const time = `${addLeadingZero(date.getHours())}:${addLeadingZero(date.getMinutes())}:00`; + + //Wed, 02 Oct 2002 13:00:00 GMT + return `${day}, ${dayNumber} ${month} ${year} ${time} GMT`; +} diff --git a/apps/www/app/_utils/metadata.ts b/apps/www/app/_utils/metadata.ts index 68a50f5c30..24b5f59f1a 100644 --- a/apps/www/app/_utils/metadata.ts +++ b/apps/www/app/_utils/metadata.ts @@ -47,5 +47,5 @@ export const generateMetadata = ({ property: 'twitter:image', content: image, }, - ]; + ] as const; }; diff --git a/apps/www/app/i18next.server.ts b/apps/www/app/i18next.server.ts index 11f80e3d62..cc31031e15 100644 --- a/apps/www/app/i18next.server.ts +++ b/apps/www/app/i18next.server.ts @@ -1,7 +1,7 @@ import { RemixI18Next } from 'remix-i18next/server'; -import i18n from '~/i18n'; -import en from '~/locales/en'; -import no from '~/locales/no'; +import i18n from './i18n'; +import en from './locales/en'; +import no from './locales/no'; const i18next = new RemixI18Next({ detection: { diff --git a/apps/www/app/routes/blog/blog.tsx b/apps/www/app/routes/blog/blog.tsx index 6eaf8f764f..08e301b429 100644 --- a/apps/www/app/routes/blog/blog.tsx +++ b/apps/www/app/routes/blog/blog.tsx @@ -1,13 +1,6 @@ -import { join } from 'node:path'; -import { bundleMDX } from 'mdx-bundler'; import { BlogCard } from '~/_components/blog-card/blog-card'; -import { - getFileFromContentDir, - getFilesFromContentDir, -} from '~/_utils/files.server'; -import { generateMetadata } from '~/_utils/metadata'; +import { getBlogPosts } from '~/_utils/config/get-blog-posts'; import i18nConf from '~/i18n'; -import i18n from '~/i18next.server'; import type { Route } from './+types/blog'; export const loader = async ({ params: { lang } }: Route.LoaderArgs) => { @@ -25,72 +18,33 @@ export const loader = async ({ params: { lang } }: Route.LoaderArgs) => { }); } - /* Get all files in /content/blog for the lang we have selected */ - const files = getFilesFromContentDir(join('blog', lang)); - - /* Filter out files that are not .mdx */ - const mdxFiles = files.filter((file) => file.relativePath.endsWith('.mdx')); - - /* Get titles and URLs for all files */ - const posts: { - title: string; - author: string; - description: string; - url: string; - date: string; - image: { - src: string; - alt: string; - }; - }[] = []; - - /* Map over files with mdx parser to get title */ - for (const file of mdxFiles) { - const fileContent = getFileFromContentDir( - join('blog', lang, file.relativePath), - ); - const result = await bundleMDX({ - source: fileContent, - }); - - const title = - result.frontmatter.title || file.relativePath.replace('.mdx', ''); - const url = file.relativePath.replace('.mdx', ''); - posts.push({ - title, - author: result.frontmatter.author || 'Unknown Author', - description: result.frontmatter.description || 'No description available', - url, - date: result.frontmatter.date || '2000-01-01', - image: { - src: result.frontmatter.imageSrc || '', - alt: result.frontmatter.imageAlt || '', - }, - }); - } - - /* Sort posts by date */ - posts.sort((a, b) => { - return new Date(b.date).getTime() - new Date(a.date).getTime(); - }); - - const t = await i18n.getFixedT(lang); - - return { - lang, - posts, - metadata: generateMetadata({ - title: t('blog.title'), - description: t('blog.description'), - }), - }; + return getBlogPosts(lang); }; -export const meta = ({ data }: Route.MetaArgs) => { - if (!data) return [{ title: 'Designsystemet' }]; - return data.metadata; +export const meta = ({ loaderData }: Route.MetaArgs) => { + if (!loaderData) return [{ title: 'Designsystemet' }]; + return loaderData.metadata; }; +export const links: Route.LinksFunction = () => [ + ...i18nConf.supportedLngs.flatMap((lang) => [ + { + rel: 'alternate', + type: 'application/rss+xml', + title: `RSS - ${lang}`, + href: `/${lang}/blog/feed.rss`, + hrefLang: lang, + }, + { + rel: 'alternate', + type: 'application/atom+xml', + title: `Atom - ${lang}`, + href: `/${lang}/blog/feed.atom`, + hrefLang: lang, + }, + ]), +]; + export default function Blog({ loaderData: { posts } }: Route.ComponentProps) { return ( <> diff --git a/apps/www/react-router.config.ts b/apps/www/react-router.config.ts index 5a3609adc2..be38ef17d7 100644 --- a/apps/www/react-router.config.ts +++ b/apps/www/react-router.config.ts @@ -1,8 +1,10 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Config } from '@react-router/dev/config'; +import { generateFeeds } from './app/_utils/config/generate-feeds'; import { generatePrerenderPaths } from './app/_utils/config/generate-prerender-paths'; import { generateSitemap } from './app/_utils/config/generate-sitemap'; +import i18n from './app/i18n'; const config: Config = { ssr: true, @@ -36,6 +38,7 @@ const config: Config = { throw new Error(`Failed to write robots.txt file: ${error}`); } await generateSitemap(allPages); + await Promise.all(i18n.supportedLngs.map((lang) => generateFeeds(lang))); }, };