diff --git a/starpunk/feeds/atom.py b/starpunk/feeds/atom.py
index 8df3fc7..3935ef3 100644
--- a/starpunk/feeds/atom.py
+++ b/starpunk/feeds/atom.py
@@ -184,12 +184,18 @@ def generate_atom_streaming(
yield f' \n'
# Media enclosures (v1.2.0 Phase 3, per Q24 and ADR-057)
+ # Enhanced with title attribute for captions (v1.4.0 Phase 4)
if hasattr(note, 'media') and note.media:
for item in note.media:
media_url = f"{site_url}/media/{item['path']}"
mime_type = item.get('mime_type', 'image/jpeg')
size = item.get('size', 0)
- yield f' \n'
+ caption = item.get('caption', '')
+
+ # Include title attribute for caption
+ title_attr = f' title="{_escape_xml(caption)}"' if caption else ''
+
+ yield f' \n'
# Content - include media as HTML (per Q24)
if note.html:
diff --git a/starpunk/feeds/json_feed.py b/starpunk/feeds/json_feed.py
index 16b1783..a58348d 100644
--- a/starpunk/feeds/json_feed.py
+++ b/starpunk/feeds/json_feed.py
@@ -313,12 +313,46 @@ def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
if hasattr(note, 'tags') and note.tags:
item["tags"] = [tag['display_name'] for tag in note.tags]
- # Add custom StarPunk extensions
- item["_starpunk"] = {
+ # Add custom StarPunk extensions (v1.4.0 Phase 4)
+ from flask import current_app
+ about_url = current_app.config.get(
+ "STARPUNK_ABOUT_URL",
+ "https://github.com/yourusername/starpunk"
+ )
+ starpunk_ext = {
"permalink_path": note.permalink,
- "word_count": len(note.content.split())
+ "word_count": len(note.content.split()),
+ "about": about_url
}
+ # Add media variants if present
+ if hasattr(note, 'media') and note.media:
+ media_variants = []
+
+ for media_item in note.media:
+ variants = media_item.get('variants', {})
+
+ if variants:
+ media_info = {
+ "caption": media_item.get('caption', ''),
+ "variants": {}
+ }
+
+ for variant_type, variant_data in variants.items():
+ media_info["variants"][variant_type] = {
+ "url": f"{site_url}/media/{variant_data['path']}",
+ "width": variant_data['width'],
+ "height": variant_data['height'],
+ "size_in_bytes": variant_data['size_bytes']
+ }
+
+ media_variants.append(media_info)
+
+ if media_variants:
+ starpunk_ext["media_variants"] = media_variants
+
+ item["_starpunk"] = starpunk_ext
+
return item
diff --git a/starpunk/feeds/rss.py b/starpunk/feeds/rss.py
index 35153e7..80d72e9 100644
--- a/starpunk/feeds/rss.py
+++ b/starpunk/feeds/rss.py
@@ -304,18 +304,62 @@ def generate_rss_streaming(
item_xml += f"""
{_escape_xml(tag['display_name'])}"""
- # Add media:content elements (all images)
+ # Enhanced media handling with variants (v1.4.0 Phase 4)
if hasattr(note, 'media') and note.media:
for media_item in note.media:
- media_url = f"{site_url}/media/{media_item['path']}"
- item_xml += f"""
- """
+ variants = media_item.get('variants', {})
- # Add media:thumbnail (first image only)
- first_media = note.media[0]
- media_url = f"{site_url}/media/{first_media['path']}"
- item_xml += f"""
- """
+ # Use media:group for multiple sizes of same image
+ if variants:
+ item_xml += '\n '
+
+ # Determine which variant is the default (largest available)
+ # Fallback order: large -> medium -> small
+ default_variant = None
+ for fallback in ['large', 'medium', 'small']:
+ if fallback in variants:
+ default_variant = fallback
+ break
+
+ # Add each variant as media:content
+ for variant_type in ['large', 'medium', 'small']:
+ if variant_type in variants:
+ v = variants[variant_type]
+ media_url = f"{site_url}/media/{v['path']}"
+ is_default = 'true' if variant_type == default_variant else 'false'
+ item_xml += f'''
+ '''
+
+ item_xml += '\n '
+
+ # Add media:thumbnail
+ if 'thumb' in variants:
+ thumb = variants['thumb']
+ thumb_url = f"{site_url}/media/{thumb['path']}"
+ item_xml += f'''
+ '''
+
+ # Add media:title for caption
+ if media_item.get('caption'):
+ item_xml += f'''
+ {_escape_xml(media_item['caption'])}'''
+
+ else:
+ # Fallback for media without variants (legacy)
+ media_url = f"{site_url}/media/{media_item['path']}"
+ item_xml += f'''
+ '''
# Close item
item_xml += """
diff --git a/tests/test_feeds_rss.py b/tests/test_feeds_rss.py
index 22b7f35..80691ae 100644
--- a/tests/test_feeds_rss.py
+++ b/tests/test_feeds_rss.py
@@ -402,7 +402,7 @@ class TestRSSStreamingMedia:
assert enclosure is not None
def test_rss_streaming_includes_media_elements(self, app, note_with_single_media):
- """Streaming RSS should include media:content and media:thumbnail"""
+ """Streaming RSS should include media:content and media:thumbnail (v1.4.0 with variants)"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
@@ -419,8 +419,10 @@ class TestRSSStreamingMedia:
channel = root.find("channel")
item = channel.find("item")
- media_content = item.find("media:content", namespaces)
- media_thumbnail = item.find("media:thumbnail", namespaces)
+ # v1.4.0: media:content is now inside media:group for images with variants
+ # Use // to search descendants, not direct children
+ media_content = item.find(".//media:content", namespaces)
+ media_thumbnail = item.find(".//media:thumbnail", namespaces)
assert media_content is not None
assert media_thumbnail is not None