diff --git a/build.js b/build.js index ffd2298..82b4b77 100644 --- a/build.js +++ b/build.js @@ -96,6 +96,7 @@ async function processBlogPosts(buildDir) { await staticBlogGenerator.generate(posts); console.log('✓ Generated static blog pages'); + await generateRssFeed(posts, buildDir); } catch (err) { if (err.code === 'ENOENT') { 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} + */ +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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + /** + * 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 [ + ' ', + ` ${xmlEscape(post.title || post.slug)}`, + ` ${url}`, + ` ${url}`, + pubDate ? ` ${pubDate}` : '', + post.author ? ` ${xmlEscape(post.author)}` : '', + ` ${description}`, + ' ', + ].filter(Boolean).join('\n'); + }); + + const xml = [ + '', + '', + ' ', + ' lit.ruv.wtf', + ` ${siteUrl}`, + ' Blog posts from lit.ruv.wtf', + ' en-us', + ` `, + ...items, + ' ', + '', + ].join('\n'); + + await fs.writeFile(path.join(buildDir, 'rss.xml'), xml, 'utf-8'); + console.log('✓ Generated RSS feed'); +} + // Run the build async function build() { const buildDir = path.join(__dirname, 'build'); diff --git a/tools/StaticBlogGenerator.js b/tools/StaticBlogGenerator.js index 4f5467e..43e91a8 100644 --- a/tools/StaticBlogGenerator.js +++ b/tools/StaticBlogGenerator.js @@ -229,6 +229,7 @@ class StaticBlogGenerator { ` `, ` `, ...ogMeta, + ` `, ' ', ' ', ' ', @@ -245,6 +246,10 @@ class StaticBlogGenerator { '', '', this.renderScrollTopBar(), bodyHtml, diff --git a/website/scripts/nodes/BlogPostNode.js b/website/scripts/nodes/BlogPostNode.js index 92598af..ebab4c4 100644 --- a/website/scripts/nodes/BlogPostNode.js +++ b/website/scripts/nodes/BlogPostNode.js @@ -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);"; if (post.title) { - const titleEl = document.createElement("h2"); + const titleEl = document.createElement("a"); + titleEl.href = `/blog/${encodeURIComponent(post.slug)}/`; 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); } @@ -71,9 +74,12 @@ export class BlogPostNode extends NodeBase { } if (metaInfo.length > 0) { - const infoEl = document.createElement("div"); - infoEl.style.cssText = "color: rgba(255,255,255,0.6); font-size: 0.9em;"; + const infoEl = document.createElement("a"); + 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.addEventListener('mouseenter', () => infoEl.style.textDecoration = 'underline'); + infoEl.addEventListener('mouseleave', () => infoEl.style.textDecoration = 'none'); metaDiv.appendChild(infoEl); } diff --git a/website/styles/main.css b/website/styles/main.css index f3e34db..216f999 100644 --- a/website/styles/main.css +++ b/website/styles/main.css @@ -586,6 +586,20 @@ svg.world-image { 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 ─────────────────────────────────────────────────────── */ body.blog-page {