Generating Spec-Compliant RSS 2.0 Feeds

Generating Spec-Compliant RSS 2.0 Feeds: Electric cyan P11 vector CRT macro showing amplitude-modulated harmonic carrier wave packets

Living Document Notice
Published 2026-09-15. The evolving architecture, revisions, and connected notes for this dispatch live in the Stax Digital Garden.

Generating Spec-Compliant RSS 2.0 Feeds

Summary

RSS syndication remains an essential protocol for open-web content dissemination and feed aggregators. However, modern full-stack web platforms frequently neglect syndication endpoints, emitting malformed XML, unescaped entity delimiters, invalid date formats, or non-canonical GUID strings that cause feed readers like NetNewsWire, Miniflux, and Feedly to duplicate entries or fail feed parsing altogether.

Harbor integrates a dedicated, spec-compliant RSS 2.0 generator directly into its Hono edge router. By querying published article records from Directus, escaping character entities, formatting dates strictly to RFC 822 specifications, and emitting RFC-compliant guid elements with explicit isPermaLink="true" attributes, Harbor delivers syndication feeds that parse cleanly across all standards-compliant aggregators.

Standards Compliance: RFC 822 Dates and Canonical GUID Identifiers

Syndication parsers enforce strict XML validation rules that differ substantially from forgiving HTML parsers:

  1. Date Serialization: RSS 2.0 requires pubDate elements to follow RFC 822 (updated by RFC 2822) formatting (Tue, 15 Sep 2026 12:00:00 GMT). Emitting ISO 8601 strings (2026-09-15T12:00:00Z) violates the specification and causes older desktop aggregators to drop publication timestamps.
  2. Canonical Identifiers: The <guid> tag must serve as a durable, unchanging identifier. Harbor binds the GUID to the canonical permalink URL while explicitly declaring isPermaLink="true". Modifying article titles or timestamps will not trigger duplicate unread notifications in subscriber feeds.
  3. XML Entity Escaping and CDATA: Raw HTML bodies injected into <description> or <content:encoded> tags must either escape reserved characters (&, <, >, ") or enclose raw content inside unparsed CDATA blocks (<![CDATA[...]]>).
Element Specification Requirement Common Malformed Output Harbor Spec Implementation
pubDate RFC 822 / 2822 date-time 2026-09-15 (ISO string) Tue, 15 Sep 2026 12:00:00 GMT
guid Stable canonical string Random UUID or database auto-increment ID https://bosunpkm.com/posts/slug
isPermaLink Explicit boolean attribute Missing attribute (assumed true by spec) Explicit isPermaLink="true"
docs Reference to RSS 2.0 specification Omitted https://www.rssboard.org/rss-specification
atom:link Self-referential rel="self" link Omitted or mismatched protocol Fully qualified canonical feed URL

Hono Spec-Compliant Feed Route Implementation

The Harbor RSS route streams XML directly with appropriate MIME headers and cache control policies:

import { Hono } from "hono";

interface FeedArticle {
  id: string;
  title: string;
  slug: string;
  summary: string;
  content_html: string;
  published_at: string;
}

const app = new Hono();

app.get("/feed.xml", async (c) => {
  const directusUrl = process.env.DIRECTUS_INTERNAL_URL || "http://127.0.0.1:8055";
  const token = process.env.DIRECTUS_READ_TOKEN;
  const siteUrl = "https://bosunpkm.com";

  const res = await fetch(
    `${directusUrl}/items/articles?filter[status][_eq]=published&sort=-published_at&limit=25`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (!res.ok) {
    return c.text("Feed Unavailable", 503);
  }

  const payload = await res.json();
  const articles: FeedArticle[] = payload.data || [];

  const lastBuildDate = articles.length > 0
    ? new Date(articles[0].published_at).toUTCString()
    : new Date().toUTCString();

  const itemsXml = articles.map((post) => {
    const permalink = `${siteUrl}/posts/${post.slug}`;
    const pubDate = new Date(post.published_at).toUTCString();
    
    return `    <item>
      <title>${escapeXml(post.title)}</title>
      <link>${permalink}</link>
      <guid isPermaLink="true">${permalink}</guid>
      <pubDate>${pubDate}</pubDate>
      <description><![CDATA[${post.summary}]]></description>
      <content:encoded><![CDATA[${post.content_html}]]></content:encoded>
    </item>`;
  }).join("\n");

  const xmlResponse = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" 
     xmlns:content="http://purl.org/rss/1.0/modules/content/" 
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Bosun PKM Dispatch</title>
    <link>${siteUrl}</link>
    <description>Technical dispatches on sovereign local-first systems and edge publishing.</description>
    <language>en-us</language>
    <lastBuildDate>${lastBuildDate}</lastBuildDate>
    <docs>https://www.rssboard.org/rss-specification</docs>
    <atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml" />
${itemsXml}
  </channel>
</rss>`;

  c.header("Content-Type", "application/xml; charset=UTF-8");
  c.header("Cache-Control", "public, max-age=0, s-maxage=300, stale-while-revalidate=900");
  return c.body(xmlResponse);
});

function escapeXml(unsafe: string): string {
  return unsafe.replace(/[<>&'"]/g, (c) => {
    switch (c) {
      case "<": return "&lt;";
      case ">": return "&gt;";
      case "&": return "&amp;";
      case "'": return "&apos;";
      case '"': return "&quot;";
      default: return c;
    }
  });
}

export default app;

Feed Validation and Parsing Probing

Verify the generated XML feed against the W3C validator rules and shell validation tools:

# Fetch and validate XML well-formedness with xmllint
curl -s http://127.0.0.1:3000/feed.xml | xmllint --noout - && echo "XML Well-Formed"

# Verify RFC 822 date compliance in item tags
curl -s http://127.0.0.1:3000/feed.xml | grep -E "<pubDate>" | head -n 3

# Verify explicit isPermaLink attribute on GUID tags
curl -s http://127.0.0.1:3000/feed.xml | grep -E '<guid isPermaLink="true">' | head -n 3

# Validate caching headers and content type returned by edge
curl -s -I http://127.0.0.1:3000/feed.xml | grep -E "(Content-Type|Cache-Control)"
← Back to Harbor Blog