feat: add RSS feed generation and integrate RSS link in blog pages

This commit is contained in:
2026-05-10 23:37:20 +10:00
parent 7007660a80
commit 6e1728d35e
4 changed files with 104 additions and 4 deletions

View File

@@ -96,6 +96,7 @@ async function processBlogPosts(buildDir) {
await staticBlogGenerator.generate(posts); await staticBlogGenerator.generate(posts);
console.log('✓ Generated static blog pages'); console.log('✓ Generated static blog pages');
await generateRssFeed(posts, buildDir);
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (err.code === 'ENOENT') {
console.log('⚠ No blog directory found, skipping blog processing'); console.log('⚠ No blog directory found, skipping blog processing');
@@ -362,6 +363,80 @@ async function updateHtmlForBundle() {
} }
} }
/**
* Generates an RSS 2.0 feed from blog posts and writes it to build/rss.xml.
*
* @param {Array<{slug: string, title: string, date: string|null, author: string|null, content: string}>} posts
* @param {string} buildDir
* @returns {Promise<void>}
*/
async function generateRssFeed(posts, buildDir) {
const siteUrl = 'https://lit.ruv.wtf';
const feedUrl = `${siteUrl}/rss.xml`;
/**
* @param {string} str
* @returns {string}
*/
function xmlEscape(str) {
return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
/**
* Strips markdown syntax to produce plain-text for descriptions.
*
* @param {string} md
* @returns {string}
*/
function mdToPlain(md) {
return (md || '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\(.*?\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
.replace(/^[-*_]{3,}$/gm, '')
.replace(/\n{2,}/g, ' ')
.replace(/\n/g, ' ')
.trim()
.slice(0, 400);
}
const items = posts.map(post => {
const url = `${siteUrl}/blog/${encodeURIComponent(post.slug)}/`;
const pubDate = post.date ? new Date(post.date).toUTCString() : '';
const description = xmlEscape(mdToPlain(post.content));
return [
' <item>',
` <title>${xmlEscape(post.title || post.slug)}</title>`,
` <link>${url}</link>`,
` <guid isPermaLink="true">${url}</guid>`,
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
post.author ? ` <author>${xmlEscape(post.author)}</author>` : '',
` <description>${description}</description>`,
' </item>',
].filter(Boolean).join('\n');
});
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
' <title>lit.ruv.wtf</title>',
` <link>${siteUrl}</link>`,
' <description>Blog posts from lit.ruv.wtf</description>',
' <language>en-us</language>',
` <atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
...items,
' </channel>',
'</rss>',
].join('\n');
await fs.writeFile(path.join(buildDir, 'rss.xml'), xml, 'utf-8');
console.log('✓ Generated RSS feed');
}
// Run the build // Run the build
async function build() { async function build() {
const buildDir = path.join(__dirname, 'build'); const buildDir = path.join(__dirname, 'build');

View File

@@ -229,6 +229,7 @@ class StaticBlogGenerator {
` <meta name="description" content="${description}">`, ` <meta name="description" content="${description}">`,
` <link rel="canonical" href="${canonicalUrl}">`, ` <link rel="canonical" href="${canonicalUrl}">`,
...ogMeta, ...ogMeta,
` <link rel="alternate" type="application/rss+xml" title="lit.ruv.wtf RSS" href="/rss.xml">`,
' <link rel="icon" type="image/png" sizes="32x32" href="/logos/32px.png">', ' <link rel="icon" type="image/png" sizes="32x32" href="/logos/32px.png">',
' <link rel="icon" type="image/png" sizes="64x64" href="/logos/64px.png">', ' <link rel="icon" type="image/png" sizes="64x64" href="/logos/64px.png">',
' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">', ' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">',
@@ -245,6 +246,10 @@ class StaticBlogGenerator {
'<body class="blog-page">', '<body class="blog-page">',
'<nav class="quick-links" aria-label="Primary">', '<nav class="quick-links" aria-label="Primary">',
this.renderHeaderLinks(), this.renderHeaderLinks(),
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
' RSS',
' </a>',
'</nav>', '</nav>',
this.renderScrollTopBar(), this.renderScrollTopBar(),
bodyHtml, bodyHtml,

View File

@@ -55,9 +55,12 @@ export class BlogPostNode extends NodeBase {
metaDiv.style.cssText = "margin-bottom: 1em; padding-bottom: 0.5em; border-bottom: 1px solid rgba(255,255,255,0.1);"; metaDiv.style.cssText = "margin-bottom: 1em; padding-bottom: 0.5em; border-bottom: 1px solid rgba(255,255,255,0.1);";
if (post.title) { if (post.title) {
const titleEl = document.createElement("h2"); const titleEl = document.createElement("a");
titleEl.href = `/blog/${encodeURIComponent(post.slug)}/`;
titleEl.textContent = post.title; titleEl.textContent = post.title;
titleEl.style.cssText = "margin: 0 0 0.5em 0; font-size: 1.5em;"; titleEl.style.cssText = "display:block; margin: 0 0 0.5em 0; font-size: 1.5em; color: inherit; text-decoration: none;";
titleEl.addEventListener('mouseenter', () => titleEl.style.textDecoration = 'underline');
titleEl.addEventListener('mouseleave', () => titleEl.style.textDecoration = 'none');
metaDiv.appendChild(titleEl); metaDiv.appendChild(titleEl);
} }
@@ -71,9 +74,12 @@ export class BlogPostNode extends NodeBase {
} }
if (metaInfo.length > 0) { if (metaInfo.length > 0) {
const infoEl = document.createElement("div"); const infoEl = document.createElement("a");
infoEl.style.cssText = "color: rgba(255,255,255,0.6); font-size: 0.9em;"; infoEl.href = `/blog/${encodeURIComponent(post.slug)}/`;
infoEl.style.cssText = "display:block; color: rgba(255,255,255,0.6); font-size: 0.9em; text-decoration: none;";
infoEl.textContent = metaInfo.join(' • '); infoEl.textContent = metaInfo.join(' • ');
infoEl.addEventListener('mouseenter', () => infoEl.style.textDecoration = 'underline');
infoEl.addEventListener('mouseleave', () => infoEl.style.textDecoration = 'none');
metaDiv.appendChild(infoEl); metaDiv.appendChild(infoEl);
} }

View File

@@ -586,6 +586,20 @@ svg.world-image {
color: rgba(205, 214, 244, 0.9); color: rgba(205, 214, 244, 0.9);
} }
.rss-link {
display: flex;
align-items: center;
gap: 5px;
color: #f09040;
opacity: 0.7;
margin-left: auto;
}
.rss-link:hover {
color: #f09040;
opacity: 1;
}
/* ─── Static Blog Pages ─────────────────────────────────────────────────────── */ /* ─── Static Blog Pages ─────────────────────────────────────────────────────── */
body.blog-page { body.blog-page {