feat: v1.2.0-rc.2 - Media display fixes and feed enhancements

## Added
- Feed Media Enhancement with Media RSS namespace support
  - RSS enclosure, media:content, media:thumbnail elements
  - JSON Feed image field for first image
- ADR-059: Full feed media standardization roadmap

## Fixed
- Media display on homepage (was only showing on note pages)
- Responsive image sizing with CSS constraints
- Caption display (now alt text only, not visible)
- Logging correlation ID crash in non-request contexts

## Documentation
- Feed media design documents and implementation reports
- Media display fixes design and validation reports
- Updated ROADMAP with v1.3.0/v1.4.0 media plans

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-09 14:58:37 -07:00
parent 10d85bb78b
commit 27501f6381
21 changed files with 3360 additions and 44 deletions

View File

@@ -259,6 +259,12 @@ def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
# Add title
item["title"] = note.title
# Add image field (URL of first/main image) - per JSON Feed 1.1 spec
# Per Q7: Field should be absent (not null) when no media
if hasattr(note, 'media') and note.media:
first_media = note.media[0]
item["image"] = f"{site_url}/media/{first_media['path']}"
# Add content (HTML or text)
# Per Q24: Include media as HTML in content_html
if note.html:

View File

@@ -142,8 +142,23 @@ def generate_rss(
# feedgen automatically wraps content in CDATA for RSS
fe.description(html_content)
# Add RSS enclosure element (first image only, per RSS 2.0 spec)
if hasattr(note, 'media') and note.media:
first_media = note.media[0]
media_url = f"{site_url}/media/{first_media['path']}"
fe.enclosure(
url=media_url,
length=str(first_media.get('size', 0)),
type=first_media.get('mime_type', 'image/jpeg')
)
# Generate RSS 2.0 XML (pretty-printed)
feed_xml = fg.rss_str(pretty=True).decode("utf-8")
feed_xml_bytes = fg.rss_str(pretty=True)
feed_xml = feed_xml_bytes.decode("utf-8")
# Add Media RSS elements manually (feedgen's media extension has issues)
# We need to inject media:content and media:thumbnail elements
feed_xml = _inject_media_rss_elements(feed_xml, site_url, notes[:limit])
# Track feed generation metrics
duration_ms = (time.time() - start_time) * 1000
@@ -215,9 +230,9 @@ def generate_rss_streaming(
now = datetime.now(timezone.utc)
last_build = format_rfc822_date(now)
# Yield XML declaration and opening RSS tag
# Yield XML declaration and opening RSS tag with Media RSS namespace
yield '<?xml version="1.0" encoding="UTF-8"?>\n'
yield '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n'
yield '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">\n'
yield " <channel>\n"
# Yield channel metadata
@@ -245,16 +260,54 @@ def generate_rss_streaming(
pubdate = pubdate.replace(tzinfo=timezone.utc)
pub_date_str = format_rfc822_date(pubdate)
# Get HTML content
html_content = clean_html_for_rss(note.html)
# Build HTML content with media (per Q24 and ADR-057)
html_content = ""
# Yield complete item as a single chunk
# Add media at top if present
if hasattr(note, 'media') and note.media:
html_content += '<div class="media">'
for item in note.media:
media_url = f"{site_url}/media/{item['path']}"
caption = item.get('caption', '')
html_content += f'<img src="{media_url}" alt="{caption}" />'
html_content += '</div>'
# Add text content below media
html_content += clean_html_for_rss(note.html)
# Build item XML
item_xml = f""" <item>
<title>{_escape_xml(title)}</title>
<link>{_escape_xml(permalink)}</link>
<guid isPermaLink="true">{_escape_xml(permalink)}</guid>
<pubDate>{pub_date_str}</pubDate>
<description><![CDATA[{html_content}]]></description>
<pubDate>{pub_date_str}</pubDate>"""
# Add enclosure element (first image only, per RSS 2.0 spec)
if hasattr(note, 'media') and note.media:
first_media = note.media[0]
media_url = f"{site_url}/media/{first_media['path']}"
item_xml += f"""
<enclosure url="{_escape_xml(media_url)}" length="{first_media.get('size', 0)}" type="{first_media.get('mime_type', 'image/jpeg')}"/>"""
# Add description with HTML content
item_xml += f"""
<description><![CDATA[{html_content}]]></description>"""
# Add media:content elements (all images)
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"""
<media:content url="{_escape_xml(media_url)}" type="{media_item.get('mime_type', 'image/jpeg')}" medium="image" fileSize="{media_item.get('size', 0)}"/>"""
# Add media:thumbnail (first image only)
first_media = note.media[0]
media_url = f"{site_url}/media/{first_media['path']}"
item_xml += f"""
<media:thumbnail url="{_escape_xml(media_url)}"/>"""
# Close item
item_xml += """
</item>
"""
yield item_xml
@@ -273,6 +326,87 @@ def generate_rss_streaming(
)
def _inject_media_rss_elements(feed_xml: str, site_url: str, notes: list[Note]) -> str:
"""
Inject Media RSS elements into generated RSS feed
Adds media:content and media:thumbnail elements for notes with media using
string manipulation. This approach is simpler than XML parsing and preserves
the original formatting from feedgen.
Args:
feed_xml: Generated RSS XML string
site_url: Base site URL (no trailing slash)
notes: List of notes (already reversed for feedgen)
Returns:
Modified RSS XML with Media RSS elements
"""
# Step 1: Add Media RSS namespace to <rss> tag
# Handle both possible attribute orderings from feedgen
if '<rss xmlns:atom' in feed_xml:
feed_xml = feed_xml.replace(
'<rss xmlns:atom',
'<rss xmlns:media="http://search.yahoo.com/mrss/" xmlns:atom',
1 # Only replace first occurrence
)
elif '<rss version="2.0"' in feed_xml:
feed_xml = feed_xml.replace(
'<rss version="2.0"',
'<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"',
1
)
else:
# Fallback
feed_xml = feed_xml.replace('<rss ', '<rss xmlns:media="http://search.yahoo.com/mrss/" ', 1)
# Step 2: Inject media elements for each note with media
# We need to find each <enclosure> element and inject media elements after it
# Notes are reversed in generate_rss, so notes[0] = first item in feed
for i, note in enumerate(notes):
# Skip if note has no media
if not hasattr(note, 'media') or not note.media:
continue
# Build media elements for this note
media_elements = []
# Add media:content for each image
for media_item in note.media:
media_url = f"{site_url}/media/{media_item['path']}"
media_url_escaped = _escape_xml(media_url)
mime_type = media_item.get('mime_type', 'image/jpeg')
file_size = media_item.get('size', 0)
media_content = f'<media:content url="{media_url_escaped}" type="{mime_type}" medium="image" fileSize="{file_size}"/>'
media_elements.append(media_content)
# Add media:thumbnail for first image
first_media = note.media[0]
media_url = f"{site_url}/media/{first_media['path']}"
media_url_escaped = _escape_xml(media_url)
media_thumbnail = f'<media:thumbnail url="{media_url_escaped}"/>'
media_elements.append(media_thumbnail)
# Find the enclosure for this note and inject media elements after it
# Look for the enclosure with the first media item's path
enclosure_pattern = f'<enclosure url="{media_url_escaped}"'
if enclosure_pattern in feed_xml:
# Find the end of the enclosure tag
enclosure_pos = feed_xml.find(enclosure_pattern)
enclosure_end = feed_xml.find('/>', enclosure_pos)
if enclosure_end != -1:
# Inject media elements right after the enclosure tag
insertion_point = enclosure_end + 2
media_xml = ''.join(media_elements)
feed_xml = feed_xml[:insertion_point] + media_xml + feed_xml[insertion_point:]
return feed_xml
def _escape_xml(text: str) -> str:
"""
Escape special XML characters for safe inclusion in XML elements