feat: Add tag/category support to all feed formats (v1.3.1)

Implements tag/category rendering in RSS 2.0, Atom 1.0, and JSON Feed 1.1
syndication feeds. Tags added in v1.3.0 are now visible to feed readers.

Changes:
- Load tags in feed generation (_get_cached_notes)
- Add <category> elements to RSS 2.0 feeds
- Add <category term/label> to Atom 1.0 feeds
- Add "tags" array to JSON Feed 1.1 items
- Omit empty tags field in JSON Feed (minimal approach)

Standards compliance:
- RSS 2.0: category element with display name
- Atom 1.0: RFC 4287 category with term and label attributes
- JSON Feed 1.1: tags array of strings

Per design: docs/design/v1.3.1/feed-tags-design.md
Implementation: docs/design/v1.3.1/2025-12-10-feed-tags-implementation.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 12:33:35 -07:00
parent 3222620cee
commit 9b26de7b05
5 changed files with 242 additions and 2 deletions

View File

@@ -178,6 +178,11 @@ def generate_atom_streaming(
# Link to entry
yield f' <link rel="alternate" type="text/html" href="{_escape_xml(permalink)}"/>\n'
# Add category elements for tags (v1.3.1)
if hasattr(note, 'tags') and note.tags:
for tag in note.tags:
yield f' <category term="{_escape_xml(tag["name"])}" label="{_escape_xml(tag["display_name"])}"/>\n'
# Media enclosures (v1.2.0 Phase 3, per Q24 and ADR-057)
if hasattr(note, 'media') and note.media:
for item in note.media:

View File

@@ -307,6 +307,12 @@ def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
item["attachments"] = attachments
# Add tags array (v1.3.1)
# Per spec: array of plain strings (tags, not categories)
# Omit field when no tags (user decision: no empty array)
if hasattr(note, 'tags') and note.tags:
item["tags"] = [tag['display_name'] for tag in note.tags]
# Add custom StarPunk extensions
item["_starpunk"] = {
"permalink_path": note.permalink,

View File

@@ -142,6 +142,11 @@ def generate_rss(
# feedgen automatically wraps content in CDATA for RSS
fe.description(html_content)
# Add category elements for tags (v1.3.1)
if hasattr(note, 'tags') and note.tags:
for tag in note.tags:
fe.category({'term': tag['display_name']})
# Add RSS enclosure element (first image only, per RSS 2.0 spec)
if hasattr(note, 'media') and note.media:
first_media = note.media[0]
@@ -293,6 +298,12 @@ def generate_rss_streaming(
item_xml += f"""
<description><![CDATA[{html_content}]]></description>"""
# Add category elements for tags (v1.3.1)
if hasattr(note, 'tags') and note.tags:
for tag in note.tags:
item_xml += f"""
<category>{_escape_xml(tag['display_name'])}</category>"""
# Add media:content elements (all images)
if hasattr(note, 'media') and note.media:
for media_item in note.media:

View File

@@ -40,12 +40,13 @@ def _get_cached_notes():
Get cached note list or fetch fresh notes
Returns cached notes if still valid, otherwise fetches fresh notes
from database and updates cache. Includes media for each note.
from database and updates cache. Includes media and tags for each note.
Returns:
List of published notes for feed generation (with media attached)
List of published notes for feed generation (with media and tags attached)
"""
from starpunk.media import get_note_media
from starpunk.tags import get_note_tags
# Get cache duration from config (in seconds)
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
@@ -68,6 +69,10 @@ def _get_cached_notes():
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
# Attach tags to each note (v1.3.1)
tags = get_note_tags(note.id)
object.__setattr__(note, 'tags', tags)
_feed_cache["notes"] = notes
_feed_cache["timestamp"] = now