Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ helix-importer-ui
*.bak
.idea
.playwright-mcp
.wrangler

# Ensure these files are tracked
!CLAUDE.md
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"lint:fix": "npm run lint:js -- --fix && npm run lint:css -- --fix",
"dev:contact-us": "wrangler dev --config ./workers/contact_us/wrangler.toml",
"deploy:contact-us": "wrangler deploy --config ./workers/contact_us/wrangler.toml",
"tail:contact-us": "wrangler tail --config ./workers/contact_us/wrangler.toml"
"tail:contact-us": "wrangler tail --config ./workers/contact_us/wrangler.toml",
"dev:feed": "wrangler dev --config ./workers/feed/wrangler.toml",
"deploy:feed": "wrangler deploy --config ./workers/feed/wrangler.toml",
"tail:feed": "wrangler tail --config ./workers/feed/wrangler.toml"
},
"repository": {
"type": "git",
Expand Down
216 changes: 216 additions & 0 deletions workers/feed/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
const FEED_TITLE = 'diyFIRE Articles';
const FEED_DESCRIPTION = 'Latest diyFIRE article updates';
const FEED_LANGUAGE = 'en-ca';
const FEED_AUTHOR_NAME = 'diyFIRE';
const PAGE_SIZE = 200;

/**
* Fetch all rows from query-index, paginating as needed.
* @param {string} origin - Content origin URL
* @returns {Promise<Array>}
*/
async function fetchAllArticles(origin) {
const results = [];
let offset = 0;

let rows;
do {
const url = `${origin}/query-index.json?offset=${offset}&limit=${PAGE_SIZE}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`query-index fetch failed: ${resp.status}`);
const json = await resp.json();
rows = json?.data || [];
results.push(...rows);
offset += rows.length;
} while (rows.length === PAGE_SIZE);
return results;
}

/**
* Filter to article pages: paths under /learn/ with 3+ segments.
* e.g. /learn/fire/whats-fire -> yes, /learn/fire -> no
*/
function isArticle(row) {
const { path } = row;
if (!path || !path.startsWith('/learn/')) return false;
const segments = path.split('/').filter(Boolean);
return segments.length >= 3;
}

/**
* Get a sortable timestamp from a row.
* Prefer authored `date` when available, fall back to `lastModified`.
*/
function getTimestamp(row) {
const date = Number(row.date);
const lastMod = Number(row.lastModified);
const ts = date && !Number.isNaN(date) ? date : lastMod;
return ts && !Number.isNaN(ts) ? ts * 1000 : 0;
}

/**
* Build sorted article list.
*/
async function getArticles(origin) {
const all = await fetchAllArticles(origin);
return all
.filter(isArticle)
.sort((a, b) => getTimestamp(b) - getTimestamp(a));
}

/**
* Escape XML special characters.
*/
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

/**
* Resolve an image path to an absolute URL.
*/
function imageUrl(image, siteUrl) {
if (!image) return '';
if (image.startsWith('http')) return image;
return `${siteUrl}${image}`;
}

/**
* Determine image MIME type from URL.
* Prefers the `format` query param (AEM optimized delivery) over the file extension.
*/
function imageMimeType(url, siteUrl) {
try {
const format = new URL(url, siteUrl).searchParams.get('format');
if (format) {
if (format === 'pjpg' || format === 'jpeg' || format === 'jpg') return 'image/jpeg';
if (format === 'png') return 'image/png';
if (format === 'webp') return 'image/webp';
if (format === 'gif') return 'image/gif';
}
} catch { /* fall through */ }
if (url.includes('.png')) return 'image/png';
if (url.includes('.webp')) return 'image/webp';
return 'image/jpeg';
}

/**
* Build RSS 2.0 XML.
*/
function buildRss(articles, siteUrl) {
const now = new Date().toUTCString();
const selfLink = `${siteUrl}/rss.xml`;

const items = articles.map((row) => {
const link = `${siteUrl}${row.path}`;
const pubDate = new Date(getTimestamp(row)).toUTCString();
const img = imageUrl(row.image, siteUrl);
const enclosure = img
? `\n <enclosure url="${escapeXml(img)}" length="0" type="${imageMimeType(img, siteUrl)}"/>`
: '';

return `<item>
<title><![CDATA[${row.title || ''}]]></title>
<link>${escapeXml(link)}</link>
<guid isPermaLink="true">${escapeXml(link)}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${row.description || ''}]]></description>
${enclosure}
</item>`;
});

return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(FEED_TITLE)}</title>
<link>${escapeXml(siteUrl)}</link>
<description>${escapeXml(FEED_DESCRIPTION)}</description>
<lastBuildDate>${now}</lastBuildDate>
<language>${FEED_LANGUAGE}</language>
<generator>diyFIRE RSS Worker</generator>
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml"/>
${items.join('\n ')}
</channel>
</rss>`;
}

/**
* Build JSON Feed 1.1.
* Spec: https://www.jsonfeed.org/version/1.1/
*/
function buildJsonFeed(articles, siteUrl) {
const items = articles.map((row) => {
const link = `${siteUrl}${row.path}`;
const img = imageUrl(row.image, siteUrl);
const item = {
id: link,
url: link,
title: row.title || '',
summary: row.description || '',
date_published: new Date(getTimestamp(row)).toISOString(),
authors: [{ name: FEED_AUTHOR_NAME }],
};
if (img) {
item.image = img;
}
if (row.keywords && row.keywords.length) {
const raw = Array.isArray(row.keywords) ? row.keywords.join(',') : String(row.keywords);
const tags = raw.split(',').map((k) => k.trim()).filter(Boolean);
if (tags.length) item.tags = tags;
}
return item;
});

return JSON.stringify({
version: 'https://jsonfeed.org/version/1.1',
title: FEED_TITLE,
home_page_url: siteUrl,
feed_url: `${siteUrl}/feed.json`,
description: FEED_DESCRIPTION,
language: FEED_LANGUAGE,
authors: [{ name: FEED_AUTHOR_NAME }],
items,
}, null, 2);
}

/**
* Worker entry point.
*/
export default {
async fetch(request, env) {
const url = new URL(request.url);
const { pathname } = url;
const siteUrl = (env.SITE_URL || 'https://diyfire.ca').replace(/\/+$/, '');
const origin = (env.CONTENT_ORIGIN || 'https://main--diyfire--cloudadoption.aem.live').replace(/\/+$/, '');

if (pathname !== '/rss.xml' && pathname !== '/feed.json') {
return new Response('Not found', { status: 404 });
}

try {
const articles = await getArticles(origin);

if (pathname === '/feed.json') {
return new Response(buildJsonFeed(articles, siteUrl), {
headers: {
'Content-Type': 'application/feed+json; charset=utf-8',
'Cache-Control': 'max-age=7200, must-revalidate',
},
});
}

return new Response(buildRss(articles, siteUrl), {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'max-age=7200, must-revalidate',
},
});
} catch (err) {
return new Response(`Feed generation failed: ${err.message}`, { status: 502 });
}
},
};
10 changes: 10 additions & 0 deletions workers/feed/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name = "cloudadoption-feed"
main = "index.js"
compatibility_date = "2026-02-16"

[vars]
SITE_URL = "https://diyfire.ca"
CONTENT_ORIGIN = "https://main--diyfire--cloudadoption.aem.live"

[observability.logs]
enabled = true