feat(syndication): Sprint 13 — public RSS + Atom + sitemap feeds#8
Merged
feat(syndication): Sprint 13 — public RSS + Atom + sitemap feeds#8
Conversation
Adds a full syndication surface so Publy communities participate in the read-across-the-web ecosystem (RSS readers, podcast clients, search crawlers). Seven new public endpoints, all tenant-scoped via the existing TenantInterceptor: GET /feed/rss.xml community RSS 2.0 GET /feed/atom.xml community Atom 1.0 GET /topics/:slug/feed/rss.xml topic RSS GET /topics/:slug/feed/atom.xml topic Atom GET /authors/:slug/feed/rss.xml author RSS GET /authors/:slug/feed/atom.xml author Atom GET /sitemap.xml sitemap 0.9 Design: - Hand-rolled XML. No `feed`/`rss` libs — the specs are ~100 lines of string building per format, and owning the output lets us add media namespaces incrementally without fighting a library. - Every text interpolation routed through `escapeXml()` covering the five predefined entities + invalid XML 1.0 control chars. RSS <description> wrapped in CDATA with `]]>` rewriting for safety. - Hard invariants on included shouts: status='PUBLISHED' AND visibility='PUBLIC' AND deleted_at IS NULL AND published_at IS NOT NULL. COMMUNITY/OWNER-visibility content can't leak into public feeds. - Topic/author feeds throw NotFoundError on unknown slug rather than returning empty — an empty-feed oracle would hide typos from operators and clients. - CDN-friendly Cache-Control: 5min/10min for feeds, 1h/2h for sitemap. - Content-Type set per format (application/rss+xml, application/atom+xml, application/xml) — feed readers are notoriously picky about MIME. New config: - FRONTEND_URL — base for item <link> targets (where HTML pages live) - DEFAULT_LANG — BCP-47 language tag for <language>/<feed xml:lang> Tests (16 new, 369/369 total passing): - Community/topic/author queries respect status, visibility, tenant, deletedAt, and slug presence - Limit parameter honored - Tenant isolation verified across two communities - NotFoundError on unknown topic/author slug - RSS XML structure + atom:link self ref - XML-unsafe chars in user input escaped (injection resistance) - CDATA `]]>` end-sequence rewritten correctly - Atom <id>, <updated>, <published>, author, and category tags present - Sitemap <urlset>/<loc>/<lastmod>/<changefreq> structure Pattern 37-syndication-feeds documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Seven new public read-only endpoints bring Publy onto the syndication map — every journalist-reader workflow (Feedly, NetNewsWire, Readwise), every podcast client, every search crawler can now consume content:
GET /feed/rss.xml//feed/atom.xml— community-wideGET /topics/:slug/feed/rss.xml//feed/atom.xml— per-topicGET /authors/:slug/feed/rss.xml//feed/atom.xml— per-authorGET /sitemap.xml— search enginesAll endpoints
@Public(), all tenant-scoped via existingTenantInterceptor, all with CDN-friendlyCache-Control(5min feeds, 1h sitemap).Why hand-rolled XML?
feed/rssnpm packages pull in xml-builder transitive deps we already avoid. The specs are ~100 lines of string building each. Owning the output lets us addxmlns:media/xmlns:contentincrementally without fighting an abstraction. Every text node routed throughescapeXml()covering the five XML predefined entities + invalid-XML-1.0 control chars; RSS<description>wrapped in CDATA with]]>rewriting for safety.Invariants
Every feed query enforces:
Nothing DRAFT/POSTED/COMMUNITY-visibility ever leaks into a public feed. Tenant scope is applied at the SQL layer — verified by integration tests that create shouts in one community and confirm another sees an empty feed.
Topic/author feeds throw
NotFoundErroron unknown slug rather than returning empty — an empty-feed oracle would hide typos from operators and clients.New config
FRONTEND_URL<link>targets (HTML page URLs)http://localhost:3000DEFAULT_LANG<language>/xml:langruTests
16 new integration tests → 369/369 total passing (was 353).
<channel>,<atom:link rel=\"self\">,<entry>,<id>)<script>in titles comes out as<script>]]>inleadrewritten as]]]]><![CDATA[><urlset>/<loc>/<lastmod>/<changefreq>Test plan
npx tsc --noEmitcleannpx biome check .clean (1 pre-existing warning unchanged)npm run build:core+build:crdtcleannpx vitest run→ 369/369 passing🤖 Generated with Claude Code