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:
424
docs/design/feed-media-option2-design.md
Normal file
424
docs/design/feed-media-option2-design.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Feed Media Enhancement Design: Option 2 (RSS + Media RSS Extension)
|
||||
|
||||
## Overview
|
||||
|
||||
This design document specifies the implementation of Option 2 for feed media support: adding Media RSS namespace elements to RSS feeds and the `image` field to JSON Feed items. This provides improved feed reader compatibility for notes with attached images.
|
||||
|
||||
**Target Version**: v1.2.x
|
||||
**Estimated Effort**: 4-6 hours
|
||||
**Prerequisites**: Media attachment model implemented (ADR-057)
|
||||
|
||||
## Current State
|
||||
|
||||
### RSS Feed (`starpunk/feeds/rss.py`)
|
||||
- Embeds media as `<img>` tags within the `<description>` CDATA section
|
||||
- Uses feedgen library for RSS 2.0 generation
|
||||
- No `<enclosure>` elements
|
||||
- No Media RSS namespace
|
||||
|
||||
### JSON Feed (`starpunk/feeds/json_feed.py`)
|
||||
- Includes media in `attachments` array (per JSON Feed 1.1 spec)
|
||||
- Includes media as `<img>` tags in `content_html`
|
||||
- No top-level `image` field for items
|
||||
|
||||
### Note Model
|
||||
- Media accessed via `note.media` property (list of dicts)
|
||||
- Each media item has: `path`, `mime_type`, `size`, `caption` (optional)
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Standards Compliance**: Follow Media RSS spec and JSON Feed 1.1 spec
|
||||
2. **Backward Compatibility**: Keep existing HTML embedding for universal reader support
|
||||
3. **Feed Reader Optimization**: Add structured metadata for enhanced display
|
||||
4. **Minimal Changes**: Modify only feed generation, no database changes
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 1. `starpunk/feeds/rss.py`
|
||||
|
||||
**Changes Required**:
|
||||
|
||||
#### A. Add Media RSS Namespace to Feed Generator
|
||||
|
||||
Location: `generate_rss()` function and `generate_rss_streaming()` function
|
||||
|
||||
```python
|
||||
# Add namespace registration before generating XML
|
||||
# For feedgen-based generation:
|
||||
fg.load_extension('media', rss=True) # feedgen has built-in media extension
|
||||
|
||||
# For streaming generation, add to opening RSS tag:
|
||||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">\n'
|
||||
```
|
||||
|
||||
#### B. Add RSS `<enclosure>` Element (First Image Only)
|
||||
|
||||
Per RSS 2.0 spec, only ONE enclosure per item is allowed. Use the first image.
|
||||
|
||||
```python
|
||||
# In item generation, after setting description:
|
||||
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')
|
||||
)
|
||||
```
|
||||
|
||||
#### C. Add Media RSS Elements (All Images)
|
||||
|
||||
For each image, add `<media:content>` and optional `<media:description>`:
|
||||
|
||||
```python
|
||||
# Using feedgen's media extension:
|
||||
for media_item in note.media:
|
||||
media_url = f"{site_url}/media/{media_item['path']}"
|
||||
|
||||
# Add media:content
|
||||
fe.media.content({
|
||||
'url': media_url,
|
||||
'type': media_item.get('mime_type', 'image/jpeg'),
|
||||
'medium': 'image',
|
||||
'fileSize': str(media_item.get('size', 0))
|
||||
})
|
||||
|
||||
# Add media:description if caption exists
|
||||
if media_item.get('caption'):
|
||||
fe.media.description(media_item['caption'], type='plain')
|
||||
|
||||
# Add media:thumbnail for first image
|
||||
if note.media:
|
||||
first_media = note.media[0]
|
||||
fe.media.thumbnail({
|
||||
'url': f"{site_url}/media/{first_media['path']}"
|
||||
})
|
||||
```
|
||||
|
||||
#### D. Expected XML Output Structure
|
||||
|
||||
For an item with 2 images:
|
||||
|
||||
```xml
|
||||
<item>
|
||||
<title>My Note Title</title>
|
||||
<link>https://example.com/note/my-slug</link>
|
||||
<guid isPermaLink="true">https://example.com/note/my-slug</guid>
|
||||
<pubDate>Mon, 09 Dec 2024 12:00:00 +0000</pubDate>
|
||||
|
||||
<!-- Standard RSS enclosure (first image only) -->
|
||||
<enclosure url="https://example.com/media/2024/12/image1.jpg"
|
||||
length="245760"
|
||||
type="image/jpeg"/>
|
||||
|
||||
<!-- Media RSS elements (all images) -->
|
||||
<media:content url="https://example.com/media/2024/12/image1.jpg"
|
||||
type="image/jpeg"
|
||||
medium="image"
|
||||
fileSize="245760"/>
|
||||
<media:content url="https://example.com/media/2024/12/image2.jpg"
|
||||
type="image/jpeg"
|
||||
medium="image"
|
||||
fileSize="198432"/>
|
||||
|
||||
<!-- Thumbnail (first image) -->
|
||||
<media:thumbnail url="https://example.com/media/2024/12/image1.jpg"/>
|
||||
|
||||
<!-- Caption if present -->
|
||||
<media:description type="plain">Photo from today's hike</media:description>
|
||||
|
||||
<!-- Description with embedded HTML (for legacy readers) -->
|
||||
<description><![CDATA[
|
||||
<div class="media">
|
||||
<img src="https://example.com/media/2024/12/image1.jpg" alt="Photo from today's hike" />
|
||||
<img src="https://example.com/media/2024/12/image2.jpg" alt="" />
|
||||
</div>
|
||||
<p>Note content here...</p>
|
||||
]]></description>
|
||||
</item>
|
||||
```
|
||||
|
||||
### 2. `starpunk/feeds/json_feed.py`
|
||||
|
||||
**Changes Required**:
|
||||
|
||||
#### A. Add `image` Field to Item Objects
|
||||
|
||||
Per JSON Feed 1.1 spec, `image` is "the URL of the main image for the item."
|
||||
|
||||
Location: `_build_item_object()` function
|
||||
|
||||
```python
|
||||
def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
|
||||
# ... existing code ...
|
||||
|
||||
# Add image field (URL of first/main image)
|
||||
# Per JSON Feed 1.1: "the URL of the main image for the item"
|
||||
if hasattr(note, 'media') and note.media:
|
||||
first_media = note.media[0]
|
||||
item["image"] = f"{site_url}/media/{first_media['path']}"
|
||||
|
||||
# ... rest of existing code (content_html, attachments, etc.) ...
|
||||
```
|
||||
|
||||
#### B. Expected JSON Output Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "https://example.com/note/my-slug",
|
||||
"url": "https://example.com/note/my-slug",
|
||||
"title": "My Note Title",
|
||||
"date_published": "2024-12-09T12:00:00Z",
|
||||
|
||||
"image": "https://example.com/media/2024/12/image1.jpg",
|
||||
|
||||
"content_html": "<div class=\"media\"><img src=\"https://example.com/media/2024/12/image1.jpg\" alt=\"Photo from today's hike\" /><img src=\"https://example.com/media/2024/12/image2.jpg\" alt=\"\" /></div><p>Note content here...</p>",
|
||||
|
||||
"attachments": [
|
||||
{
|
||||
"url": "https://example.com/media/2024/12/image1.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"title": "Photo from today's hike",
|
||||
"size_in_bytes": 245760
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/media/2024/12/image2.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size_in_bytes": 198432
|
||||
}
|
||||
],
|
||||
|
||||
"_starpunk": {
|
||||
"permalink_path": "/note/my-slug",
|
||||
"word_count": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### RSS Implementation: feedgen vs Manual Streaming
|
||||
|
||||
**For `generate_rss()` (feedgen-based)**:
|
||||
|
||||
The feedgen library has a media extension. Check if it's available:
|
||||
|
||||
```python
|
||||
# Test if feedgen supports media extension
|
||||
from feedgen.ext.media import MediaExtension
|
||||
|
||||
# If supported, use:
|
||||
fg.register_extension('media', MediaExtension, rss=True)
|
||||
```
|
||||
|
||||
If feedgen's media extension is insufficient, consider manual XML injection after feedgen generates the base XML.
|
||||
|
||||
**For `generate_rss_streaming()` (manual XML)**:
|
||||
|
||||
Modify the streaming generator to include media elements. This requires:
|
||||
|
||||
1. Update the opening RSS tag to include media namespace
|
||||
2. Add `<enclosure>` element after `<pubDate>`
|
||||
3. Add `<media:content>` elements for each image
|
||||
4. Add `<media:thumbnail>` for first image
|
||||
5. Add `<media:description>` if caption exists
|
||||
|
||||
### JSON Feed Implementation
|
||||
|
||||
Straightforward addition in `_build_item_object()`:
|
||||
|
||||
```python
|
||||
# Add image field if media exists
|
||||
if hasattr(note, 'media') and note.media:
|
||||
first_media = note.media[0]
|
||||
item["image"] = f"{site_url}/media/{first_media['path']}"
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests to Add/Modify
|
||||
|
||||
**File**: `tests/test_feeds_rss.py` (create or extend)
|
||||
|
||||
```python
|
||||
def test_rss_enclosure_for_note_with_media():
|
||||
"""RSS item should include enclosure element for first image."""
|
||||
# Create note with media
|
||||
# Generate RSS
|
||||
# Parse XML, verify <enclosure> present with correct attributes
|
||||
|
||||
def test_rss_media_content_for_all_images():
|
||||
"""RSS item should include media:content for each image."""
|
||||
# Create note with 2 images
|
||||
# Generate RSS
|
||||
# Parse XML, verify 2 <media:content> elements
|
||||
|
||||
def test_rss_media_thumbnail_for_first_image():
|
||||
"""RSS item should include media:thumbnail for first image."""
|
||||
# Create note with media
|
||||
# Generate RSS
|
||||
# Parse XML, verify <media:thumbnail> present
|
||||
|
||||
def test_rss_media_description_for_caption():
|
||||
"""RSS item should include media:description if caption exists."""
|
||||
# Create note with captioned image
|
||||
# Generate RSS
|
||||
# Parse XML, verify <media:description> present
|
||||
|
||||
def test_rss_no_media_elements_without_attachments():
|
||||
"""RSS item without media should have no media elements."""
|
||||
# Create note without media
|
||||
# Generate RSS
|
||||
# Parse XML, verify no enclosure or media:* elements
|
||||
|
||||
def test_rss_namespace_declaration():
|
||||
"""RSS feed should declare media namespace."""
|
||||
# Generate any RSS feed
|
||||
# Verify xmlns:media attribute in root element
|
||||
```
|
||||
|
||||
**File**: `tests/test_feeds_json.py` (create or extend)
|
||||
|
||||
```python
|
||||
def test_json_feed_image_field_for_note_with_media():
|
||||
"""JSON Feed item should include image field for first image."""
|
||||
# Create note with media
|
||||
# Generate JSON feed
|
||||
# Parse JSON, verify "image" field present with correct URL
|
||||
|
||||
def test_json_feed_no_image_field_without_media():
|
||||
"""JSON Feed item without media should not have image field."""
|
||||
# Create note without media
|
||||
# Generate JSON feed
|
||||
# Parse JSON, verify "image" field not present
|
||||
|
||||
def test_json_feed_image_uses_first_media():
|
||||
"""JSON Feed image field should use first media item URL."""
|
||||
# Create note with 3 images
|
||||
# Generate JSON feed
|
||||
# Verify "image" URL matches first image path
|
||||
```
|
||||
|
||||
### Feed Validation Tests
|
||||
|
||||
**Manual Validation** (document in test plan):
|
||||
|
||||
1. **W3C Feed Validator**: https://validator.w3.org/feed/
|
||||
- Submit generated RSS feed
|
||||
- Verify no errors for media:* elements
|
||||
- Note: Validator may warn about unknown extensions (acceptable)
|
||||
|
||||
2. **Feed Reader Testing**:
|
||||
- Feedly: Verify images display in article preview
|
||||
- NetNewsWire: Check media thumbnail in list view
|
||||
- Feedbin: Test image extraction
|
||||
- RSS.app: Verify enclosure handling
|
||||
|
||||
3. **JSON Feed Validator**: Use online JSON Feed validator
|
||||
- Verify `image` field accepted
|
||||
- Verify `attachments` array valid
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
def test_rss_route_with_media_notes(client, app):
|
||||
"""GET /feed.xml with media notes returns valid RSS with media elements."""
|
||||
# Create test notes with media
|
||||
# Request /feed.xml
|
||||
# Verify response contains media namespace and elements
|
||||
|
||||
def test_json_route_with_media_notes(client, app):
|
||||
"""GET /feed.json with media notes returns JSON with image fields."""
|
||||
# Create test notes with media
|
||||
# Request /feed.json
|
||||
# Verify response contains image fields
|
||||
```
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
### Media RSS Specification
|
||||
- **URL**: https://www.rssboard.org/media-rss
|
||||
- **Key Elements Used**:
|
||||
- `media:content` - Primary media reference
|
||||
- `media:thumbnail` - Preview image
|
||||
- `media:description` - Caption text
|
||||
|
||||
### JSON Feed 1.1 Specification
|
||||
- **URL**: https://jsonfeed.org/version/1.1/
|
||||
- **Key Fields Used**:
|
||||
- `image` (item level) - "the URL of the main image for the item"
|
||||
- `attachments` - Array of attachment objects (already implemented)
|
||||
|
||||
### RSS 2.0 Enclosure Specification
|
||||
- **URL**: https://www.rssboard.org/rss-specification#ltenclosuregtSubelementOfLtitemgt
|
||||
- **Constraint**: Only ONE enclosure per item allowed
|
||||
- **Required Attributes**: `url`, `length`, `type`
|
||||
|
||||
## Feed Reader Compatibility Notes
|
||||
|
||||
### Media RSS Support
|
||||
|
||||
| Reader | media:content | media:thumbnail | enclosure |
|
||||
|--------|---------------|-----------------|-----------|
|
||||
| Feedly | Yes | Yes | Yes |
|
||||
| Inoreader | Yes | Yes | Yes |
|
||||
| NetNewsWire | Partial | Yes | Yes |
|
||||
| Feedbin | Yes | Yes | Yes |
|
||||
| RSS.app | Yes | Yes | Yes |
|
||||
| The Old Reader | Yes | Partial | Yes |
|
||||
|
||||
### JSON Feed Image Support
|
||||
|
||||
| Reader | image field | attachments |
|
||||
|--------|-------------|-------------|
|
||||
| Feedly | Yes | Yes |
|
||||
| NetNewsWire | Yes | Yes |
|
||||
| Reeder | Yes | Yes |
|
||||
| Feedbin | Yes | Yes |
|
||||
|
||||
**Note**: The HTML-embedded images in `description`/`content_html` serve as fallback for readers that don't support Media RSS or JSON Feed attachments.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. **Implement RSS Changes**
|
||||
- Add namespace declaration
|
||||
- Add enclosure element
|
||||
- Add media:content elements
|
||||
- Add media:thumbnail
|
||||
- Add media:description for captions
|
||||
|
||||
2. **Implement JSON Feed Changes**
|
||||
- Add image field to item builder
|
||||
|
||||
3. **Add Tests**
|
||||
- Unit tests for both feed types
|
||||
- Integration tests for routes
|
||||
|
||||
4. **Manual Validation**
|
||||
- Test with W3C validator
|
||||
- Test in 3+ feed readers
|
||||
|
||||
5. **Deploy**
|
||||
- Release as part of v1.2.x
|
||||
|
||||
## Future Considerations (Option 3)
|
||||
|
||||
This design explicitly does NOT include:
|
||||
- Multiple image sizes/thumbnails (deferred to ADR-059)
|
||||
- Video support (deferred to v1.4.0)
|
||||
- Audio/podcast support (deferred to v1.3.0+)
|
||||
- Full Media RSS attribute set (width, height, duration)
|
||||
|
||||
These are documented in ADR-059: Full Feed Media Standardization for future releases.
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `starpunk/feeds/rss.py` | Add media namespace, enclosure, media:content, media:thumbnail, media:description |
|
||||
| `starpunk/feeds/json_feed.py` | Add `image` field to items with media |
|
||||
| `tests/test_feeds_rss.py` | Add 6 new test cases for media elements |
|
||||
| `tests/test_feeds_json.py` | Add 3 new test cases for image field |
|
||||
|
||||
**Total Estimated Changes**: ~100-150 lines of new code + ~100 lines of tests
|
||||
Reference in New Issue
Block a user