",
- "content_text": "Plain text content",
- "summary": "Brief summary",
- "image": "https://example.com/image.jpg",
- "banner_image": "https://example.com/banner.jpg",
- "date_published": "2024-11-25T12:00:00Z",
- "date_modified": "2024-11-25T13:00:00Z",
- "authors": [],
- "tags": ["tag1", "tag2"],
- "language": "en",
- "attachments": [],
- "_custom": {}
-}
-```
-
-### Required Item Fields
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `id` | String | Unique, stable ID |
-
-### Optional Item Fields
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `url` | String | Item permalink |
-| `external_url` | String | Link to external content |
-| `title` | String | Item title |
-| `content_html` | String | HTML content |
-| `content_text` | String | Plain text content |
-| `summary` | String | Brief summary |
-| `image` | String | Main image URL |
-| `banner_image` | String | Wide banner image |
-| `date_published` | String | RFC 3339 date |
-| `date_modified` | String | RFC 3339 date |
-| `authors` | Array | Item authors |
-| `tags` | Array | String tags |
-| `language` | String | Language code |
-| `attachments` | Array | File attachments |
-
-### Author Object
-
-```json
-{
- "name": "Author Name",
- "url": "https://example.com/about",
- "avatar": "https://example.com/avatar.jpg"
-}
-```
-
-### Attachment Object
-
-```json
-{
- "url": "https://example.com/file.pdf",
- "mime_type": "application/pdf",
- "title": "Attachment Title",
- "size_in_bytes": 1024000,
- "duration_in_seconds": 300
-}
-```
-
-## Implementation Design
-
-### JSON Feed Generator Class
-
-```python
-import json
-from typing import List, Dict, Any, Iterator
-from datetime import datetime, timezone
-
-class JsonFeedGenerator:
- """JSON Feed 1.1 generator with streaming support"""
-
- def __init__(self, site_url: str, site_name: str, site_description: str,
- author_name: str = None, author_url: str = None, author_avatar: str = None):
- self.site_url = site_url.rstrip('/')
- self.site_name = site_name
- self.site_description = site_description
- self.author = {
- 'name': author_name,
- 'url': author_url,
- 'avatar': author_avatar
- } if author_name else None
-
- def generate(self, notes: List[Note], limit: int = 50) -> str:
- """Generate complete JSON feed
-
- IMPORTANT: Notes are expected to be in DESC order (newest first)
- from the database. This order MUST be preserved in the feed.
- """
- feed = self._build_feed_object(notes[:limit])
- return json.dumps(feed, ensure_ascii=False, indent=2)
-
- def generate_streaming(self, notes: List[Note], limit: int = 50) -> Iterator[str]:
- """Generate JSON feed as stream of chunks
-
- IMPORTANT: Notes are expected to be in DESC order (newest first)
- from the database. This order MUST be preserved in the feed.
- """
- # Start feed object
- yield '{\n'
- yield ' "version": "https://jsonfeed.org/version/1.1",\n'
- yield f' "title": {json.dumps(self.site_name)},\n'
-
- # Add optional feed metadata
- yield from self._stream_feed_metadata()
-
- # Start items array
- yield ' "items": [\n'
-
- # Stream items - maintain DESC order (newest first)
- # DO NOT reverse! Database order is correct
- items = notes[:limit]
- for i, note in enumerate(items):
- item_json = json.dumps(self._build_item_object(note), indent=4)
- # Indent items properly
- indented = '\n'.join(' ' + line for line in item_json.split('\n'))
- yield indented
-
- if i < len(items) - 1:
- yield ',\n'
- else:
- yield '\n'
-
- # Close items array and feed
- yield ' ]\n'
- yield '}\n'
-
- def _build_feed_object(self, notes: List[Note]) -> Dict[str, Any]:
- """Build complete feed object"""
- feed = {
- 'version': 'https://jsonfeed.org/version/1.1',
- 'title': self.site_name,
- 'home_page_url': self.site_url,
- 'feed_url': f'{self.site_url}/feed.json',
- 'description': self.site_description,
- 'items': [self._build_item_object(note) for note in notes]
- }
-
- # Add optional fields
- if self.author:
- feed['authors'] = [self._clean_author(self.author)]
-
- feed['language'] = 'en' # Make configurable
-
- # Add icon/favicon if configured
- icon_url = self._get_icon_url()
- if icon_url:
- feed['icon'] = icon_url
-
- favicon_url = self._get_favicon_url()
- if favicon_url:
- feed['favicon'] = favicon_url
-
- return feed
-
- def _build_item_object(self, note: Note) -> Dict[str, Any]:
- """Build item object from note"""
- permalink = f'{self.site_url}{note.permalink}'
-
- item = {
- 'id': permalink,
- 'url': permalink,
- 'title': note.title or self._format_date_title(note.created_at),
- 'date_published': self._format_json_date(note.created_at)
- }
-
- # Add content (prefer HTML)
- if note.html:
- item['content_html'] = note.html
- elif note.content:
- item['content_text'] = note.content
-
- # Add modified date if different
- if hasattr(note, 'updated_at') and note.updated_at != note.created_at:
- item['date_modified'] = self._format_json_date(note.updated_at)
-
- # Add summary if available
- if hasattr(note, 'summary') and note.summary:
- item['summary'] = note.summary
-
- # Add tags if available
- if hasattr(note, 'tags') and note.tags:
- item['tags'] = note.tags
-
- # Add author if different from feed author
- if hasattr(note, 'author') and note.author != self.author:
- item['authors'] = [self._clean_author(note.author)]
-
- # Add image if available
- image_url = self._extract_image_url(note)
- if image_url:
- item['image'] = image_url
-
- # Add custom extensions
- item['_starpunk'] = {
- 'permalink_path': note.permalink,
- 'word_count': len(note.content.split()) if note.content else 0
- }
-
- return item
-
- def _clean_author(self, author: Any) -> Dict[str, str]:
- """Clean author object for JSON"""
- clean = {}
-
- if isinstance(author, dict):
- if author.get('name'):
- clean['name'] = author['name']
- if author.get('url'):
- clean['url'] = author['url']
- if author.get('avatar'):
- clean['avatar'] = author['avatar']
- elif hasattr(author, 'name'):
- clean['name'] = author.name
- if hasattr(author, 'url'):
- clean['url'] = author.url
- if hasattr(author, 'avatar'):
- clean['avatar'] = author.avatar
- else:
- clean['name'] = str(author)
-
- return clean
-
- def _format_json_date(self, dt: datetime) -> str:
- """Format datetime to RFC 3339 for JSON Feed
-
- Format: 2024-11-25T12:00:00Z or 2024-11-25T12:00:00-05:00
- """
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
-
- # Use Z for UTC
- if dt.tzinfo == timezone.utc:
- return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
- else:
- return dt.isoformat()
-
- def _extract_image_url(self, note: Note) -> Optional[str]:
- """Extract first image URL from note content"""
- if not note.html:
- return None
-
- # Simple regex to find first img tag
- import re
- match = re.search(r']+src="([^"]+)"', note.html)
- if match:
- img_url = match.group(1)
- # Make absolute if relative
- if not img_url.startswith('http'):
- img_url = f'{self.site_url}{img_url}'
- return img_url
-
- return None
-```
-
-### Streaming JSON Generation
-
-For memory efficiency with large feeds:
-
-```python
-class StreamingJsonEncoder:
- """Helper for streaming JSON generation"""
-
- @staticmethod
- def stream_object(obj: Dict[str, Any], indent: int = 0) -> Iterator[str]:
- """Stream a JSON object"""
- indent_str = ' ' * indent
- yield indent_str + '{\n'
-
- items = list(obj.items())
- for i, (key, value) in enumerate(items):
- yield f'{indent_str} "{key}": '
-
- if isinstance(value, dict):
- yield from StreamingJsonEncoder.stream_object(value, indent + 2)
- elif isinstance(value, list):
- yield from StreamingJsonEncoder.stream_array(value, indent + 2)
- else:
- yield json.dumps(value)
-
- if i < len(items) - 1:
- yield ','
- yield '\n'
-
- yield indent_str + '}'
-
- @staticmethod
- def stream_array(arr: List[Any], indent: int = 0) -> Iterator[str]:
- """Stream a JSON array"""
- indent_str = ' ' * indent
- yield '[\n'
-
- for i, item in enumerate(arr):
- if isinstance(item, dict):
- yield from StreamingJsonEncoder.stream_object(item, indent + 2)
- else:
- yield indent_str + ' ' + json.dumps(item)
-
- if i < len(arr) - 1:
- yield ','
- yield '\n'
-
- yield indent_str + ']'
-```
-
-## Complete JSON Feed Example
-
-```json
-{
- "version": "https://jsonfeed.org/version/1.1",
- "title": "StarPunk Notes",
- "home_page_url": "https://example.com/",
- "feed_url": "https://example.com/feed.json",
- "description": "Personal notes and thoughts",
- "authors": [
- {
- "name": "John Doe",
- "url": "https://example.com/about",
- "avatar": "https://example.com/avatar.jpg"
- }
- ],
- "language": "en",
- "icon": "https://example.com/icon.png",
- "favicon": "https://example.com/favicon.ico",
- "items": [
- {
- "id": "https://example.com/notes/2024/11/25/first-note",
- "url": "https://example.com/notes/2024/11/25/first-note",
- "title": "My First Note",
- "content_html": "
This is my first note with bold text.
",
- "summary": "Introduction to my notes",
- "image": "https://example.com/images/first.jpg",
- "date_published": "2024-11-25T10:00:00Z",
- "date_modified": "2024-11-25T10:30:00Z",
- "tags": ["personal", "introduction"],
- "_starpunk": {
- "permalink_path": "/notes/2024/11/25/first-note",
- "word_count": 8
- }
- },
- {
- "id": "https://example.com/notes/2024/11/24/another-note",
- "url": "https://example.com/notes/2024/11/24/another-note",
- "title": "Another Note",
- "content_text": "Plain text content for this note.",
- "date_published": "2024-11-24T15:45:00Z",
- "tags": ["thoughts"],
- "_starpunk": {
- "permalink_path": "/notes/2024/11/24/another-note",
- "word_count": 6
- }
- }
- ]
-}
-```
-
-## Validation
-
-### JSON Feed Validator
-
-Validate against the official validator:
-- https://validator.jsonfeed.org/
-
-### Common Validation Issues
-
-1. **Invalid JSON Syntax**
- - Proper escaping of quotes
- - Valid UTF-8 encoding
- - No trailing commas
-
-2. **Missing Required Fields**
- - version, title, items required
- - Each item needs id
-
-3. **Invalid Date Format**
- - Must be RFC 3339
- - Include timezone
-
-4. **Invalid URLs**
- - Must be absolute URLs
- - Properly encoded
-
-## Testing Strategy
-
-### Unit Tests
-
-```python
-class TestJsonFeedGenerator:
- def test_required_fields(self):
- """Test all required fields are present"""
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- feed_json = generator.generate(notes)
- feed = json.loads(feed_json)
-
- assert feed['version'] == 'https://jsonfeed.org/version/1.1'
- assert 'title' in feed
- assert 'items' in feed
-
- def test_feed_order_newest_first(self):
- """Test JSON feed shows newest entries first (spec convention)"""
- # Create notes with different timestamps
- old_note = Note(
- title="Old Note",
- created_at=datetime(2024, 11, 20, 10, 0, 0, tzinfo=timezone.utc)
- )
- new_note = Note(
- title="New Note",
- created_at=datetime(2024, 11, 25, 10, 0, 0, tzinfo=timezone.utc)
- )
-
- # Generate feed with notes in DESC order (as from database)
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- feed_json = generator.generate([new_note, old_note])
- feed = json.loads(feed_json)
-
- # First item should be newest
- assert feed['items'][0]['title'] == "New Note"
- assert '2024-11-25' in feed['items'][0]['date_published']
-
- # Second item should be oldest
- assert feed['items'][1]['title'] == "Old Note"
- assert '2024-11-20' in feed['items'][1]['date_published']
-
- def test_json_validity(self):
- """Test output is valid JSON"""
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- feed_json = generator.generate(notes)
-
- # Should parse without error
- feed = json.loads(feed_json)
- assert isinstance(feed, dict)
-
- def test_date_formatting(self):
- """Test RFC 3339 date formatting"""
- dt = datetime(2024, 11, 25, 12, 0, 0, tzinfo=timezone.utc)
- formatted = generator._format_json_date(dt)
-
- assert formatted == '2024-11-25T12:00:00Z'
-
- def test_streaming_generation(self):
- """Test streaming produces valid JSON"""
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- chunks = list(generator.generate_streaming(notes))
- feed_json = ''.join(chunks)
-
- # Should be valid JSON
- feed = json.loads(feed_json)
- assert feed['version'] == 'https://jsonfeed.org/version/1.1'
-
- def test_custom_extensions(self):
- """Test custom _starpunk extension"""
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- feed_json = generator.generate([sample_note])
- feed = json.loads(feed_json)
-
- item = feed['items'][0]
- assert '_starpunk' in item
- assert 'permalink_path' in item['_starpunk']
- assert 'word_count' in item['_starpunk']
-```
-
-### Integration Tests
-
-```python
-def test_json_feed_endpoint():
- """Test JSON feed endpoint"""
- response = client.get('/feed.json')
-
- assert response.status_code == 200
- assert response.content_type == 'application/feed+json'
-
- feed = json.loads(response.data)
- assert feed['version'] == 'https://jsonfeed.org/version/1.1'
-
-def test_content_negotiation_json():
- """Test content negotiation prefers JSON"""
- response = client.get('/feed', headers={'Accept': 'application/json'})
-
- assert response.status_code == 200
- assert 'json' in response.content_type.lower()
-
-def test_feed_reader_compatibility():
- """Test with JSON Feed readers"""
- readers = [
- 'Feedbin',
- 'Inoreader',
- 'NewsBlur',
- 'NetNewsWire'
- ]
-
- for reader in readers:
- assert validate_with_reader(feed_url, reader, format='json')
-```
-
-### Validation Tests
-
-```python
-def test_jsonfeed_validation():
- """Validate against official validator"""
- generator = JsonFeedGenerator(site_url, site_name, site_description)
- feed_json = generator.generate(sample_notes)
-
- # Submit to validator
- result = validate_json_feed(feed_json)
- assert result['valid'] == True
- assert len(result['errors']) == 0
-```
-
-## Performance Benchmarks
-
-### Generation Speed
-
-```python
-def benchmark_json_generation():
- """Benchmark JSON feed generation"""
- notes = generate_sample_notes(100)
- generator = JsonFeedGenerator(site_url, site_name, site_description)
-
- start = time.perf_counter()
- feed_json = generator.generate(notes, limit=50)
- duration = time.perf_counter() - start
-
- assert duration < 0.05 # Less than 50ms
- assert len(feed_json) > 0
-```
-
-### Size Comparison
-
-```python
-def test_json_vs_xml_size():
- """Compare JSON feed size to RSS/ATOM"""
- notes = generate_sample_notes(50)
-
- # Generate all formats
- json_feed = json_generator.generate(notes)
- rss_feed = rss_generator.generate(notes)
- atom_feed = atom_generator.generate(notes)
-
- # JSON should be more compact
- print(f"JSON: {len(json_feed)} bytes")
- print(f"RSS: {len(rss_feed)} bytes")
- print(f"ATOM: {len(atom_feed)} bytes")
-
- # Typically JSON is 20-30% smaller
-```
-
-## Configuration
-
-### JSON Feed Settings
-
-```ini
-# JSON Feed configuration
-STARPUNK_FEED_JSON_ENABLED=true
-STARPUNK_FEED_JSON_AUTHOR_NAME=John Doe
-STARPUNK_FEED_JSON_AUTHOR_URL=https://example.com/about
-STARPUNK_FEED_JSON_AUTHOR_AVATAR=https://example.com/avatar.jpg
-STARPUNK_FEED_JSON_ICON=https://example.com/icon.png
-STARPUNK_FEED_JSON_FAVICON=https://example.com/favicon.ico
-STARPUNK_FEED_JSON_LANGUAGE=en
-STARPUNK_FEED_JSON_HUB_URL= # WebSub hub URL (optional)
-```
-
-## Security Considerations
-
-1. **JSON Injection Prevention**
- - Proper JSON escaping
- - No raw user input
- - Validate all URLs
-
-2. **Content Security**
- - HTML content sanitized
- - No script injection
- - Safe JSON encoding
-
-3. **Size Limits**
- - Maximum feed size
- - Item count limits
- - Timeout protection
-
-## Migration Notes
-
-### Adding JSON Feed
-
-- Runs parallel to RSS/ATOM
-- No changes to existing feeds
-- Shared caching infrastructure
-- Same data source
-
-## Advanced Features
-
-### WebSub Support (Future)
-
-```json
-{
- "hubs": [
- {
- "type": "WebSub",
- "url": "https://example.com/hub"
- }
- ]
-}
-```
-
-### Pagination
-
-```json
-{
- "next_url": "https://example.com/feed.json?page=2"
-}
-```
-
-### Attachments
-
-```json
-{
- "attachments": [
- {
- "url": "https://example.com/podcast.mp3",
- "mime_type": "audio/mpeg",
- "title": "Podcast Episode",
- "size_in_bytes": 25000000,
- "duration_in_seconds": 1800
- }
- ]
-}
-```
-
-## Acceptance Criteria
-
-1. β Valid JSON Feed 1.1 generation
-2. β All required fields present
-3. β RFC 3339 dates correct
-4. β Valid JSON syntax
-5. β Streaming generation working
-6. β Official validator passing
-7. β Works with 5+ JSON Feed readers
-8. β Performance target met (<50ms)
-9. β Custom extensions working
-10. β Security review passed
\ No newline at end of file
diff --git a/docs/design/v1.1.2/metrics-instrumentation-spec.md b/docs/design/v1.1.2/metrics-instrumentation-spec.md
deleted file mode 100644
index 6212940..0000000
--- a/docs/design/v1.1.2/metrics-instrumentation-spec.md
+++ /dev/null
@@ -1,534 +0,0 @@
-# Metrics Instrumentation Specification - v1.1.2
-
-## Overview
-
-This specification completes the metrics instrumentation foundation started in v1.1.1, adding comprehensive coverage for database operations, HTTP requests, memory monitoring, and business-specific syndication metrics.
-
-## Requirements
-
-### Functional Requirements
-
-1. **Database Performance Metrics**
- - Time all database operations
- - Track query patterns and frequency
- - Detect slow queries (>1 second)
- - Monitor connection pool utilization
- - Count rows affected/returned
-
-2. **HTTP Request/Response Metrics**
- - Full request lifecycle timing
- - Request and response size tracking
- - Status code distribution
- - Per-endpoint performance metrics
- - Client identification (user agent)
-
-3. **Memory Monitoring**
- - Continuous RSS memory tracking
- - Memory growth detection
- - High water mark tracking
- - Garbage collection statistics
- - Leak detection algorithms
-
-4. **Business Metrics**
- - Feed request counts by format
- - Cache hit/miss rates
- - Content publication rates
- - Syndication success tracking
- - Format popularity analysis
-
-### Non-Functional Requirements
-
-1. **Performance Impact**
- - Total overhead <1% when enabled
- - Zero impact when disabled
- - Efficient metric storage (<2MB)
- - Non-blocking collection
-
-2. **Data Retention**
- - In-memory circular buffer
- - Last 1000 metrics retained
- - 15-minute detail window
- - Automatic cleanup
-
-## Design
-
-### Database Instrumentation
-
-#### Connection Wrapper
-
-```python
-class MonitoredConnection:
- """SQLite connection wrapper with performance monitoring"""
-
- def __init__(self, db_path: str, metrics_collector: MetricsCollector):
- self.conn = sqlite3.connect(db_path)
- self.metrics = metrics_collector
-
- def execute(self, query: str, params: Optional[tuple] = None) -> sqlite3.Cursor:
- """Execute query with timing"""
- query_type = self._get_query_type(query)
- table_name = self._extract_table_name(query)
-
- start_time = time.perf_counter()
- try:
- cursor = self.conn.execute(query, params or ())
- duration = time.perf_counter() - start_time
-
- # Record successful execution
- self.metrics.record_database_operation(
- operation_type=query_type,
- table_name=table_name,
- duration_ms=duration * 1000,
- rows_affected=cursor.rowcount if query_type != 'SELECT' else len(cursor.fetchall())
- )
-
- # Check for slow query
- if duration > 1.0:
- self.metrics.record_slow_query(query, duration, params)
-
- return cursor
-
- except Exception as e:
- duration = time.perf_counter() - start_time
- self.metrics.record_database_error(query_type, table_name, str(e), duration * 1000)
- raise
-
- def _get_query_type(self, query: str) -> str:
- """Extract query type from SQL"""
- query_upper = query.strip().upper()
- for query_type in ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP']:
- if query_upper.startswith(query_type):
- return query_type
- return 'OTHER'
-
- def _extract_table_name(self, query: str) -> Optional[str]:
- """Extract primary table name from query"""
- # Simple regex patterns for common cases
- patterns = [
- r'FROM\s+(\w+)',
- r'INTO\s+(\w+)',
- r'UPDATE\s+(\w+)',
- r'DELETE\s+FROM\s+(\w+)'
- ]
- # Implementation details...
-```
-
-#### Metrics Collected
-
-| Metric | Type | Description |
-|--------|------|-------------|
-| `db.query.duration` | Histogram | Query execution time in ms |
-| `db.query.count` | Counter | Total queries by type |
-| `db.query.errors` | Counter | Failed queries by type |
-| `db.rows.affected` | Histogram | Rows modified per query |
-| `db.rows.returned` | Histogram | Rows returned per SELECT |
-| `db.slow_queries` | List | Queries exceeding threshold |
-| `db.connection.active` | Gauge | Active connections |
-| `db.transaction.duration` | Histogram | Transaction time in ms |
-
-### HTTP Instrumentation
-
-#### Request Middleware
-
-```python
-class HTTPMetricsMiddleware:
- """Flask middleware for HTTP metrics collection"""
-
- def __init__(self, app: Flask, metrics_collector: MetricsCollector):
- self.app = app
- self.metrics = metrics_collector
- self.setup_hooks()
-
- def setup_hooks(self):
- """Register Flask hooks for metrics"""
-
- @self.app.before_request
- def start_request_timer():
- """Initialize request metrics"""
- g.request_metrics = {
- 'start_time': time.perf_counter(),
- 'start_memory': self._get_memory_usage(),
- 'request_id': str(uuid.uuid4()),
- 'method': request.method,
- 'endpoint': request.endpoint,
- 'path': request.path,
- 'content_length': request.content_length or 0
- }
-
- @self.app.after_request
- def record_response_metrics(response):
- """Record response metrics"""
- if not hasattr(g, 'request_metrics'):
- return response
-
- # Calculate metrics
- duration = time.perf_counter() - g.request_metrics['start_time']
- memory_delta = self._get_memory_usage() - g.request_metrics['start_memory']
-
- # Record to collector
- self.metrics.record_http_request(
- method=g.request_metrics['method'],
- endpoint=g.request_metrics['endpoint'],
- status_code=response.status_code,
- duration_ms=duration * 1000,
- request_size=g.request_metrics['content_length'],
- response_size=len(response.get_data()),
- memory_delta_mb=memory_delta
- )
-
- # Add timing header for debugging
- if self.app.config.get('DEBUG'):
- response.headers['X-Response-Time'] = f"{duration * 1000:.2f}ms"
-
- return response
-```
-
-#### Metrics Collected
-
-| Metric | Type | Description |
-|--------|------|-------------|
-| `http.request.duration` | Histogram | Total request processing time |
-| `http.request.count` | Counter | Requests by method and endpoint |
-| `http.request.size` | Histogram | Request body size distribution |
-| `http.response.size` | Histogram | Response body size distribution |
-| `http.status.{code}` | Counter | Response status code counts |
-| `http.endpoint.{name}.duration` | Histogram | Per-endpoint timing |
-| `http.memory.delta` | Gauge | Memory change per request |
-
-### Memory Monitoring
-
-#### Background Monitor Thread
-
-```python
-class MemoryMonitor(Thread):
- """Background thread for continuous memory monitoring"""
-
- def __init__(self, metrics_collector: MetricsCollector, interval: int = 10):
- super().__init__(daemon=True)
- self.metrics = metrics_collector
- self.interval = interval
- self.running = True
- self.baseline_memory = None
- self.high_water_mark = 0
-
- def run(self):
- """Main monitoring loop"""
- # Establish baseline after startup
- time.sleep(5)
- self.baseline_memory = self._get_memory_info()
-
- while self.running:
- try:
- memory_info = self._get_memory_info()
-
- # Update high water mark
- self.high_water_mark = max(self.high_water_mark, memory_info['rss'])
-
- # Calculate growth rate
- if self.baseline_memory:
- growth_rate = (memory_info['rss'] - self.baseline_memory['rss']) /
- (time.time() - self.baseline_memory['timestamp']) * 3600
-
- # Detect potential leak (>10MB/hour growth)
- if growth_rate > 10:
- self.metrics.record_memory_leak_warning(growth_rate)
-
- # Record metrics
- self.metrics.record_memory_usage(
- rss_mb=memory_info['rss'],
- vms_mb=memory_info['vms'],
- high_water_mb=self.high_water_mark,
- gc_stats=self._get_gc_stats()
- )
-
- except Exception as e:
- logger.error(f"Memory monitoring error: {e}")
-
- time.sleep(self.interval)
-
- def _get_memory_info(self) -> dict:
- """Get current memory usage"""
- import resource
- usage = resource.getrusage(resource.RUSAGE_SELF)
- return {
- 'timestamp': time.time(),
- 'rss': usage.ru_maxrss / 1024, # Convert to MB
- 'vms': usage.ru_idrss
- }
-
- def _get_gc_stats(self) -> dict:
- """Get garbage collection statistics"""
- import gc
- return {
- 'collections': gc.get_count(),
- 'collected': gc.collect(0),
- 'uncollectable': len(gc.garbage)
- }
-```
-
-#### Metrics Collected
-
-| Metric | Type | Description |
-|--------|------|-------------|
-| `memory.rss` | Gauge | Resident set size in MB |
-| `memory.vms` | Gauge | Virtual memory size in MB |
-| `memory.high_water` | Gauge | Maximum RSS observed |
-| `memory.growth_rate` | Gauge | MB/hour growth rate |
-| `gc.collections` | Counter | GC collection counts by generation |
-| `gc.collected` | Counter | Objects collected |
-| `gc.uncollectable` | Gauge | Uncollectable object count |
-
-### Business Metrics
-
-#### Syndication Metrics
-
-```python
-class SyndicationMetrics:
- """Business metrics specific to content syndication"""
-
- def __init__(self, metrics_collector: MetricsCollector):
- self.metrics = metrics_collector
-
- def record_feed_request(self, format: str, cached: bool, generation_time: float):
- """Record feed request metrics"""
- self.metrics.increment(f'feed.requests.{format}')
-
- if cached:
- self.metrics.increment('feed.cache.hits')
- else:
- self.metrics.increment('feed.cache.misses')
- self.metrics.record_histogram('feed.generation.time', generation_time * 1000)
-
- def record_content_negotiation(self, accept_header: str, selected_format: str):
- """Track content negotiation results"""
- self.metrics.increment(f'feed.negotiation.{selected_format}')
-
- # Track client preferences
- if 'json' in accept_header.lower():
- self.metrics.increment('feed.client.prefers_json')
- elif 'atom' in accept_header.lower():
- self.metrics.increment('feed.client.prefers_atom')
-
- def record_publication(self, note_length: int, has_media: bool):
- """Track content publication metrics"""
- self.metrics.increment('content.notes.published')
- self.metrics.record_histogram('content.note.length', note_length)
-
- if has_media:
- self.metrics.increment('content.notes.with_media')
-```
-
-#### Metrics Collected
-
-| Metric | Type | Description |
-|--------|------|-------------|
-| `feed.requests.{format}` | Counter | Requests by feed format |
-| `feed.cache.hits` | Counter | Cache hit count |
-| `feed.cache.misses` | Counter | Cache miss count |
-| `feed.cache.hit_rate` | Gauge | Cache hit percentage |
-| `feed.generation.time` | Histogram | Feed generation duration |
-| `feed.negotiation.{format}` | Counter | Format selection results |
-| `content.notes.published` | Counter | Total notes published |
-| `content.note.length` | Histogram | Note size distribution |
-| `content.syndication.success` | Counter | Successful syndications |
-
-## Implementation Details
-
-### Metrics Collector
-
-```python
-class MetricsCollector:
- """Central metrics collection and storage"""
-
- def __init__(self, buffer_size: int = 1000):
- self.buffer = deque(maxlen=buffer_size)
- self.counters = defaultdict(int)
- self.gauges = {}
- self.histograms = defaultdict(list)
- self.slow_queries = deque(maxlen=100)
-
- def record_metric(self, category: str, name: str, value: float, metadata: dict = None):
- """Record a generic metric"""
- metric = {
- 'timestamp': time.time(),
- 'category': category,
- 'name': name,
- 'value': value,
- 'metadata': metadata or {}
- }
- self.buffer.append(metric)
-
- def increment(self, name: str, amount: int = 1):
- """Increment a counter"""
- self.counters[name] += amount
-
- def set_gauge(self, name: str, value: float):
- """Set a gauge value"""
- self.gauges[name] = value
-
- def record_histogram(self, name: str, value: float):
- """Add value to histogram"""
- self.histograms[name].append(value)
- # Keep only last 1000 values
- if len(self.histograms[name]) > 1000:
- self.histograms[name] = self.histograms[name][-1000:]
-
- def get_summary(self, window_seconds: int = 900) -> dict:
- """Get metrics summary for dashboard"""
- cutoff = time.time() - window_seconds
- recent = [m for m in self.buffer if m['timestamp'] > cutoff]
-
- summary = {
- 'counters': dict(self.counters),
- 'gauges': dict(self.gauges),
- 'histograms': self._calculate_histogram_stats(),
- 'recent_metrics': recent[-100:], # Last 100 metrics
- 'slow_queries': list(self.slow_queries)
- }
-
- return summary
-
- def _calculate_histogram_stats(self) -> dict:
- """Calculate statistics for histograms"""
- stats = {}
- for name, values in self.histograms.items():
- if values:
- sorted_values = sorted(values)
- stats[name] = {
- 'count': len(values),
- 'min': min(values),
- 'max': max(values),
- 'mean': sum(values) / len(values),
- 'p50': sorted_values[len(values) // 2],
- 'p95': sorted_values[int(len(values) * 0.95)],
- 'p99': sorted_values[int(len(values) * 0.99)]
- }
- return stats
-```
-
-## Configuration
-
-### Environment Variables
-
-```ini
-# Metrics collection toggles
-STARPUNK_METRICS_ENABLED=true
-STARPUNK_METRICS_DB_TIMING=true
-STARPUNK_METRICS_HTTP_TIMING=true
-STARPUNK_METRICS_MEMORY_MONITOR=true
-STARPUNK_METRICS_BUSINESS=true
-
-# Thresholds
-STARPUNK_METRICS_SLOW_QUERY_THRESHOLD=1.0 # seconds
-STARPUNK_METRICS_MEMORY_LEAK_THRESHOLD=10 # MB/hour
-
-# Storage
-STARPUNK_METRICS_BUFFER_SIZE=1000
-STARPUNK_METRICS_RETENTION_SECONDS=900 # 15 minutes
-
-# Monitoring intervals
-STARPUNK_METRICS_MEMORY_INTERVAL=10 # seconds
-```
-
-## Testing Strategy
-
-### Unit Tests
-
-1. **Collector Tests**
- ```python
- def test_metrics_buffer_circular():
- collector = MetricsCollector(buffer_size=10)
- for i in range(20):
- collector.record_metric('test', 'metric', i)
- assert len(collector.buffer) == 10
- assert collector.buffer[0]['value'] == 10 # Oldest is 10, not 0
- ```
-
-2. **Instrumentation Tests**
- ```python
- def test_database_timing():
- conn = MonitoredConnection(':memory:', collector)
- conn.execute('CREATE TABLE test (id INTEGER)')
-
- metrics = collector.get_summary()
- assert 'db.query.duration' in metrics['histograms']
- assert metrics['counters']['db.query.count'] == 1
- ```
-
-### Integration Tests
-
-1. **End-to-End Request Tracking**
- ```python
- def test_request_metrics():
- response = client.get('/feed.xml')
-
- metrics = app.metrics_collector.get_summary()
- assert 'http.request.duration' in metrics['histograms']
- assert metrics['counters']['http.status.200'] > 0
- ```
-
-2. **Memory Leak Detection**
- ```python
- def test_memory_monitoring():
- monitor = MemoryMonitor(collector)
- monitor.start()
-
- # Simulate memory growth
- large_list = [0] * 1000000
- time.sleep(15)
-
- metrics = collector.get_summary()
- assert metrics['gauges']['memory.rss'] > 0
- ```
-
-## Performance Benchmarks
-
-### Overhead Measurement
-
-```python
-def benchmark_instrumentation_overhead():
- # Baseline without instrumentation
- config.METRICS_ENABLED = False
- start = time.perf_counter()
- for _ in range(1000):
- execute_operation()
- baseline = time.perf_counter() - start
-
- # With instrumentation
- config.METRICS_ENABLED = True
- start = time.perf_counter()
- for _ in range(1000):
- execute_operation()
- instrumented = time.perf_counter() - start
-
- overhead_percent = ((instrumented - baseline) / baseline) * 100
- assert overhead_percent < 1.0 # Less than 1% overhead
-```
-
-## Security Considerations
-
-1. **No Sensitive Data**: Never log query parameters that might contain passwords
-2. **Rate Limiting**: Metrics endpoints should be rate-limited
-3. **Access Control**: Metrics dashboard requires admin authentication
-4. **Data Sanitization**: Escape all user-provided data in metrics
-
-## Migration Notes
-
-### From v1.1.1
-
-- Existing performance monitoring configuration remains compatible
-- New metrics are additive, no breaking changes
-- Dashboard enhanced but backward compatible
-
-## Acceptance Criteria
-
-1. β All database operations are timed
-2. β HTTP requests fully instrumented
-3. β Memory monitoring thread operational
-4. β Business metrics for syndication tracked
-5. β Performance overhead <1%
-6. β Metrics dashboard shows all new data
-7. β Slow query detection working
-8. β Memory leak detection functional
-9. β All metrics properly documented
-10. β Security review passed
\ No newline at end of file
diff --git a/docs/design/v1.1.2/phase2-completion-update.md b/docs/design/v1.1.2/phase2-completion-update.md
deleted file mode 100644
index f5cbe1f..0000000
--- a/docs/design/v1.1.2/phase2-completion-update.md
+++ /dev/null
@@ -1,159 +0,0 @@
-# StarPunk v1.1.2 Phase 2 - Completion Update
-
-**Date**: 2025-11-26
-**Phase**: 2 - Feed Formats
-**Status**: COMPLETE β
-
-## Summary
-
-Phase 2 of the v1.1.2 "Syndicate" release has been fully completed by the developer. All sub-phases (2.0 through 2.4) have been implemented, tested, and reviewed.
-
-## Implementation Status
-
-### Phase 2.0: RSS Feed Ordering Fix β COMPLETE
-- **Status**: COMPLETE (2025-11-26)
-- **Time**: 0.5 hours (as estimated)
-- **Result**: Critical bug fixed, RSS now shows newest-first
-
-### Phase 2.1: Feed Module Restructuring β COMPLETE
-- **Status**: COMPLETE (2025-11-26)
-- **Time**: 1.5 hours
-- **Result**: Clean module organization in `starpunk/feeds/`
-
-### Phase 2.2: ATOM Feed Generation β COMPLETE
-- **Status**: COMPLETE (2025-11-26)
-- **Time**: 2.5 hours
-- **Result**: Full RFC 4287 compliance with 11 passing tests
-
-### Phase 2.3: JSON Feed Generation β COMPLETE
-- **Status**: COMPLETE (2025-11-26)
-- **Time**: 2.5 hours
-- **Result**: JSON Feed 1.1 compliance with 13 passing tests
-
-### Phase 2.4: Content Negotiation β COMPLETE
-- **Status**: COMPLETE (2025-11-26)
-- **Time**: 1 hour
-- **Result**: HTTP Accept header negotiation with 63 passing tests
-
-## Total Phase 2 Metrics
-
-- **Total Time**: 8 hours (vs 6-8 hours estimated)
-- **Total Tests**: 132 (all passing)
-- **Lines of Code**: ~2,540 (production + tests)
-- **Standards**: Full compliance with RSS 2.0, ATOM 1.0, JSON Feed 1.1
-
-## Deliverables
-
-### Production Code
-- `starpunk/feeds/rss.py` - RSS 2.0 generator (moved from feed.py)
-- `starpunk/feeds/atom.py` - ATOM 1.0 generator (new)
-- `starpunk/feeds/json_feed.py` - JSON Feed 1.1 generator (new)
-- `starpunk/feeds/negotiation.py` - Content negotiation (new)
-- `starpunk/feeds/__init__.py` - Module exports
-- `starpunk/feed.py` - Backward compatibility shim
-- `starpunk/routes/public.py` - Feed endpoints
-
-### Test Code
-- `tests/helpers/feed_ordering.py` - Shared ordering test helper
-- `tests/test_feeds_atom.py` - ATOM tests (11 tests)
-- `tests/test_feeds_json.py` - JSON Feed tests (13 tests)
-- `tests/test_feeds_negotiation.py` - Negotiation tests (41 tests)
-- `tests/test_routes_feeds.py` - Integration tests (22 tests)
-
-### Documentation
-- `docs/reports/2025-11-26-v1.1.2-phase2-complete.md` - Developer's implementation report
-- `docs/reviews/2025-11-26-phase2-architect-review.md` - Architect's review (APPROVED)
-
-## Available Endpoints
-
-```
-GET /feed # Content negotiation (RSS/ATOM/JSON)
-GET /feed.rss # Explicit RSS 2.0
-GET /feed.atom # Explicit ATOM 1.0
-GET /feed.json # Explicit JSON Feed 1.1
-GET /feed.xml # Backward compat (β /feed.rss)
-```
-
-## Quality Metrics
-
-### Test Results
-```bash
-$ uv run pytest tests/test_feed*.py tests/test_routes_feed*.py -q
-132 passed in 11.42s
-```
-
-### Standards Compliance
-- β RSS 2.0: Full specification compliance
-- β ATOM 1.0: RFC 4287 compliance
-- β JSON Feed 1.1: Full specification compliance
-- β HTTP: Practical content negotiation
-
-### Performance
-- RSS generation: ~2-5ms for 50 items
-- ATOM generation: ~2-5ms for 50 items
-- JSON generation: ~1-3ms for 50 items
-- Content negotiation: <1ms overhead
-
-## Architect's Review
-
-**Verdict**: APPROVED WITH COMMENDATION
-
-Key points from review:
-- Exceptional adherence to architectural principles
-- Perfect implementation of StarPunk philosophy
-- Zero defects identified
-- Ready for immediate production deployment
-
-## Next Steps
-
-### Immediate
-1. β Merge to main branch (approved by architect)
-2. β Deploy to production (includes critical RSS fix)
-3. β³ Begin Phase 3: Feed Caching
-
-### Phase 3 Preview
-- Checksum-based feed caching
-- ETag support
-- Conditional GET (304 responses)
-- Cache invalidation strategy
-- Estimated time: 4-6 hours
-
-## Updates Required
-
-### Project Plan
-The main implementation guide (`docs/design/v1.1.2/implementation-guide.md`) should be updated to reflect:
-- Phase 2 marked as COMPLETE
-- Actual time taken (8 hours)
-- Link to completion documentation
-- Phase 3 ready to begin
-
-### CHANGELOG
-Add entry for Phase 2 completion:
-```markdown
-### [Unreleased] - Phase 2 Complete
-
-#### Added
-- ATOM 1.0 feed support with RFC 4287 compliance
-- JSON Feed 1.1 support with full specification compliance
-- HTTP content negotiation for automatic format selection
-- Explicit feed endpoints (/feed.rss, /feed.atom, /feed.json)
-- Comprehensive feed test suite (132 tests)
-
-#### Fixed
-- Critical: RSS feed ordering now shows newest entries first
-- Removed misleading comments about feedgen behavior
-
-#### Changed
-- Restructured feed code into `starpunk/feeds/` module
-- Improved feed generation performance with streaming
-```
-
-## Conclusion
-
-Phase 2 is complete and exceeds all requirements. The implementation is production-ready and approved for immediate deployment. The developer has demonstrated exceptional skill in delivering a comprehensive, standards-compliant solution with minimal code.
-
----
-
-**Updated by**: StarPunk Architect (AI)
-**Date**: 2025-11-26
-**Phase Status**: β COMPLETE - Ready for Phase 3
\ No newline at end of file
diff --git a/docs/operations/upgrade-to-v1.1.2.md b/docs/design/v1.1.2/upgrade-to-v1.1.2.md
similarity index 100%
rename from docs/operations/upgrade-to-v1.1.2.md
rename to docs/design/v1.1.2/upgrade-to-v1.1.2.md
diff --git a/docs/design/v1.1.2-caption-alttext-update.md b/docs/design/v1.1.2/v1.1.2-caption-alttext-update.md
similarity index 100%
rename from docs/design/v1.1.2-caption-alttext-update.md
rename to docs/design/v1.1.2/v1.1.2-caption-alttext-update.md
diff --git a/docs/projectplan/v1.1.2-options.md b/docs/design/v1.1.2/v1.1.2-options.md
similarity index 100%
rename from docs/projectplan/v1.1.2-options.md
rename to docs/design/v1.1.2/v1.1.2-options.md
diff --git a/docs/reports/v1.1.2-phase1-metrics-implementation.md b/docs/design/v1.1.2/v1.1.2-phase1-metrics-implementation.md
similarity index 100%
rename from docs/reports/v1.1.2-phase1-metrics-implementation.md
rename to docs/design/v1.1.2/v1.1.2-phase1-metrics-implementation.md
diff --git a/docs/architecture/v1.1.2-syndicate-architecture.md b/docs/design/v1.1.2/v1.1.2-syndicate-architecture.md
similarity index 100%
rename from docs/architecture/v1.1.2-syndicate-architecture.md
rename to docs/design/v1.1.2/v1.1.2-syndicate-architecture.md
diff --git a/docs/reviews/2025-11-28-v1.2.0-design-complete.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-design-complete.md
similarity index 100%
rename from docs/reviews/2025-11-28-v1.2.0-design-complete.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-design-complete.md
diff --git a/docs/reports/2025-11-28-media-display-fixes.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-media-display-fixes.md
similarity index 100%
rename from docs/reports/2025-11-28-media-display-fixes.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-media-display-fixes.md
diff --git a/docs/reports/2025-11-28-v1.2.0-phase1-custom-slugs.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase1-custom-slugs.md
similarity index 100%
rename from docs/reports/2025-11-28-v1.2.0-phase1-custom-slugs.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase1-custom-slugs.md
diff --git a/docs/reviews/2025-11-28-v1.2.0-phase1-review.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase1-review.md
similarity index 100%
rename from docs/reviews/2025-11-28-v1.2.0-phase1-review.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase1-review.md
diff --git a/docs/reports/2025-11-28-v1.2.0-phase2-author-microformats.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase2-author-microformats.md
similarity index 100%
rename from docs/reports/2025-11-28-v1.2.0-phase2-author-microformats.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase2-author-microformats.md
diff --git a/docs/reviews/2025-11-28-v1.2.0-phase2-review.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase2-review.md
similarity index 100%
rename from docs/reviews/2025-11-28-v1.2.0-phase2-review.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase2-review.md
diff --git a/docs/reports/2025-11-28-v1.2.0-phase3-media-upload.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase3-media-upload.md
similarity index 100%
rename from docs/reports/2025-11-28-v1.2.0-phase3-media-upload.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase3-media-upload.md
diff --git a/docs/reviews/2025-11-28-v1.2.0-phase3-review.md b/docs/design/v1.2.0/2025-11-28-v1.2.0-phase3-review.md
similarity index 100%
rename from docs/reviews/2025-11-28-v1.2.0-phase3-review.md
rename to docs/design/v1.2.0/2025-11-28-v1.2.0-phase3-review.md
diff --git a/docs/reports/2025-12-09-feed-media-implementation.md b/docs/design/v1.2.0/2025-12-09-feed-media-implementation.md
similarity index 100%
rename from docs/reports/2025-12-09-feed-media-implementation.md
rename to docs/design/v1.2.0/2025-12-09-feed-media-implementation.md
diff --git a/docs/reports/2025-12-09-media-display-validation.md b/docs/design/v1.2.0/2025-12-09-media-display-validation.md
similarity index 100%
rename from docs/reports/2025-12-09-media-display-validation.md
rename to docs/design/v1.2.0/2025-12-09-media-display-validation.md
diff --git a/docs/design/v1.2.0/2025-12-09-v1.2.0-release.md b/docs/design/v1.2.0/2025-12-09-v1.2.0-release.md
new file mode 100644
index 0000000..d383107
--- /dev/null
+++ b/docs/design/v1.2.0/2025-12-09-v1.2.0-release.md
@@ -0,0 +1,258 @@
+# v1.2.0 Release Report
+
+**Date**: 2025-12-09
+**Version**: 1.2.0
+**Release Type**: Stable Minor Release
+**Previous Version**: 1.1.2
+
+## Overview
+
+Successfully promoted v1.2.0-rc.2 to stable v1.2.0 release. This is a major feature release adding comprehensive media support, author discovery, custom slugs, and enhanced syndication feeds.
+
+## Release Process
+
+### 1. Version Updates
+
+**File**: `starpunk/__init__.py`
+- Updated `__version__` from `"1.2.0-rc.2"` to `"1.2.0"`
+- Updated `__version_info__` from `(1, 2, 0, "dev")` to `(1, 2, 0)`
+
+### 2. CHANGELOG Updates
+
+**File**: `CHANGELOG.md`
+- Merged rc.1 and rc.2 entries into single `[1.2.0]` section
+- Added release date: 2025-12-09
+- Consolidated all features and fixes from both release candidates
+- Maintained chronological order of changes
+
+### 3. Git Operations
+
+**Commit**: `927db4a`
+```
+release: Bump version to 1.2.0
+
+Promote v1.2.0-rc.2 to stable v1.2.0 release
+
+- Merged rc.1 and rc.2 changelog entries
+- Updated version in starpunk/__init__.py
+- All features tested in production
+```
+
+**Tag**: `v1.2.0` (annotated)
+- Comprehensive release notes included
+- Documents all major features
+- Notes standards compliance
+- Includes upgrade instructions
+
+### 4. Container Images
+
+Built and pushed container images:
+- `git.thesatelliteoflove.com/phil/starpunk:v1.2.0`
+- `git.thesatelliteoflove.com/phil/starpunk:latest`
+
+**Image Size**: 190 MB
+**Base**: Python 3.11-slim
+**Build**: Multi-stage with uv package manager
+
+### 5. Registry Push
+
+Successfully pushed to remote:
+- Git commit pushed to `origin/main`
+- Git tag `v1.2.0` pushed to remote
+- Container images pushed to `git.thesatelliteoflove.com` registry
+
+## Release Contents
+
+### Major Features
+
+#### Media Upload & Display
+- Upload up to 4 images per note (JPEG, PNG, GIF, WebP)
+- Automatic image optimization with Pillow library
+- File size limit: 10MB per image
+- Dimension limit: 4096x4096 pixels
+- Auto-resize images over 2048px
+- EXIF orientation correction
+- Social media style layout (media first, then text)
+- Optional captions for accessibility
+- Responsive image sizing with proper CSS
+
+#### Feed Media Enhancement
+- Media RSS namespace (xmlns:media) for structured metadata
+- RSS enclosure element for first image (per RSS 2.0 spec)
+- Media RSS media:content elements for all images
+- Media RSS media:thumbnail element for preview
+- JSON Feed image field (per JSON Feed 1.1 spec)
+- Enhanced display in modern feed readers (Feedly, Inoreader, NetNewsWire)
+
+#### Author Profile Discovery
+- Automatic h-card discovery from IndieAuth identity
+- Caches author information (name, photo, bio, rel-me links)
+- 24-hour cache TTL
+- Graceful fallback to domain name
+- Never blocks login functionality
+- Eliminates need for manual author configuration
+
+#### Complete Microformats2 Support
+- Full h-entry markup with required properties
+- Author h-card nested within each h-entry
+- Proper p-name handling (only when explicit title)
+- u-uid and u-url match for permalink stability
+- Homepage as h-feed with proper structure
+- rel-me links from discovered profile
+- dt-updated property when note modified
+- Passes Microformats2 validation
+
+#### Custom Slugs
+- Web UI custom slug input field
+- Optional field with auto-generation fallback
+- Read-only after creation (preserves permalinks)
+- Automatic validation and sanitization
+- Helpful placeholder text and guidance
+- Matches Micropub mp-slug behavior
+
+### Fixes from RC Releases
+
+#### RC.2 Fixes
+- Media display on homepage (not just individual note pages)
+- Responsive image sizing with container constraints
+- Caption display (alt text only, not visible text)
+- Logging correlation ID crash in non-request contexts
+
+#### RC.1 Fixes
+- All features tested and validated in production
+
+## Standards Compliance
+
+- W3C Micropub Specification
+- Microformats2 h-entry, h-card, h-feed
+- RSS 2.0 with Media RSS extension
+- JSON Feed 1.1 specification
+- IndieWeb best practices
+
+## Testing
+
+- 600+ tests passing
+- All features tested in production (rc.1 and rc.2)
+- Enhanced feed reader compatibility verified
+- Media upload and display validated
+- Author discovery tested with multiple profiles
+
+## Upgrade Instructions
+
+### From v1.1.2
+
+No breaking changes. Simple upgrade process:
+
+1. Pull latest code: `git pull origin main`
+2. Checkout tag: `git checkout v1.2.0`
+3. Restart application
+
+### Configuration
+
+No configuration changes required. All new features work automatically.
+
+Optional configuration for media:
+- `MEDIA_MAX_SIZE` - Max file size in bytes (default: 10MB)
+- `MEDIA_MAX_DIMENSION` - Max dimension in pixels (default: 4096)
+- `MEDIA_RESIZE_THRESHOLD` - Auto-resize threshold (default: 2048)
+
+## Verification
+
+### Version Check
+```bash
+$ uv run python -c "from starpunk import __version__; print(__version__)"
+1.2.0
+```
+
+### Git Tag
+```bash
+$ git tag -l v1.2.0
+v1.2.0
+
+$ git log -1 --oneline
+927db4a release: Bump version to 1.2.0
+```
+
+### Container Images
+```bash
+$ podman images | grep starpunk | grep v1.2.0
+git.thesatelliteoflove.com/phil/starpunk v1.2.0 20853617ebf1 190 MB
+git.thesatelliteoflove.com/phil/starpunk latest 20853617ebf1 190 MB
+```
+
+## Documentation
+
+### Updated Files
+- `/home/phil/Projects/starpunk/starpunk/__init__.py`
+- `/home/phil/Projects/starpunk/CHANGELOG.md`
+
+### Release Documentation
+- Git tag annotation with full release notes
+- This implementation report
+- CHANGELOG.md with complete details
+
+### Existing Documentation (Unchanged)
+- `/home/phil/Projects/starpunk/docs/design/v1.2.0-media-css-design.md`
+- `/home/phil/Projects/starpunk/docs/design/v1.1.2-caption-alttext-update.md`
+- `/home/phil/Projects/starpunk/docs/design/media-display-fixes.md`
+- `/home/phil/Projects/starpunk/docs/reports/2025-11-28-media-display-fixes.md`
+
+## Release Timeline
+
+- **2025-11-28**: v1.2.0-rc.1 released (initial feature complete)
+- **2025-12-09**: v1.2.0-rc.2 released (media display fixes)
+- **2025-12-09**: v1.2.0 stable released (production validated)
+
+## Backwards Compatibility
+
+Fully backward compatible with v1.1.2. No breaking changes.
+
+- Existing notes display correctly
+- Existing feeds continue working
+- Existing configuration valid
+- Existing clients unaffected
+
+## Known Issues
+
+None identified. All features tested and stable in production.
+
+## Next Steps
+
+### Post-Release
+1. Monitor production deployment
+2. Update any documentation references to version numbers
+3. Announce release to users
+
+### Future Development (v1.3.0 or v2.0.0)
+- Additional IndieWeb features (Webmentions, etc.)
+- Enhanced search capabilities
+- Performance optimizations
+- User-requested features
+
+## Related Documentation
+
+- `/home/phil/Projects/starpunk/docs/standards/versioning-strategy.md`
+- `/home/phil/Projects/starpunk/docs/standards/git-branching-strategy.md`
+- `/home/phil/Projects/starpunk/CHANGELOG.md`
+
+## Compliance
+
+This release follows:
+- Semantic Versioning 2.0.0
+- Keep a Changelog format
+- Git workflow from versioning-strategy.md
+- Developer protocol from CLAUDE.md
+
+## Summary
+
+Successfully promoted v1.2.0-rc.2 to stable v1.2.0 release. All steps completed:
+
+- Version updated in `starpunk/__init__.py`
+- CHANGELOG.md updated with merged entries
+- Git commit created and pushed
+- Annotated tag `v1.2.0` created and pushed
+- Container images built (v1.2.0 and latest)
+- Container images pushed to registry
+- All verification checks passed
+
+The release is now available for production deployment.
diff --git a/docs/design/v1.2.0/2025-12-10-documentation-audit-v1.2.0.md b/docs/design/v1.2.0/2025-12-10-documentation-audit-v1.2.0.md
new file mode 100644
index 0000000..cf1ab67
--- /dev/null
+++ b/docs/design/v1.2.0/2025-12-10-documentation-audit-v1.2.0.md
@@ -0,0 +1,300 @@
+# Documentation Audit Report - Post v1.2.0 Release
+
+**Date**: 2025-12-10
+**Agent**: Documentation Manager
+**Scope**: Comprehensive documentation audit and cleanup after v1.2.0 release
+
+## Executive Summary
+
+Performed a comprehensive documentation audit of the StarPunk project following the v1.2.0 release. The audit focused on repository structure compliance, design document organization, ADR integrity, and README currency. All issues identified have been resolved, resulting in a well-organized and maintainable documentation system.
+
+**Overall Documentation Health**: Excellent
+
+## Audit Findings and Actions
+
+### 1. Repository Root Compliance
+
+**Status**: PASS
+
+**Finding**: Repository root contains only the three approved documentation files:
+- README.md
+- CLAUDE.md
+- CHANGELOG.md
+
+**Action**: No action required. Root structure is compliant with documentation standards.
+
+---
+
+### 2. Misplaced Design Documents
+
+**Status**: RESOLVED
+
+**Finding**: Three design documents were located in `/docs/design/` root instead of version-specific subdirectories:
+- `media-display-fixes.md` (v1.2.0 content)
+- `v1.1.2-caption-alttext-update.md` (v1.1.2 content, marked as superseded)
+- `v1.2.0-media-css-design.md` (v1.2.0 content, marked as superseded)
+
+**Actions Taken**:
+1. Moved `media-display-fixes.md` β `v1.2.0/media-display-fixes.md`
+2. Moved `v1.1.2-caption-alttext-update.md` β `v1.1.2/caption-alttext-update.md`
+3. Moved `v1.2.0-media-css-design.md` β `v1.2.0/media-css-design.md`
+
+**Rationale**: Version-based organization improves discoverability and maintains clear historical record of design evolution.
+
+---
+
+### 3. Legacy Design Documents Organization
+
+**Status**: RESOLVED
+
+**Finding**: 31 design documents from v1.0.0 and v1.1.x development remained in `/docs/design/` root, including:
+- 19 phase-based documents from initial implementation (v1.0.0)
+- 9 hotfix and diagnostic documents (v1.1.1)
+- 3 feed enhancement documents (v1.1.2)
+
+**Actions Taken**:
+
+**Created Version Folders**:
+- `/docs/design/v1.0.0/` - For initial implementation (phase-based)
+- `/docs/design/v1.1.1/` - For authentication hotfix documents
+
+**Moved v1.0.0 Documents** (19 files):
+- All `phase-*.md` files (phase 1.1 through 5)
+- `initial-files.md`, `initial-schema-*.md`
+- `project-structure.md`
+- `micropub-endpoint-design.md`
+
+**Moved v1.1.1 Documents** (9 files):
+- `auth-redirect-loop-*.md` (diagnosis and fix)
+- `hotfix-v1.1.1-*.md`
+- `indieauth-pkce-authentication.md`
+- `token-security-migration.md`
+
+**Moved v1.1.2 Documents** (3 files):
+- `feed-media-handling-options.md`
+- `feed-media-option2-design.md`
+- `caption-alttext-update.md`
+
+**Result**: Only `INDEX.md` remains in `/docs/design/` root, which is correct and expected.
+
+---
+
+### 4. Design Documentation INDEX Update
+
+**Status**: COMPLETED
+
+**Finding**: The `/docs/design/INDEX.md` file referenced the old flat structure with phase documents in the root.
+
+**Actions Taken**:
+1. Rewrote INDEX.md to reflect version-based organization
+2. Added clear organization section listing all version folders
+3. Documented key design documents for each version
+4. Updated "How to Use" section for version-based navigation
+5. Updated "Document Types" to reflect current patterns
+6. Updated last-modified date to 2025-12-10
+
+**New Structure**:
+- Organization section with version folder listing
+- Version-specific sections (v1.0.0, v1.1.1, v1.1.2, v1.2.0)
+- Key documents highlighted for each version
+- Updated usage guidance for developers
+
+---
+
+### 5. ADR Numbering Sequence
+
+**Status**: VERIFIED - No Issues
+
+**Finding**: ADR sequence shows a gap: jumps from ADR-059 to ADR-061, missing ADR-060.
+
+**Investigation**:
+- ADR-059 references "Option 2 (ADR-060)" for Media RSS implementation
+- Media RSS was implemented in v1.2.0 (confirmed in CHANGELOG)
+- No separate ADR-060 document was created
+
+**Conclusion**: ADR-060 was planned but implementation happened without creating a separate ADR. The decision is adequately documented in ADR-059 itself, which describes both Option 2 (basic Media RSS) and Option 3 (full standardization). This is acceptable - not every decision requires a separate ADR when well-documented in a related ADR.
+
+**Recommendation**: If future work requires more detailed Media RSS decisions, create ADR-060 at that time. For now, ADR-059 provides sufficient documentation.
+
+---
+
+### 6. README.md Currency
+
+**Status**: UPDATED
+
+**Finding**: README.md showed version 1.1.0 but current version is 1.2.0. Major v1.2.0 features were not documented.
+
+**Actions Taken**:
+
+**Version Updates**:
+- Changed "Current Version: 1.1.0" β "1.2.0"
+- Updated versioning section to reflect current 1.2.0 stable release
+
+**Features Section Enhancements**:
+Added new v1.2.0 features:
+- Media attachments (image upload and display)
+- Microformats2 (full h-entry, h-card, h-feed markup)
+- Author discovery (automatic profile discovery)
+- RSS, ATOM, JSON Feed (multiple syndication formats)
+- Custom slugs (permalink control)
+
+Updated from "6 core dependencies" to "Core dependencies" (more accurate, less maintenance burden).
+
+**IndieWeb Compliance Section**:
+Enhanced to include:
+- "RSS 2.0 with Media RSS extensions" (was just "RSS 2.0")
+- Added "ATOM 1.0 - Syndication format"
+- Added "JSON Feed 1.1 - Modern feed format"
+- Enhanced Microformats2 description to mention h-entry, h-card, h-feed
+
+**Result**: README now accurately reflects v1.2.0 capabilities and feature set.
+
+---
+
+### 7. Reports Folder Organization
+
+**Status**: EXCELLENT - No Action Required
+
+**Finding**: The `/docs/reports/` folder is well-organized with 76 implementation reports.
+
+**Observations**:
+- All reports follow naming convention: `YYYY-MM-DD-description.md`
+- Clear chronological ordering (oldest: 2025-11-18, newest: 2025-12-09)
+- Version-tagged reports (e.g., `v1.2.0-phase1-custom-slugs.md`)
+- One untracked file: `2025-12-09-v1.2.0-release.md` (appropriate for v1.2.0)
+
+**Action**: Renamed one report for consistency:
+- `2025-11-28-media-display-fixes.md` β `2025-11-28-v1.2.0-media-display-fixes.md`
+
+**Assessment**: Reports folder follows best practices and needs no further cleanup.
+
+---
+
+### 8. Superseded Documents
+
+**Status**: VERIFIED
+
+**Finding**: Several documents marked as "Superseded" were found:
+- `v1.1.2/caption-alttext-update.md` - Superseded by `media-display-fixes.md`
+- `v1.2.0/media-css-design.md` - Superseded by `media-display-fixes.md`
+- Various ADRs with superseded status headers
+
+**Assessment**:
+- Superseded documents are properly marked with status headers
+- They are retained for historical context (correct approach)
+- They are now organized in version folders (improves discoverability)
+- Cross-references to superseding documents are present
+
+**Action**: No action required. Superseded documents are properly handled.
+
+---
+
+## Documentation Organization Summary
+
+### Repository Root
+```
+/
+βββ README.md β Updated to v1.2.0
+βββ CLAUDE.md β Current
+βββ CHANGELOG.md β Current
+```
+
+### Design Documentation Structure
+```
+docs/design/
+βββ INDEX.md β Updated for version-based structure
+βββ v1.0.0/ β 19 documents (initial implementation)
+βββ v1.1.1/ β 9 documents (hotfix)
+βββ v1.1.2/ β 10 documents (feed enhancements)
+βββ v1.2.0/ β 6 documents (media and IndieWeb)
+```
+
+### ADR Status
+- Total ADRs: 56 (ADR-001 through ADR-061, excluding ADR-060)
+- Gap at ADR-060: Acceptable (documented in ADR-059)
+- All ADRs properly numbered and sequenced
+- Superseded ADRs have status headers
+
+### Reports Status
+- Total reports: 76 implementation reports
+- All follow naming convention: `YYYY-MM-DD-description.md`
+- Date range: 2025-11-18 to 2025-12-10
+- Well-organized, chronologically ordered
+
+---
+
+## Git Changes Summary
+
+The following files were moved/renamed using `git mv` to preserve history:
+
+**Design Document Relocations** (34 files):
+- 19 files β `docs/design/v1.0.0/`
+- 9 files β `docs/design/v1.1.1/`
+- 3 files β `docs/design/v1.1.2/`
+- 3 files β `docs/design/v1.2.0/`
+
+**Report Rename** (1 file):
+- `2025-11-28-media-display-fixes.md` β `2025-11-28-v1.2.0-media-display-fixes.md`
+
+**Documentation Updates** (2 files):
+- `README.md` - Version and features updated
+- `docs/design/INDEX.md` - Complete restructure for version-based organization
+
+**Total Changes**: 37 file operations + 2 content updates
+
+---
+
+## Recommendations
+
+### Immediate Actions
+None required. All issues have been resolved.
+
+### Future Maintenance
+
+1. **Design Document Discipline**
+ - Always create new design docs in appropriate version folder
+ - Use version prefixes in filenames for cross-version documents
+ - Update INDEX.md when adding new version folders
+
+2. **ADR Management**
+ - Continue sequential numbering (next: ADR-062)
+ - Consider creating ADR-060 if Media RSS needs detailed decision doc
+ - Always mark superseded ADRs with status headers
+
+3. **README Maintenance**
+ - Update version number on each release
+ - Add new features to features section
+ - Keep IndieWeb compliance section current
+
+4. **Reports Best Practices**
+ - Continue using `YYYY-MM-DD-description.md` format
+ - Include version prefix for version-specific work
+ - Create reports for all significant implementations
+
+### Documentation Health Indicators
+
+Monitor these metrics to maintain documentation quality:
+
+- **Root Cleanliness**: Only README.md, CLAUDE.md, CHANGELOG.md in root
+- **Design Organization**: All design docs in version folders (except INDEX.md)
+- **ADR Sequence**: Sequential numbering with documented gaps
+- **Report Consistency**: All reports follow naming convention
+- **README Currency**: Version and features match current release
+
+---
+
+## Conclusion
+
+The StarPunk documentation is now in excellent health following the v1.2.0 release. All structural issues have been resolved, historical documents are properly organized by version, and the README accurately reflects current capabilities.
+
+The version-based organization of design documents provides a clear historical record and improves discoverability. The reports folder demonstrates excellent discipline with consistent naming and comprehensive coverage of implementation work.
+
+**Documentation Health Score**: A+ (Excellent)
+
+**Ready for v1.3.0 Development**: Yes
+
+---
+
+**Audit Completed**: 2025-12-10
+**Maintained By**: Documentation Manager Agent
+**Next Audit Recommended**: After v1.3.0 release
diff --git a/docs/design/v1.2.0/developer-qa.md b/docs/design/v1.2.0/developer-qa.md
deleted file mode 100644
index 4ca5816..0000000
--- a/docs/design/v1.2.0/developer-qa.md
+++ /dev/null
@@ -1,303 +0,0 @@
-# v1.2.0 Developer Q&A
-
-**Date**: 2025-11-28
-**Architect**: StarPunk Architect Subagent
-**Purpose**: Answer critical implementation questions for v1.2.0
-
-## Custom Slugs Answers
-
-**Q1: Validation pattern conflict - should we apply new lowercase validation to existing slugs?**
-- **Answer:** Validate only new custom slugs, don't migrate existing slugs
-- **Rationale:** Existing slugs work, no need to change them retroactively
-- **Implementation:** In `validate_and_sanitize_custom_slug()`, apply lowercase enforcement only to new/edited slugs
-
-**Q2: Form field readonly behavior - how should the slug field behave on edit forms?**
-- **Answer:** Display as readonly input field with current value visible
-- **Rationale:** Users need to see the current slug but understand it cannot be changed
-- **Implementation:** Use `readonly` attribute, not `disabled` (disabled fields don't submit with form)
-
-**Q3: Slug uniqueness validation - where should this happen?**
-- **Answer:** Both client-side (for UX) and server-side (for security)
-- **Rationale:** Client-side prevents unnecessary submissions, server-side is authoritative
-- **Implementation:** Database unique constraint + Python validation in `validate_and_sanitize_custom_slug()`
-
-## Media Upload Answers
-
-**Q4: Media upload flow - how should upload and note association work?**
-- **Answer:** Upload during note creation, associate via note_id after creation
-- **Rationale:** Simpler than pre-upload with temporary IDs
-- **Implementation:** Upload files in `create_note_submit()` after note is created, store associations in media table
-
-**Q5: Storage directory structure - exact path format?**
-- **Answer:** `data/media/YYYY/MM/filename-uuid.ext`
-- **Rationale:** Date organization helps with backups and management
-- **Implementation:** Use `os.makedirs(path, exist_ok=True)` to create directories as needed
-
-**Q6: File naming convention - how to ensure uniqueness?**
-- **Answer:** `{original_name_slug}-{uuid4()[:8]}.{extension}`
-- **Rationale:** Preserves original name for SEO while ensuring uniqueness
-- **Implementation:** Slugify original filename, append 8-char UUID, preserve extension
-
-**Q7: MIME type validation - which types exactly?**
-- **Answer:** Allow: image/jpeg, image/png, image/gif, image/webp. Reject all others
-- **Rationale:** Common web formats only, no SVG (XSS risk)
-- **Implementation:** Use python-magic for reliable MIME detection, not just file extension
-
-**Q8: Upload size limits - what's reasonable?**
-- **Answer:** 10MB per file, 40MB total per note (4 files Γ 10MB)
-- **Rationale:** Sufficient for high-quality images without overwhelming storage
-- **Implementation:** Check in both client-side JavaScript and server-side validation
-
-**Q9: Database schema for media table - exact columns?**
-- **Answer:** id, note_id, filename, mime_type, size_bytes, width, height, uploaded_at
-- **Rationale:** Minimal but sufficient metadata for display and management
-- **Implementation:** Use Pillow to extract image dimensions on upload
-
-**Q10: Orphaned file cleanup - how to handle?**
-- **Answer:** Keep orphaned files, add admin cleanup tool in future version
-- **Rationale:** Data preservation is priority, cleanup can be manual for v1.2.0
-- **Implementation:** Log orphaned files but don't auto-delete
-
-**Q11: Upload progress indication - required for v1.2.0?**
-- **Answer:** No, simple form submission is sufficient for v1.2.0
-- **Rationale:** Keep it simple, can enhance in future version
-- **Implementation:** Standard HTML form with enctype="multipart/form-data"
-
-**Q12: Image display order - how to maintain?**
-- **Answer:** Use upload sequence, store display_order in media table
-- **Rationale:** Predictable and simple
-- **Implementation:** Auto-increment display_order starting at 0
-
-**Q13: Thumbnail generation - needed for v1.2.0?**
-- **Answer:** No, use CSS for responsive sizing
-- **Rationale:** Simplicity over optimization for v1
-- **Implementation:** Use `max-width: 100%` and lazy loading
-
-**Q14: Edit form media handling - can users remove media?**
-- **Answer:** Yes, checkbox to mark for deletion
-- **Rationale:** Essential editing capability
-- **Implementation:** "Remove" checkboxes next to each image in edit form
-
-**Q15: Media URL structure - exact format?**
-- **Answer:** `/media/YYYY/MM/filename.ext` (matches storage path)
-- **Rationale:** Clean URLs, date organization visible
-- **Implementation:** Route in `starpunk/routes/public.py` using send_from_directory
-
-## Author Discovery Answers
-
-**Q16: Discovery failure handling - what if profile URL is unreachable?**
-- **Answer:** Use defaults: name from IndieAuth me URL domain, no photo
-- **Rationale:** Always provide something, never break
-- **Implementation:** Try discovery, catch all exceptions, use defaults
-
-**Q17: h-card parsing library - which one?**
-- **Answer:** Use mf2py (already in requirements for Micropub)
-- **Rationale:** Already a dependency, well-maintained
-- **Implementation:** `import mf2py; result = mf2py.parse(url=profile_url)`
-
-**Q18: Multiple h-cards on profile - which to use?**
-- **Answer:** First h-card with url property matching the profile URL
-- **Rationale:** Most specific match per IndieWeb convention
-- **Implementation:** Loop through h-cards, check url property
-
-**Q19: Discovery caching duration - how long?**
-- **Answer:** 24 hours, with manual refresh button in admin
-- **Rationale:** Balance between freshness and performance
-- **Implementation:** Store discovered_at timestamp, check age
-
-**Q20: Profile update mechanism - when to refresh?**
-- **Answer:** On login + manual refresh button + 24hr expiry
-- **Rationale:** Login is natural refresh point
-- **Implementation:** Call discovery in auth callback
-
-**Q21: Missing properties handling - what if no name/photo?**
-- **Answer:** name = domain from URL, photo = None (no image)
-- **Rationale:** Graceful degradation
-- **Implementation:** Use get() with defaults on parsed properties
-
-**Q22: Database schema for author_profile - exact columns?**
-- **Answer:** me_url (PK), name, photo, url, discovered_at, raw_data (JSON)
-- **Rationale:** Cache parsed data + raw for debugging
-- **Implementation:** Single row table, upsert on discovery
-
-## Microformats2 Answers
-
-**Q23: h-card placement - where exactly in templates?**
-- **Answer:** Only within h-entry author property (p-author h-card)
-- **Rationale:** Correct semantic placement per spec
-- **Implementation:** In note partial template, not standalone
-
-**Q24: h-feed container - which pages need it?**
-- **Answer:** Homepage (/) and any paginated list pages
-- **Rationale:** Feed pages only, not single note pages
-- **Implementation:** Wrap note list in div.h-feed with h1.p-name
-
-**Q25: Optional properties - which to include?**
-- **Answer:** Only what we have: author, name, url, published, content
-- **Rationale:** Don't add empty properties
-- **Implementation:** Use conditional template blocks
-
-**Q26: Micropub compatibility - any changes needed?**
-- **Answer:** No, Micropub already handles microformats correctly
-- **Rationale:** Micropub creates data, templates display it
-- **Implementation:** Ensure templates match Micropub's data model
-
-## Feed Integration Answers
-
-**Q27: RSS/Atom changes for media - how to include images?**
-- **Answer:** Add as enclosures (RSS) and link rel="enclosure" (Atom)
-- **Rationale:** Standard podcast/media pattern
-- **Implementation:** Loop through note.media, add enclosure elements
-
-**Q28: JSON Feed media handling - which property?**
-- **Answer:** Use "attachments" array per JSON Feed 1.1 spec
-- **Rationale:** Designed for exactly this use case
-- **Implementation:** Create attachment objects with url, mime_type
-
-**Q29: Feed caching - any changes needed?**
-- **Answer:** No, existing cache logic is sufficient
-- **Rationale:** Media URLs are stable once uploaded
-- **Implementation:** No changes required
-
-**Q30: Author in feeds - use discovered data?**
-- **Answer:** Yes, use discovered name and photo in feed metadata
-- **Rationale:** Consistency across all outputs
-- **Implementation:** Pass author_profile to feed templates
-
-## Database Migration Answers
-
-**Q31: Migration naming convention - what number?**
-- **Answer:** Use next sequential: 005_add_media_support.sql
-- **Rationale:** Continue existing pattern
-- **Implementation:** Check latest migration, increment
-
-**Q32: Migration rollback - needed?**
-- **Answer:** No, forward-only migrations per project convention
-- **Rationale:** Simplicity, follows existing pattern
-- **Implementation:** CREATE IF NOT EXISTS, never DROP
-
-**Q33: Migration testing - how to verify?**
-- **Answer:** Test on copy of production database
-- **Rationale:** Real-world data is best test
-- **Implementation:** Copy data/starpunk.db, run migration, verify
-
-## Testing Strategy Answers
-
-**Q34: Test data for media - what to use?**
-- **Answer:** Generate 1x1 pixel PNG in tests, don't use real files
-- **Rationale:** Minimal, fast, no binary files in repo
-- **Implementation:** Use Pillow to generate test images in memory
-
-**Q35: Author discovery mocking - how to test?**
-- **Answer:** Mock HTTP responses with test h-card HTML
-- **Rationale:** Deterministic, no external dependencies
-- **Implementation:** Use responses library or unittest.mock
-
-**Q36: Integration test priority - which are critical?**
-- **Answer:** Upload β Display β Edit β Delete flow
-- **Rationale:** Core user journey must work
-- **Implementation:** Single test that exercises full lifecycle
-
-## Error Handling Answers
-
-**Q37: Upload failure recovery - how to handle?**
-- **Answer:** Show error, preserve form data, allow retry
-- **Rationale:** Don't lose user's work
-- **Implementation:** Flash error, return to form with content preserved
-
-**Q38: Discovery network timeout - how long to wait?**
-- **Answer:** 5 second timeout for profile fetch
-- **Rationale:** Balance between patience and responsiveness
-- **Implementation:** Use requests timeout parameter
-
-## Deployment Answers
-
-**Q39: Media directory permissions - what's needed?**
-- **Answer:** data/media/ needs write permission for app user
-- **Rationale:** Same as existing data/ directory
-- **Implementation:** Document in deployment guide, create in setup
-
-**Q40: Upgrade path from v1.1.2 - any special steps?**
-- **Answer:** Run migration, create media directory, restart app
-- **Rationale:** Minimal disruption
-- **Implementation:** Add to CHANGELOG upgrade notes
-
-**Q41: Configuration changes - any new env vars?**
-- **Answer:** No, all settings have sensible defaults
-- **Rationale:** Maintain zero-config philosophy
-- **Implementation:** Hardcode limits in code with constants
-
-## Critical Path Decisions Summary
-
-These are the key decisions to unblock implementation:
-
-1. **Media upload flow**: Upload after note creation, associate via note_id
-2. **Author discovery**: Use mf2py, cache for 24hrs, graceful fallbacks
-3. **h-card parsing**: First h-card with matching URL property
-4. **h-card placement**: Only within h-entry as p-author
-5. **Migration strategy**: Sequential numbering (005), forward-only
-
-## Implementation Order
-
-Based on dependencies and complexity:
-
-### Phase 1: Custom Slugs (2 hours)
-- Simplest feature
-- No database changes
-- Template and validation only
-
-### Phase 2: Author Discovery (4 hours)
-- Build discovery module
-- Add author_profile table
-- Integrate with auth flow
-- Update templates
-
-### Phase 3: Media Upload (6 hours)
-- Most complex feature
-- Media table and migration
-- Upload handling
-- Template updates
-- Storage management
-
-## File Structure
-
-Key files to create/modify:
-
-### New Files
-- `starpunk/discovery.py` - Author discovery module
-- `starpunk/media.py` - Media handling module
-- `migrations/005_add_media_support.sql` - Database changes
-- `static/js/media-upload.js` - Optional enhancement
-
-### Modified Files
-- `templates/admin/new.html` - Add slug and media fields
-- `templates/admin/edit.html` - Add slug (readonly) and media
-- `templates/partials/note.html` - Add microformats markup
-- `templates/public/index.html` - Add h-feed container
-- `starpunk/routes/admin.py` - Handle slugs and uploads
-- `starpunk/routes/auth.py` - Trigger discovery on login
-- `starpunk/models/note.py` - Add media relationship
-
-## Success Metrics
-
-Implementation is complete when:
-
-1. β Custom slug can be specified on creation
-2. β Images can be uploaded and displayed
-3. β Author info is discovered from IndieAuth profile
-4. β IndieWebify.me validates h-feed and h-entry
-5. β All tests pass
-6. β No regressions in existing functionality
-7. β Media files are tracked in database
-8. β Errors are handled gracefully
-
-## Final Notes
-
-- Keep it simple - this is v1.2.0, not v2.0.0
-- Data preservation over premature optimization
-- When uncertain, choose the more explicit option
-- Document any deviations from this guidance
-
----
-
-This Q&A document serves as the authoritative implementation guide for v1.2.0. Any questions not covered here should follow the principle of maximum simplicity.
\ No newline at end of file
diff --git a/docs/design/v1.2.0/feature-specification.md b/docs/design/v1.2.0/feature-specification.md
deleted file mode 100644
index 200e964..0000000
--- a/docs/design/v1.2.0/feature-specification.md
+++ /dev/null
@@ -1,872 +0,0 @@
-# v1.2.0 Feature Specification
-
-## Overview
-
-Version 1.2.0 focuses on three essential improvements to the StarPunk web interface:
-1. Custom slug support in the web UI
-2. Media upload capability (web UI only, not Micropub)
-3. Complete Microformats2 implementation
-
-## Feature 1: Custom Slugs in Web UI
-
-### Current State
-- Slugs are auto-generated from the first line of content
-- Custom slugs only possible via Micropub API (mp-slug property)
-- Web UI has no option to specify custom slugs
-
-### Requirements
-- Add optional "Slug" field to note creation form
-- Validate slug format (URL-safe, unique)
-- If empty, fall back to auto-generation
-- Support custom slugs in edit form as well
-
-### Design Specification
-
-#### Form Updates
-Location: `templates/admin/new.html` and `templates/admin/edit.html`
-
-Add new form field:
-```html
-
-
-
- URL-safe characters only (lowercase letters, numbers, hyphens)
- {% if editing %}
- Slugs cannot be changed after creation to preserve permalinks
- {% endif %}
-
-```
-
-#### Backend Changes
-Location: `starpunk/routes/admin.py`
-
-Modify `create_note_submit()`:
-- Extract slug from form data
-- Pass to `create_note()` as `custom_slug` parameter
-- Handle validation errors
-
-Modify `edit_note_submit()`:
-- Display current slug as read-only
-- Do NOT allow slug updates (prevent broken permalinks)
-
-#### Validation Rules
-- Must be URL-safe: `^[a-z0-9-]+$`
-- Maximum length: 200 characters
-- Must be unique (database constraint)
-- Empty string = auto-generate
-- **Read-only after creation** (no editing allowed)
-
-### Acceptance Criteria
-- [ ] Slug field appears in create note form
-- [ ] Slug field appears in edit note form
-- [ ] Custom slugs are validated for format
-- [ ] Custom slugs are validated for uniqueness
-- [ ] Empty field triggers auto-generation
-- [ ] Error messages are user-friendly
-
----
-
-## Feature 2: Media Upload (Web UI Only)
-
-### Current State
-- No media upload capability
-- Notes are text/markdown only
-- No file storage infrastructure
-
-### Requirements
-- Upload images when creating/editing notes
-- Store uploaded files locally
-- Display media at top of note (social media style)
-- Support multiple media per note
-- Basic file validation
-- NOT implementing Micropub media endpoint (future version)
-
-### Design Specification
-
-#### Conceptual Model
-Media attachments work like social media posts (Twitter, Mastodon, etc.):
-- Media is displayed at the TOP of the note when published
-- Text content appears BELOW the media
-- Multiple images can be attached to a single note (maximum 4)
-- Media is stored as attachments, not inline markdown
-- Display order is upload order (no reordering interface)
-- Each image can have an optional caption for accessibility
-
-#### Storage Architecture
-```
-data/
- media/
- 2025/
- 01/
- image-slug-12345.jpg
- another-image-67890.png
-```
-
-URL Structure: `/media/2025/01/filename.jpg` (date-organized paths)
-
-#### Database Schema
-
-**Option A: Junction Table (RECOMMENDED)**
-```sql
--- Media files table
-CREATE TABLE media (
- id INTEGER PRIMARY KEY,
- filename TEXT NOT NULL,
- original_name TEXT NOT NULL,
- path TEXT NOT NULL UNIQUE,
- mime_type TEXT NOT NULL,
- size INTEGER NOT NULL,
- width INTEGER, -- Image dimensions for responsive display
- height INTEGER,
- uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
--- Note-media relationship table
-CREATE TABLE note_media (
- id INTEGER PRIMARY KEY,
- note_id INTEGER NOT NULL,
- media_id INTEGER NOT NULL,
- display_order INTEGER NOT NULL DEFAULT 0,
- caption TEXT, -- Optional alt text/caption
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
- FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
- UNIQUE(note_id, media_id)
-);
-
-CREATE INDEX idx_note_media_note ON note_media(note_id);
-CREATE INDEX idx_note_media_order ON note_media(note_id, display_order);
-```
-
-**Rationale**: Junction table provides flexibility for:
-- Multiple media per note with ordering
-- Reusing media across notes (future)
-- Per-attachment metadata (captions)
-- Efficient queries for syndication feeds
-
-#### Display Strategy
-
-**Note Rendering**:
-```html
-
-
- {% if note.media %}
-
- {% for media in note.media %}
-
- {% endfor %}
-
- {% else %}
-
-
- {% for media in note.media[:4] %}
-
- {% endfor %}
-
- {% endif %}
-
- {% endif %}
-
-
-
- {{ note.html|safe }}
-
-
-```
-
-#### Upload Flow
-1. User selects multiple files via HTML file input
-2. Files validated (type, size)
-3. Files saved to `data/media/YYYY/MM/` with generated names
-4. Database records created in `media` table
-5. Associations created in `note_media` table
-6. Media displayed as thumbnails below textarea
-7. User can remove or reorder attachments
-
-#### Form Updates
-Location: `templates/admin/new.html` and `templates/admin/edit.html`
-
-```html
-
-
-
-```
-
-#### Backend Implementation
-Location: New module `starpunk/media.py`
-
-Key functions:
-- `validate_media_file(file)` - Check type, size (max 10MB), dimensions (max 4096x4096)
-- `optimize_image(file)` - Resize if >2048px, correct EXIF orientation (using Pillow)
-- `save_media_file(file)` - Store optimized version to disk with date-based path
-- `generate_media_url(filename)` - Create public URL
-- `track_media_upload(metadata)` - Save to database
-- `attach_media_to_note(note_id, media_ids, captions)` - Create note-media associations with captions
-- `get_media_by_note(note_id)` - List media for a note ordered by display_order
-- `extract_image_dimensions(file)` - Get width/height for storage
-
-Image Processing with Pillow:
-```python
-from PIL import Image, ImageOps
-
-def optimize_image(file_obj):
- """Optimize image for web display."""
- img = Image.open(file_obj)
-
- # Correct EXIF orientation
- img = ImageOps.exif_transpose(img)
-
- # Check dimensions
- if max(img.size) > 4096:
- raise ValueError("Image dimensions exceed 4096x4096")
-
- # Resize if needed (preserve aspect ratio)
- if max(img.size) > 2048:
- img.thumbnail((2048, 2048), Image.Resampling.LANCZOS)
-
- return img
-```
-
-#### Routes
-Location: `starpunk/routes/public.py`
-
-Add route to serve media:
-```python
-@bp.route('/media///')
-def serve_media(year, month, filename):
- # Serve file from data/media/YYYY/MM/
- # Set appropriate cache headers
-```
-
-Location: `starpunk/routes/admin.py`
-
-Add upload endpoint:
-```python
-@bp.route('/admin/upload', methods=['POST'])
-@require_auth
-def upload_media():
- # Handle AJAX upload, return JSON with URL and media_id
- # Store in media table, return metadata
-```
-
-#### Syndication Feed Support
-
-**RSS 2.0 Strategy**:
-```xml
-
-
- Note Title
-
-
-
-
-
-
Note text content here...
-
- ]]>
- ...
-
-```
-Rationale: RSS `` only supports single items and is meant for podcasts/downloads. HTML in description is standard for blog posts with images.
-
-**ATOM 1.0 Strategy**:
-```xml
-
-
- Note Title
-
-
-
- <div class="media">
- <img src="https://site.com/media/2025/01/image1.jpg" />
- <img src="https://site.com/media/2025/01/image2.jpg" />
- </div>
- <div>Note text content...</div>
-
-
-```
-Rationale: ATOM supports multiple `` elements. We include both enclosures (for feed readers that understand them) AND HTML content (for universal display).
-
-**JSON Feed 1.1 Strategy**:
-```json
-{
- "id": "...",
- "title": "Note Title",
- "content_html": "
...
Note text...
",
- "attachments": [
- {
- "url": "https://site.com/media/2025/01/image1.jpg",
- "mime_type": "image/jpeg",
- "size_in_bytes": 123456
- },
- {
- "url": "https://site.com/media/2025/01/image2.jpg",
- "mime_type": "image/jpeg",
- "size_in_bytes": 234567
- }
- ]
-}
-```
-Rationale: JSON Feed has native support for multiple attachments! This is the cleanest implementation.
-
-**Feed Generation Updates**:
-- Modify `generate_rss()` to prepend media HTML to content
-- Modify `generate_atom()` to add `` elements
-- Modify `generate_json_feed()` to populate `attachments` array
-- Query `note_media` JOIN `media` when generating feeds
-
-#### Security Considerations
-- Validate MIME types server-side (JPEG, PNG, GIF, WebP only)
-- Reject files over 10MB (before processing)
-- Limit total uploads (4 images max per note)
-- Sanitize filenames (remove special characters, use slugify)
-- Prevent directory traversal attacks
-- Add rate limiting to upload endpoint
-- Validate image dimensions (max 4096x4096, reject if larger)
-- Use Pillow to verify file integrity (corrupted files will fail to open)
-- Resize images over 2048px to prevent memory issues
-- Strip potentially harmful EXIF data during optimization
-
-### Acceptance Criteria
-- [ ] Multiple file upload field in create/edit forms
-- [ ] Images saved to data/media/ directory after optimization
-- [ ] Media-note associations tracked in database with captions
-- [ ] Media displayed at TOP of notes
-- [ ] Text content displayed BELOW media
-- [ ] Media served at /media/YYYY/MM/filename
-- [ ] File type validation (JPEG, PNG, GIF, WebP only)
-- [ ] File size validation (10MB max, checked before processing)
-- [ ] Image dimension validation (4096x4096 max)
-- [ ] Automatic resize for images over 2048px
-- [ ] EXIF orientation correction during processing
-- [ ] Max 4 images per note enforced
-- [ ] Caption field for each uploaded image
-- [ ] Captions used as alt text in HTML
-- [ ] Media appears in RSS feeds (HTML in description)
-- [ ] Media appears in ATOM feeds (enclosures + HTML)
-- [ ] Media appears in JSON feeds (attachments array)
-- [ ] User can remove attached images
-- [ ] Display order matches upload order (no reordering UI)
-- [ ] Error handling for invalid/oversized/corrupted files
-
----
-
-## Feature 3: Complete Microformats2 Support
-
-### Current State
-- Basic h-entry on note pages
-- Basic h-feed on index
-- Missing h-card (author info)
-- Missing many microformats properties
-- No rel=me links
-
-### Requirements
-Full compliance with Microformats2 specification:
-- Complete h-entry implementation
-- Author h-card on all pages
-- Proper h-feed structure
-- rel=me for identity verification
-- All relevant properties marked up
-
-### Design Specification
-
-#### Author Discovery System
-When a user authenticates via IndieAuth, we discover their author information from their profile URL:
-
-1. **Discovery Process** (runs during login):
- - User logs in with IndieAuth using their domain (e.g., https://user.example.com)
- - System fetches the user's profile page
- - Parses h-card microformats from the profile
- - Extracts: name, photo, bio/note, rel-me links
- - Caches author info in database (new `author_profile` table)
-
-2. **Database Schema** for Author Profile:
-```sql
-CREATE TABLE author_profile (
- id INTEGER PRIMARY KEY,
- me_url TEXT NOT NULL UNIQUE, -- The IndieAuth 'me' URL
- name TEXT, -- From h-card p-name
- photo TEXT, -- From h-card u-photo
- bio TEXT, -- From h-card p-note
- rel_me_links TEXT, -- JSON array of rel-me URLs
- discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-```
-
-3. **Caching Strategy**:
- - Cache on first login
- - Refresh on each login (but use cache if discovery fails)
- - Manual refresh button in admin settings
- - Cache expires after 7 days (configurable)
-
-4. **Fallback Behavior**:
- - If discovery fails, use cached data if available
- - If no cache and discovery fails, use minimal defaults:
- - Name: Domain name (e.g., "user.example.com")
- - Photo: None (gracefully degrade)
- - Bio: None
- - Log discovery failures for debugging
-
-#### h-card (Author Information)
-Location: `templates/partials/author.html` (new)
-
-Required properties from discovered profile:
-- p-name (author name from discovery)
-- u-url (author URL from ADMIN_ME)
-- u-photo (avatar from discovery, optional)
-
-```html
-
+{% endif %}
+```
+
+---
+
+## 5. u-photo Placement Fix
+
+### 5.1 Issue
+
+Per the draft spec, `u-photo` should be a direct child of `h-entry`, not nested inside `e-content`. The current implementation places photos correctly via the `display_media` macro, but we need to verify.
+
+### 5.2 Current State
+
+`templates/partials/media.html`:
+```html
+{% macro display_media(media_items) %}
+ {% if media_items %}
+
+ {% for item in media_items %}
+
+
+
+ {% endfor %}
+
+ {% endif %}
+{% endmacro %}
+```
+
+`templates/note.html`:
+```html
+
+ {# ... #}
+
+ {# Media display at TOP (v1.2.0 Phase 3, per ADR-057) #}
+ {{ display_media(note.media) }}
+
+ {# e-content: note content BELOW media (per ADR-057) #}
+
+ {{ note.html|safe }}
+
+```
+
+### 5.3 Analysis
+
+**Current behavior is CORRECT**: The `display_media` macro is called BEFORE the `e-content` div, meaning `u-photo` images are siblings of `e-content`, not children. This meets the draft spec requirement.
+
+### 5.4 Required Changes
+
+**None** - Current implementation is correct. Add a comment to document this:
+
+```html
+{# u-photo placement: Per draft spec, u-photo must be direct child of h-entry, #}
+{# NOT inside e-content. Media is rendered ABOVE e-content to meet this requirement. #}
+{{ display_media(note.media) }}
+```
+
+---
+
+## 6. p-category Template Implementation
+
+### 6.1 Note Page (`templates/note.html`)
+
+Add p-category markup for tags with `rel="tag"` per microformats2 specification:
+
+```html
+
+ {# ... existing markup ... #}
+
+
+
+```
+
+### 6.2 Index Page (`templates/index.html`)
+
+Add p-category to note previews with `rel="tag"`:
+
+```html
+{% for note in notes %}
+
+ {# ... existing markup ... #}
+
+
+
+{% endfor %}
+```
+
+### 6.3 Tag Archive Route
+
+Add new route to `starpunk/routes/public.py`.
+
+**URL Pattern**: Use `/tag/` (singular) - consistent with `/note/`.
+
+**No pagination** for v1.3.0 - show all notes with the tag. Pagination can be added in a future version if needed.
+
+```python
+@bp.route("/tag/")
+def tag(tag: str):
+ """
+ Tag archive page
+
+ Lists all notes with a specific tag.
+
+ Args:
+ tag: Tag name (will be normalized before lookup)
+
+ Returns:
+ Rendered tag archive template
+
+ Raises:
+ 404: If tag doesn't exist
+
+ Note:
+ URL accepts any format - normalized before lookup.
+ /tag/IndieWeb and /tag/indieweb resolve to same tag.
+ """
+ from starpunk.tags import get_notes_by_tag, get_tag_by_name, normalize_tag
+ from starpunk.media import get_note_media
+
+ # Normalize the tag name before lookup
+ normalized_name, _ = normalize_tag(tag)
+
+ tag_info = get_tag_by_name(normalized_name)
+ if not tag_info:
+ abort(404)
+
+ notes = get_notes_by_tag(normalized_name)
+
+ # Attach media and tags to each note
+ for note in notes:
+ media = get_note_media(note.id)
+ object.__setattr__(note, 'media', media)
+ # Tags already loaded via get_notes_by_tag
+
+ return render_template(
+ "tag.html",
+ tag=tag_info,
+ notes=notes
+ )
+```
+
+### 6.4 New Template: `templates/tag.html`
+
+```html
+{% extends "base.html" %}
+{% from "partials/media.html" import display_media %}
+
+{% block title %}{{ tag.display_name }} - StarPunk{% endblock %}
+
+{% block content %}
+
+
Notes tagged "{{ tag.display_name }}"
+
+ {% if notes %}
+ {% for note in notes %}
+
+ {# Same structure as index.html note previews #}
+ {# ... #}
+
+ {% endfor %}
+ {% else %}
+
No notes with this tag.
+ {% endif %}
+
+{% endblock %}
+```
+
+---
+
+## 7. Loading Tags in Routes
+
+### 7.1 Updates to `starpunk/routes/public.py`
+
+#### Index Route
+
+```python
+@bp.route("/")
+def index():
+ from starpunk.media import get_note_media
+ from starpunk.tags import get_note_tags
+
+ notes = list_notes(published_only=True, limit=20)
+
+ for note in notes:
+ # Attach media
+ media = get_note_media(note.id)
+ object.__setattr__(note, 'media', media)
+
+ # Attach tags
+ tags = get_note_tags(note.id)
+ object.__setattr__(note, '_cached_tags', tags)
+
+ return render_template("index.html", notes=notes)
+```
+
+#### Note Route
+
+```python
+@bp.route("/note/")
+def note(slug: str):
+ from starpunk.media import get_note_media
+ from starpunk.tags import get_note_tags
+
+ note_obj = get_note(slug=slug)
+
+ if not note_obj or not note_obj.published:
+ abort(404)
+
+ # Attach media
+ media = get_note_media(note_obj.id)
+ object.__setattr__(note_obj, 'media', media)
+
+ # Attach tags
+ tags = get_note_tags(note_obj.id)
+ object.__setattr__(note_obj, '_cached_tags', tags)
+
+ return render_template("note.html", note=note_obj)
+```
+
+---
+
+## 8. Admin Interface Updates
+
+### 8.1 Edit Form (`templates/admin/edit.html`)
+
+Add tag input field (plain text, no autocomplete for v1.3.0):
+
+```html
+
+
+
+ Separate multiple tags with commas
+
+```
+
+### 8.2 Admin Routes
+
+Update `starpunk/routes/admin.py` to handle tags on create/edit.
+
+**Tag parsing in routes**:
+```python
+from starpunk.tags import parse_tag_input
+
+# In create_note_submit() and update_note_submit():
+tags_input = request.form.get('tags', '')
+tags = parse_tag_input(tags_input) # Handles comma-split, trim, dedupe
+
+# Pass to create_note/update_note:
+note = create_note(content=content, published=published, tags=tags if tags else None)
+```
+
+**Empty tag field behavior**: An empty tag field removes all tags from the note. This is consistent with user expectations - clearing the field means "no tags."
+
+---
+
+## 9. Validation Test Plan
+
+### 9.1 Automated Testing with mf2py
+
+Create `tests/test_microformats.py`:
+
+```python
+"""
+Microformats2 validation tests using mf2py
+
+Per user requirements: Validate with mf2py
+"""
+
+import mf2py
+import pytest
+from starpunk import create_app
+
+
+class TestMicroformats:
+ """Test microformats2 markup compliance"""
+
+ def test_index_has_hfeed(self, client):
+ """h-feed exists on index page"""
+ response = client.get('/')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hfeeds = [i for i in parsed['items'] if 'h-feed' in i.get('type', [])]
+ assert len(hfeeds) == 1
+
+ def test_hfeed_has_required_properties(self, client):
+ """h-feed has name, author, url per spec"""
+ response = client.get('/')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hfeed = [i for i in parsed['items'] if 'h-feed' in i.get('type', [])][0]
+ props = hfeed.get('properties', {})
+
+ assert 'name' in props, "h-feed must have p-name"
+ assert 'author' in props, "h-feed must have p-author"
+ assert 'url' in props, "h-feed must have u-url"
+
+ def test_hfeed_author_is_hcard(self, client):
+ """h-feed author is valid h-card"""
+ response = client.get('/')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hfeed = [i for i in parsed['items'] if 'h-feed' in i.get('type', [])][0]
+ author = hfeed.get('properties', {}).get('author', [{}])[0]
+
+ assert 'h-card' in author.get('type', [])
+ assert 'name' in author.get('properties', {})
+ assert 'url' in author.get('properties', {})
+
+ def test_note_has_hentry(self, client, published_note):
+ """Individual note has h-entry"""
+ response = client.get(f'/note/{published_note.slug}')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hentries = [i for i in parsed['items'] if 'h-entry' in i.get('type', [])]
+ assert len(hentries) == 1
+
+ def test_hentry_has_required_properties(self, client, published_note):
+ """h-entry has content, published, url"""
+ response = client.get(f'/note/{published_note.slug}')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hentry = [i for i in parsed['items'] if 'h-entry' in i.get('type', [])][0]
+ props = hentry.get('properties', {})
+
+ assert 'content' in props, "h-entry must have e-content"
+ assert 'published' in props, "h-entry must have dt-published"
+ assert 'url' in props, "h-entry must have u-url"
+
+ def test_hentry_has_pcategory_for_tags(self, client, published_note_with_tags):
+ """h-entry has p-category for each tag"""
+ response = client.get(f'/note/{published_note_with_tags.slug}')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hentry = [i for i in parsed['items'] if 'h-entry' in i.get('type', [])][0]
+ categories = hentry.get('properties', {}).get('category', [])
+
+ assert len(categories) > 0, "h-entry with tags must have p-category"
+
+ def test_uphoto_outside_econtent(self, client, published_note_with_media):
+ """u-photo is direct child of h-entry, not inside e-content"""
+ response = client.get(f'/note/{published_note_with_media.slug}')
+ parsed = mf2py.parse(doc=response.data.decode())
+
+ hentry = [i for i in parsed['items'] if 'h-entry' in i.get('type', [])][0]
+ props = hentry.get('properties', {})
+
+ # u-photo should be at h-entry level
+ assert 'photo' in props, "h-entry with media must have u-photo"
+
+ # Verify it's not nested in e-content
+ content = props.get('content', [{}])[0]
+ if isinstance(content, dict):
+ content_html = content.get('html', '')
+ # u-photo class should NOT appear inside content
+ assert 'u-photo' not in content_html
+```
+
+### 9.2 Manual Validation with indiewebify.me
+
+Test URLs to validate:
+1. Homepage: `https://example.com/` - Validate h-feed
+2. Note permalink: `https://example.com/note/test-note` - Validate h-entry
+3. Note with tags: Validate p-category parsing
+4. Note with media: Validate u-photo placement
+
+**Acceptance Criteria**:
+- indiewebify.me h-feed validation passes
+- indiewebify.me h-entry validation passes
+- mf2py correctly parses all properties
+
+---
+
+## 10. Migration Considerations
+
+### 10.1 Database Migration
+
+Migration `008_add_tags.sql` is additive - no data loss risk.
+
+### 10.2 Existing Notes
+
+Existing notes will have no tags. This is expected behavior. Manual tagging via the edit interface is acceptable for v1.3.0. A bulk-tagging admin UI is out of scope but can be added to the backlog.
+
+### 10.3 Backward Compatibility
+
+- All existing URLs remain functional
+- No breaking changes to Micropub API
+- Tags are optional on create/update
+- Micropub clients without `category` support work unchanged
+
+---
+
+## 11. Implementation Phases
+
+### Phase Dependencies
+
+- Phase 1 must come first (templates need data)
+- Phases 2 and 3 can be interleaved
+- p-category markup can be added before tag routes exist (links will 404 until Phase 3, acceptable during development)
+
+### Phase 1: Database and Backend (Priority: High)
+
+1. Create migration `008_add_tags.sql`
+2. Implement `starpunk/tags.py`
+3. Update `starpunk/notes.py` to accept tags
+4. Update `starpunk/micropub.py` to pass tags
+
+### Phase 2: Templates (Priority: High)
+
+1. Update `templates/index.html` with h-feed properties
+2. Update `templates/note.html` with p-category
+3. Update `templates/note.html` h-card with p-note
+4. Create `templates/tag.html`
+
+### Phase 3: Routes and Admin (Priority: Medium)
+
+1. Add tag archive route
+2. Update admin forms for tag editing
+3. Load tags in public routes
+
+### Phase 4: Validation (Priority: High)
+
+1. Write mf2py tests
+2. Manual indiewebify.me validation
+3. Document validation results
+
+### Recommended Commit Strategy
+
+Commit each phase separately with corresponding tests:
+
+1. `feat(tags): Add database schema and tags module`
+2. `feat(templates): Add p-category markup to note pages`
+3. `feat(routes): Add tag archive route`
+4. `feat(admin): Add tag editing to admin interface`
+5. `test(microformats): Add mf2py validation tests`
+
+This allows easier code review, bisectable history, and rollback capability.
+
+---
+
+## 12. File Change Summary
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `migrations/008_add_tags.sql` | Database schema for tags |
+| `starpunk/tags.py` | Tag management module |
+| `templates/tag.html` | Tag archive template |
+| `tests/test_microformats.py` | Microformats validation tests |
+
+### Modified Files
+
+| File | Changes |
+|------|---------|
+| `starpunk/notes.py` | Add `tags` parameter to create/update |
+| `starpunk/models.py` | Add `tags` property to Note |
+| `starpunk/micropub.py` | Pass tags to create_note, return in q=source |
+| `starpunk/routes/public.py` | Add tag route, load tags in index/note |
+| `starpunk/routes/admin.py` | Handle tag editing |
+| `templates/index.html` | Add h-feed properties, p-category |
+| `templates/note.html` | Add p-category, enhance h-card |
+| `templates/admin/edit.html` | Add tag input field |
+
+---
+
+## 13. Acceptance Criteria Summary
+
+1. **Tags work end-to-end**:
+ - Can create note with tags via Micropub
+ - Can add/edit tags via admin interface
+ - Tags display on note pages with p-category class
+ - Tag archive pages work
+
+2. **h-feed compliance**:
+ - mf2py parses h-feed with name, author, url
+ - indiewebify.me validates h-feed
+
+3. **h-entry compliance**:
+ - mf2py parses p-category for tags
+ - u-photo is outside e-content
+ - indiewebify.me validates h-entry
+
+4. **h-card compliance**:
+ - Author h-card includes photo and bio when available
+ - Graceful fallback when not available
+
+---
+
+## 14. Test Fixtures
+
+The developer should create these fixtures in `tests/conftest.py`:
+
+```python
+@pytest.fixture
+def published_note_with_tags(app, db):
+ """Create a published note with tags for microformats testing"""
+ from starpunk.notes import create_note
+
+ note = create_note(
+ content="Test note with tags for microformats validation",
+ published=True,
+ tags=["Python", "IndieWeb", "Testing"]
+ )
+ return note
+
+
+@pytest.fixture
+def published_note_with_media(app, db, tmp_path):
+ """Create a published note with media for u-photo testing"""
+ from starpunk.notes import create_note
+ from starpunk.media import create_media, link_media_to_note
+ from PIL import Image
+ import io
+
+ note = create_note(content="Test note with media", published=True)
+
+ # Create minimal valid JPEG for testing
+ img = Image.new('RGB', (100, 100), color='red')
+ img_bytes = io.BytesIO()
+ img.save(img_bytes, format='JPEG')
+ img_bytes.seek(0)
+
+ test_image = tmp_path / "test.jpg"
+ test_image.write_bytes(img_bytes.getvalue())
+
+ media = create_media(test_image, note.id, alt_text="Test image")
+ return note
+```
+
+**Note**: mf2py is already in `requirements.txt` as a production dependency (added in v1.2.0). No additional dependencies needed.
+
+---
+
+## 15. Developer Q&A Summary
+
+This section documents key decisions from developer questions:
+
+| Question | Decision |
+|----------|----------|
+| Tag URL case sensitivity | Normalize to lowercase before lookup |
+| Tag display order | Alphabetical by display_name (case-insensitive) |
+| Tags in `to_dict()` | Add `include_tags` parameter (default False) |
+| Tag loading in routes | Pre-load via `object.__setattr__` (like media) |
+| Empty `category: []` in Micropub | Remove all tags (update), no tags (create) |
+| `q=source` response | Include tags in category property |
+| Tag route URL | `/tag/` (singular) |
+| Tag pagination | None for v1.3.0 |
+| `rel="tag"` attribute | Yes, add per microformats2 spec |
+| Admin tag input | Plain text, comma-separated, with trim and dedupe |
+| Empty tag field in admin | Removes all tags |
+| Tag autocomplete | Out of scope for v1.3.0 |
+| Hidden author bio | Intentional - semantic markup for parsers |
+| Dual u-photo elements | Intentional - parser compatibility |
+| Existing notes migration | Manual tagging acceptable |
+| Phase dependencies | Phase 1 first, then 2/3 interleaved |
+| Commit strategy | Separate commits per phase |
diff --git a/docs/examples/INDEX.md b/docs/examples/INDEX.md
deleted file mode 100644
index c5bd71a..0000000
--- a/docs/examples/INDEX.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Examples Documentation Index
-
-This directory contains example implementations, code samples, and usage patterns for StarPunk CMS.
-
-## Available Examples
-
-### Identity Page
-- **[identity-page.html](identity-page.html)** - Example IndieAuth identity page
-- **[identity-page-customization-guide.md](identity-page-customization-guide.md)** - Guide for customizing identity pages
-
-## Example Categories
-
-### IndieAuth Examples
-- Identity page setup and customization
-- Endpoint discovery implementation
-- Authentication flow examples
-
-## How to Use Examples
-
-### For Integration
-1. Copy example files to your project
-2. Customize for your specific needs
-3. Follow accompanying documentation
-
-### For Learning
-- Study examples to understand patterns
-- Use as reference for your own implementation
-- Adapt to your use case
-
-## Contributing Examples
-
-When adding new examples:
-1. Include working code
-2. Add documentation explaining the example
-3. Update this index
-4. Follow project coding standards
-
-## Related Documentation
-- **[../design/](../design/)** - Feature designs
-- **[../standards/](../standards/)** - Coding standards
-- **[../architecture/](../architecture/)** - System architecture
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
diff --git a/docs/migration/INDEX.md b/docs/migration/INDEX.md
deleted file mode 100644
index 617eec2..0000000
--- a/docs/migration/INDEX.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Migration Guides Index
-
-This directory contains migration guides for upgrading between versions and making configuration changes.
-
-## Migration Guides
-
-- **[fix-hardcoded-endpoints.md](fix-hardcoded-endpoints.md)** - Migrate from hardcoded TOKEN_ENDPOINT to dynamic endpoint discovery
-
-## Migration Types
-
-### Configuration Migrations
-Guides for updating configuration between versions:
-- Environment variable changes
-- Configuration file updates
-- Feature flag migrations
-
-### Code Migrations
-Guides for updating code that uses StarPunk:
-- API changes
-- Breaking changes
-- Deprecated feature replacements
-
-## How to Use Migration Guides
-
-1. **Identify Your Version**: Check current version with `python -c "from starpunk import __version__; print(__version__)"`
-2. **Find Relevant Guide**: Look for migration guide for your target version
-3. **Follow Steps**: Complete migration steps in order
-4. **Test**: Verify system works after migration
-5. **Update**: Update version numbers and documentation
-
-## Related Documentation
-- **[../standards/versioning-strategy.md](../standards/versioning-strategy.md)** - Versioning guidelines
-- **[CHANGELOG.md](../../CHANGELOG.md)** - Version change log
-- **[../decisions/](../decisions/)** - ADRs documenting breaking changes
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
diff --git a/docs/projectplan/BACKLOG.md b/docs/projectplan/BACKLOG.md
new file mode 100644
index 0000000..3942f59
--- /dev/null
+++ b/docs/projectplan/BACKLOG.md
@@ -0,0 +1,104 @@
+# StarPunk Backlog
+
+**Last Updated**: 2025-12-10
+
+## Recently Completed
+
+### v1.3.0 - Microformats2 Compliance and Tags (Complete)
+- Tag/Category system with database schema
+- p-category microformats2 markup
+- h-feed required properties (name, author, url)
+- Author h-card with photo and bio
+- u-photo placement outside e-content
+- mf2py validation test suite
+
+## Priority Levels
+
+- **Critical** - Items that break existing functionality
+- **High** - Important features or fixes
+- **Medium** - Planned features
+- **Low** - Nice-to-have, deferred indefinitely
+
+---
+
+## Critical
+
+*No critical items*
+
+---
+
+## High
+
+### Enhanced Feed Media Support *(Scheduled: v1.4.0)*
+- Multiple image sizes/thumbnails (150px, 320px, 640px, 1280px)
+- Full Media RSS implementation (media:group, all attributes)
+- Enhanced JSON Feed attachments
+- ATOM enclosure links for all media
+- See: ADR-059
+
+---
+
+## Medium
+
+### Tag Enhancements (v1.3.0 Follow-up)
+- Tag pagination on archive pages (when note count exceeds threshold)
+- Tag autocomplete in admin interface
+- Tag-filtered feeds (e.g., `/feed.rss?tag=python`, `/tags/python/feed.rss`)
+- Fix: Empty tag field in admin should remove all tags (currently may leave unchanged)
+
+### Tag-Filtered Feeds
+- Filter feeds by tag (e.g., `/feed.rss?tag=python`)
+- Dedicated tag feed URLs (e.g., `/tags/python/feed.rss`)
+- Support all three formats (RSS, Atom, JSON Feed)
+- Cache management for filtered feeds
+
+### Webmentions
+- Receive endpoint
+- Send on publish
+- Display received mentions
+- Moderation interface
+
+### Reply Contexts
+- In-reply-to support
+- Like/repost posts
+- Bookmark posts
+
+### Media Uploads Enhancements
+- File management interface
+- Thumbnail generation
+- CDN integration (optional)
+
+### Photo Posts
+- Instagram-like photo notes
+- Gallery views
+- EXIF data preservation
+
+### Audio/Podcast Support
+- Podcast RSS with iTunes namespace
+- Audio duration extraction
+- Episode metadata support
+- Apple/Google podcast compatibility
+- See: ADR-059
+
+### Video Support
+- Video upload handling
+- Poster image generation
+- Video in Media RSS feeds
+- HTML5 video embedding
+
+---
+
+## Low
+
+### Flaky Migration Race Condition Tests
+- Improve `test_migration_race_condition.py::TestGraduatedLogging::test_debug_level_for_early_retries`
+- Test expects DEBUG retry messages but passes when migration succeeds without retries
+- May need to mock or force retry conditions for reliable testing
+
+### Deferred Indefinitely
+- Static Site Generation - Conflicts with dynamic Micropub
+- Multi-language UI - Low priority for single-user system
+- Advanced Analytics - Privacy concerns, use external tools
+- Comments System - Use Webmentions instead
+- WYSIWYG Editor - Markdown is sufficient
+- Mobile App - Web interface is mobile-friendly
diff --git a/docs/projectplan/INDEX.md b/docs/projectplan/INDEX.md
deleted file mode 100644
index ff9a1af..0000000
--- a/docs/projectplan/INDEX.md
+++ /dev/null
@@ -1,166 +0,0 @@
-# StarPunk Project Planning Index
-
-## Overview
-
-This directory contains all project planning documentation for StarPunk, organized by version and planning phase. Use this index to navigate to the appropriate documentation.
-
-## Current Status
-
-**Latest Release**: v1.1.0 "SearchLight" (2025-11-25)
-**Project Status**: Production Ready - V1 Feature Complete
-
-## Directory Structure
-
-```
-/docs/projectplan/
-βββ INDEX.md (this file)
-βββ ROADMAP.md β Future development roadmap
-βββ v1/ β V1.0 planning (COMPLETE)
-β βββ README.md β V1 planning overview
-β βββ implementation-plan.md β Detailed implementation phases
-β βββ feature-scope.md β In/out of scope decisions
-β βββ quick-reference.md β Developer quick reference
-β βββ dependencies-diagram.md β Module dependencies
-βββ v1.1/ β V1.1 planning (COMPLETE)
- βββ RELEASE-STATUS.md β V1.1.0 release tracking
- βββ priority-work.md β Completed priority items
- βββ potential-features.md β Feature backlog
-```
-
-## Quick Navigation
-
-### For Current Development
-- [Roadmap](/home/phil/Projects/starpunk/docs/projectplan/ROADMAP.md) - Future versions and features
-- [V1.1 Release Status](/home/phil/Projects/starpunk/docs/projectplan/v1.1/RELEASE-STATUS.md) - Latest release details
-
-### For Historical Reference
-- [V1 Implementation Plan](/home/phil/Projects/starpunk/docs/projectplan/v1/implementation-plan.md) - How V1 was built
-- [Feature Scope](/home/phil/Projects/starpunk/docs/projectplan/v1/feature-scope.md) - V1 scope decisions
-
-### For Daily Work
-- [Quick Reference](/home/phil/Projects/starpunk/docs/projectplan/v1/quick-reference.md) - Commands and lookups
-- [Potential Features](/home/phil/Projects/starpunk/docs/projectplan/v1.1/potential-features.md) - Feature backlog
-
-## Version History
-
-### V1.1.0 "SearchLight" (Released 2025-11-25)
-- Full-text search with FTS5
-- Custom slugs via Micropub
-- RSS feed fixes
-- Migration improvements
-- [Full Release Details](/home/phil/Projects/starpunk/docs/projectplan/v1.1/RELEASE-STATUS.md)
-
-### V1.0.0 (Released 2025-11-24)
-- IndieAuth authentication
-- Micropub endpoint
-- Notes management
-- RSS syndication
-- Web interface
-- [Implementation Report](/home/phil/Projects/starpunk/docs/reports/v1.0.0-implementation-report.md)
-
-## Key Documents
-
-### Planning Documents
-1. **[Roadmap](/home/phil/Projects/starpunk/docs/projectplan/ROADMAP.md)**
- - Future version planning
- - Feature timeline
- - Design principles
-
-2. **[V1 Implementation Plan](/home/phil/Projects/starpunk/docs/projectplan/v1/implementation-plan.md)**
- - Phase-by-phase implementation
- - Task tracking
- - Test requirements
-
-3. **[Feature Scope](/home/phil/Projects/starpunk/docs/projectplan/v1/feature-scope.md)**
- - In/out of scope matrix
- - Decision framework
- - Lines of code budget
-
-### Status Documents
-1. **[V1.1 Release Status](/home/phil/Projects/starpunk/docs/projectplan/v1.1/RELEASE-STATUS.md)**
- - Latest release tracking
- - Completed features
- - Test coverage
-
-2. **[Priority Work](/home/phil/Projects/starpunk/docs/projectplan/v1.1/priority-work.md)**
- - Critical items (completed)
- - Implementation notes
- - Success criteria
-
-### Reference Documents
-1. **[Quick Reference](/home/phil/Projects/starpunk/docs/projectplan/v1/quick-reference.md)**
- - Common commands
- - File checklist
- - Configuration guide
-
-2. **[Potential Features](/home/phil/Projects/starpunk/docs/projectplan/v1.1/potential-features.md)**
- - Feature backlog
- - Implementation options
- - Priority scoring
-
-## Related Documentation
-
-### Architecture
-- [Architecture Overview](/home/phil/Projects/starpunk/docs/architecture/overview.md)
-- [Technology Stack](/home/phil/Projects/starpunk/docs/architecture/technology-stack.md)
-- [Architecture Decision Records](/home/phil/Projects/starpunk/docs/decisions/)
-
-### Implementation Reports
-- [V1.1.0 Implementation Report](/home/phil/Projects/starpunk/docs/reports/v1.1.0-implementation-report.md)
-- [V1.0.0 Implementation Report](/home/phil/Projects/starpunk/docs/reports/v1.0.0-implementation-report.md)
-- [All Reports](/home/phil/Projects/starpunk/docs/reports/)
-
-### Standards
-- [Python Coding Standards](/home/phil/Projects/starpunk/docs/standards/python-coding-standards.md)
-- [Git Branching Strategy](/home/phil/Projects/starpunk/docs/standards/git-branching-strategy.md)
-- [Versioning Strategy](/home/phil/Projects/starpunk/docs/standards/versioning-strategy.md)
-
-## How to Use This Documentation
-
-### For New Contributors
-1. Read the [Roadmap](/home/phil/Projects/starpunk/docs/projectplan/ROADMAP.md)
-2. Review [Feature Scope](/home/phil/Projects/starpunk/docs/projectplan/v1/feature-scope.md)
-3. Check [Potential Features](/home/phil/Projects/starpunk/docs/projectplan/v1.1/potential-features.md)
-
-### For Implementation
-1. Check [Current Status](#current-status) above
-2. Review relevant ADRs in `/docs/decisions/`
-3. Follow [Quick Reference](/home/phil/Projects/starpunk/docs/projectplan/v1/quick-reference.md)
-4. Document in `/docs/reports/`
-
-### For Planning
-1. Review [Roadmap](/home/phil/Projects/starpunk/docs/projectplan/ROADMAP.md)
-2. Check [Feature Backlog](/home/phil/Projects/starpunk/docs/projectplan/v1.1/potential-features.md)
-3. Create ADRs for major decisions
-4. Update this index when adding documents
-
-## Maintenance
-
-This planning documentation should be updated:
-- After each release (update status, versions)
-- When planning new features (update roadmap)
-- When making scope decisions (update feature documents)
-- When creating new planning documents (update this index)
-
-## Success Metrics
-
-Project planning success is measured by:
-- β All V1 features implemented
-- β 598 tests (588 passing)
-- β IndieWeb compliance achieved
-- β Documentation complete
-- β Production ready
-
-## Philosophy
-
-> "Every line of code must justify its existence. When in doubt, leave it out."
-
-This philosophy guides all planning and implementation decisions.
-
----
-
-**Index Created**: 2025-11-25
-**Last Updated**: 2025-11-25
-**Maintained By**: StarPunk Architect
-
-For questions about project planning, consult the Architect agent or review the ADRs.
\ No newline at end of file
diff --git a/docs/projectplan/ROADMAP.md b/docs/projectplan/ROADMAP.md
deleted file mode 100644
index 5580672..0000000
--- a/docs/projectplan/ROADMAP.md
+++ /dev/null
@@ -1,368 +0,0 @@
-# StarPunk Roadmap
-
-## Current Status
-
-**Latest Version**: v1.1.2 "Syndicate"
-**Released**: 2025-11-27
-**Status**: Production Ready
-
-StarPunk has achieved V1 feature completeness with all core IndieWeb functionality implemented:
-- β IndieAuth authentication
-- β Micropub endpoint
-- β Notes management
-- β RSS syndication
-- β Full-text search
-- β Custom slugs
-
-## Version History
-
-### Released Versions
-
-#### v1.1.2 "Syndicate" (2025-11-27)
-- Multi-format feed support (RSS 2.0, ATOM 1.0, JSON Feed 1.1)
-- Content negotiation for automatic format selection
-- Feed caching with LRU eviction and TTL expiration
-- ETag support with 304 conditional responses
-- Feed statistics dashboard in admin panel
-- OPML 2.0 export for feed discovery
-- Complete metrics instrumentation
-
-#### v1.1.1 (2025-11-26)
-- Fix metrics dashboard 500 error
-- Add data transformer for metrics template
-
-#### v1.1.0 "SearchLight" (2025-11-25)
-- Full-text search with FTS5
-- Complete search UI
-- Custom slugs via Micropub mp-slug
-- RSS feed ordering fix
-- Migration system improvements
-
-#### v1.0.1 (2025-11-24)
-- Fixed Micropub URL double-slash bug
-- Minor bug fixes
-
-#### v1.0.0 (2025-11-24)
-- Initial production release
-- IndieAuth authentication
-- Micropub server implementation
-- Notes CRUD functionality
-- RSS feed generation
-- Web interface (public & admin)
-
-## Future Roadmap
-
-### v1.1.1 "Polish" (Superseded)
-**Timeline**: Completed as hotfix
-**Status**: Released as hotfix (2025-11-26)
-**Note**: Critical fixes released immediately, remaining scope moved to v1.2.0
-
-Planned Features:
-
-#### Search Configuration System (3-4 hours)
-- `SEARCH_ENABLED` flag for sites that don't need search
-- `SEARCH_TITLE_LENGTH` configurable limit (currently hardcoded at 100)
-- Enhanced search term highlighting in results
-- Search result relevance scoring display
-- Graceful FTS5 degradation with fallback to LIKE queries
-
-#### Performance Monitoring Foundation (4-6 hours)
-- Add timing instrumentation to key operations
-- Database query performance logging
-- Slow query detection and warnings (configurable threshold)
-- Memory usage tracking in production
-- `/admin/performance` dashboard with real-time metrics
-
-#### Production Readiness Improvements (3-5 hours)
-- Graceful degradation when FTS5 unavailable
-- Better error messages for common configuration issues
-- Database connection pooling optimization
-- Improved logging structure with configurable levels
-- Enhanced health check endpoints (`/health` and `/health/ready`)
-
-#### Bug Fixes & Edge Cases (2-3 hours)
-- Fix 10 flaky timing tests from migration race conditions
-- Handle Unicode edge cases in slug generation
-- RSS feed memory optimization for large note counts
-- Session timeout handling improvements
-
-Technical Decisions:
-- [ADR-052: Configuration System Architecture](/home/phil/Projects/starpunk/docs/decisions/ADR-052-configuration-system-architecture.md)
-- [ADR-053: Performance Monitoring Strategy](/home/phil/Projects/starpunk/docs/decisions/ADR-053-performance-monitoring-strategy.md)
-- [ADR-054: Structured Logging Architecture](/home/phil/Projects/starpunk/docs/decisions/ADR-054-structured-logging-architecture.md)
-- [ADR-055: Error Handling Philosophy](/home/phil/Projects/starpunk/docs/decisions/ADR-055-error-handling-philosophy.md)
-
-### v1.1.2 "Syndicate" (Completed)
-**Timeline**: Completed 2025-11-27
-**Status**: Released
-**Actual Effort**: ~10 hours across 3 phases
-**Focus**: Expanded syndication format support
-
-Delivered Features:
-- β **Phase 1: Metrics Instrumentation**
- - Comprehensive metrics collection system
- - Business metrics tracking for feed operations
- - Foundation for performance monitoring
-- β **Phase 2: Multi-Format Feeds**
- - RSS 2.0 (existing, enhanced)
- - ATOM 1.0 feed at `/feed.atom` (RFC 4287 compliant)
- - JSON Feed 1.1 at `/feed.json`
- - Content negotiation at `/feed`
- - Auto-discovery links for all formats
-- β **Phase 3: Feed Enhancements**
- - Feed caching with LRU eviction (50 entries max)
- - TTL-based expiration (5 minutes default)
- - ETag support with SHA-256 checksums
- - HTTP 304 conditional responses
- - Feed statistics dashboard
- - OPML 2.0 export at `/opml.xml`
- - Content-Type negotiation (optional)
- - Feed validation tests
-
-See: [ADR-038: Syndication Formats](/home/phil/Projects/starpunk/docs/decisions/ADR-038-syndication-formats.md)
-
-### v1.2.0 "Polish"
-**Timeline**: December 2025 (Next Release)
-**Focus**: Quality improvements and production readiness
-**Effort**: 12-18 hours
-
-Next Planned Features:
-- **Search Configuration System** (3-4 hours)
- - `SEARCH_ENABLED` flag for sites that don't need search
- - `SEARCH_TITLE_LENGTH` configurable limit
- - Enhanced search term highlighting
- - Search result relevance scoring display
-- **Performance Monitoring Dashboard** (4-6 hours)
- - Extend existing metrics infrastructure
- - Database query performance tracking
- - Memory usage monitoring
- - `/admin/performance` dedicated dashboard
-- **Production Improvements** (3-5 hours)
- - Better error messages for configuration issues
- - Enhanced health check endpoints
- - Database connection pooling optimization
- - Structured logging with configurable levels
-- **Bug Fixes** (2-3 hours)
- - Unicode edge cases in slug generation
- - Session timeout handling improvements
- - RSS feed memory optimization for large counts
-
-### v1.3.0 "Semantic"
-**Timeline**: Q1 2026
-**Focus**: Enhanced semantic markup, organization, and advanced feed media
-**Effort**: 10-16 hours for microformats2, 12-18 hours for feed media, plus category system
-
-Planned Features:
-- **Strict Microformats2 Compliance** (10-16 hours)
- - Complete h-entry properties (p-name, p-summary, p-author)
- - Author h-card implementation
- - h-feed wrapper for index pages
- - Full IndieWeb parser compatibility
- - Microformats2 validation suite
- - See: [ADR-040: Microformats2 Compliance](/home/phil/Projects/starpunk/docs/decisions/ADR-040-microformats2-compliance.md)
-- **Enhanced Feed Media Support** (12-18 hours) - Full Standardization Phase A
- - Multiple image sizes/thumbnails (150px, 320px, 640px, 1280px)
- - Full Media RSS implementation (media:group, all attributes)
- - Enhanced JSON Feed attachments
- - ATOM enclosure links for all media
- - See: [ADR-059: Full Feed Media Standardization](/home/phil/Projects/starpunk/docs/decisions/ADR-059-full-feed-media-standardization.md)
-- **Tag/Category System**
- - Database schema for tags
- - Tag-based filtering
- - Tag clouds
- - Category RSS/ATOM/JSON feeds
- - p-category microformats2 support
-- **Hierarchical Slugs**
- - Support for `/` in slugs
- - Directory-like organization
- - Breadcrumb navigation with microformats2
-- **Draft Management**
- - Explicit draft status
- - Draft preview
- - Scheduled publishing
-- **Search Enhancements**
- - Tag search
- - Date range filtering
- - Advanced query syntax
-
-### v1.4.0 "Connections"
-**Timeline**: Q2 2026
-**Focus**: IndieWeb social features
-
-Planned Features:
-- **Webmentions**
- - Receive endpoint
- - Send on publish
- - Display received mentions
- - Moderation interface
-- **IndieAuth Provider** (optional)
- - Self-hosted IndieAuth server
- - Token endpoint
- - Client registration
-- **Reply Contexts**
- - In-reply-to support
- - Like/repost posts
- - Bookmark posts
-
-### v1.4.0 "Media"
-**Timeline**: Q3 2026
-**Focus**: Rich content support and podcast/video syndication
-
-Planned Features:
-- **Media Uploads**
- - Image upload via Micropub
- - File management interface
- - Thumbnail generation
- - CDN integration (optional)
-- **Photo Posts**
- - Instagram-like photo notes
- - Gallery views
- - EXIF data preservation
-- **Audio/Podcast Support** (10-16 hours) - Full Standardization Phase B
- - Podcast RSS with iTunes namespace
- - Audio duration extraction
- - Episode metadata support
- - Apple/Google podcast compatibility
- - See: [ADR-059: Full Feed Media Standardization](/home/phil/Projects/starpunk/docs/decisions/ADR-059-full-feed-media-standardization.md)
-- **Video Support** (16-24 hours) - Full Standardization Phase C
- - Video upload handling
- - Poster image generation
- - Video in Media RSS feeds
- - HTML5 video embedding
-
-### v2.0.0 "MultiUser"
-**Timeline**: 2027
-**Focus**: Multi-author support (BREAKING CHANGES)
-
-Major Features:
-- **User Management**
- - Multiple authors
- - Role-based permissions
- - User profiles
-- **Content Attribution**
- - Per-note authorship
- - Author pages
- - Author RSS feeds
-- **Collaborative Features**
- - Draft sharing
- - Editorial workflow
- - Comment system
-
-## Design Principles
-
-All future development will maintain these core principles:
-
-1. **Simplicity First**: Every feature must justify its complexity
-2. **IndieWeb Standards**: Full compliance with specifications
-3. **Progressive Enhancement**: Core functionality works without JavaScript
-4. **Data Portability**: User data remains exportable and portable
-5. **Backwards Compatibility**: Minor versions preserve compatibility
-
-## Feature Request Process
-
-To propose new features:
-
-1. **Check Alignment**
- - Does it align with IndieWeb principles?
- - Does it solve a real user problem?
- - Can it be implemented simply?
-
-2. **Document Proposal**
- - Create issue or discussion
- - Describe use case clearly
- - Consider implementation complexity
-
-3. **Architectural Review**
- - Impact on existing features
- - Database schema changes
- - API compatibility
-
-4. **Priority Assessment**
- - User value vs. complexity
- - Maintenance burden
- - Dependencies on other features
-
-## Deferred Features
-
-These features have been considered but deferred indefinitely:
-
-- **Static Site Generation**: Conflicts with dynamic Micropub
-- **Multi-language UI**: Low priority for single-user system
-- **Advanced Analytics**: Privacy concerns, use external tools
-- **Comments System**: Use Webmentions instead
-- **WYSIWYG Editor**: Markdown is sufficient
-- **Mobile App**: Web interface is mobile-friendly
-
-## Support Lifecycle
-
-### Version Support
-- **Current Release** (v1.1.0): Full support
-- **Previous Minor** (v1.0.x): Security fixes only
-- **Older Versions**: Community support only
-
-### Compatibility Promise
-- **Database**: Migrations always provided
-- **API**: Micropub/IndieAuth remain stable
-- **Configuration**: Changes documented in upgrade guides
-
-## Contributing
-
-StarPunk welcomes contributions that align with its philosophy:
-
-### Code Contributions
-- Follow existing patterns
-- Include tests
-- Document changes
-- Keep it simple
-
-### Documentation
-- User guides
-- API documentation
-- Deployment guides
-- Migration guides
-
-### Testing
-- Bug reports with reproduction steps
-- Compatibility testing
-- Performance testing
-- Security testing
-
-## Technology Evolution
-
-### Near-term Considerations
-- Python 3.12+ adoption
-- SQLite WAL mode
-- HTTP/2 support
-- Container optimizations
-
-### Long-term Possibilities
-- Alternative database backends (PostgreSQL)
-- Federation protocols (ActivityPub)
-- Real-time features (WebSockets)
-- AI-assisted writing (local models)
-
-## Success Metrics
-
-StarPunk success is measured by:
-- **Simplicity**: Lines of code remain minimal
-- **Reliability**: Uptime and stability
-- **Standards Compliance**: Passing validators
-- **User Satisfaction**: Feature completeness
-- **Performance**: Response times <300ms
-
-## Philosophy
-
-> "Every line of code must justify its existence. When in doubt, leave it out."
-
-This philosophy guides all development decisions. StarPunk aims to be the simplest possible IndieWeb CMS that works correctly, not the most feature-rich.
-
----
-
-**Document Created**: 2025-11-25
-**Last Updated**: 2025-11-25
-**Status**: Living Document
-
-For the latest updates, see:
-- [Release Notes](/home/phil/Projects/starpunk/CHANGELOG.md)
-- [Project Plan](/home/phil/Projects/starpunk/docs/projectplan/)
-- [Architecture Decisions](/home/phil/Projects/starpunk/docs/decisions/)
\ No newline at end of file
diff --git a/docs/projectplan/v1.0.0/RELEASE.md b/docs/projectplan/v1.0.0/RELEASE.md
new file mode 100644
index 0000000..9e80306
--- /dev/null
+++ b/docs/projectplan/v1.0.0/RELEASE.md
@@ -0,0 +1,20 @@
+# StarPunk v1.0.0 Release
+
+**Status**: Released 2025-11-24
+**Codename**: Initial Release
+
+## Features
+
+- IndieAuth authentication (via indielogin.com)
+- Micropub server implementation
+- Notes CRUD functionality
+- RSS feed generation
+- Web interface (public & admin)
+
+## Bugs Addressed
+
+*Initial release - no prior bugs*
+
+## Implementation
+
+See `docs/design/v1.0.0/` for implementation details and reports.
diff --git a/docs/projectplan/v1.1.0/RELEASE.md b/docs/projectplan/v1.1.0/RELEASE.md
new file mode 100644
index 0000000..e1199ac
--- /dev/null
+++ b/docs/projectplan/v1.1.0/RELEASE.md
@@ -0,0 +1,19 @@
+# StarPunk v1.1.0 Release
+
+**Status**: Released 2025-11-25
+**Codename**: SearchLight
+
+## Features
+
+- Full-text search with FTS5
+- Custom slugs via Micropub mp-slug
+- Migration system improvements
+
+## Bugs Addressed
+
+- RSS feed ordering (newest first)
+- Custom slug extraction from Micropub
+
+## Implementation
+
+See `docs/design/v1.1.1/` for implementation details and reports.
diff --git a/docs/projectplan/v1.1.1/RELEASE.md b/docs/projectplan/v1.1.1/RELEASE.md
new file mode 100644
index 0000000..e468e02
--- /dev/null
+++ b/docs/projectplan/v1.1.1/RELEASE.md
@@ -0,0 +1,16 @@
+# StarPunk v1.1.1 Release
+
+**Status**: Released 2025-11-26
+
+## Features
+
+*Hotfix release - no new features*
+
+## Bugs Addressed
+
+- Fix metrics dashboard 500 error
+- Add data transformer for metrics template
+
+## Implementation
+
+See `docs/design/v1.1.1/` for implementation details and reports.
diff --git a/docs/projectplan/v1.1.2/RELEASE.md b/docs/projectplan/v1.1.2/RELEASE.md
new file mode 100644
index 0000000..1dcce63
--- /dev/null
+++ b/docs/projectplan/v1.1.2/RELEASE.md
@@ -0,0 +1,21 @@
+# StarPunk v1.1.2 Release
+
+**Status**: Released 2025-11-27
+**Codename**: Syndicate
+
+## Features
+
+- Multi-format feed support (RSS 2.0, ATOM 1.0, JSON Feed 1.1)
+- Content negotiation for automatic format selection
+- Feed caching with LRU eviction and TTL expiration
+- ETag support with 304 conditional responses
+- Feed statistics dashboard
+- OPML 2.0 export
+
+## Bugs Addressed
+
+*No bugs - feature release*
+
+## Implementation
+
+See `docs/design/v1.1.2/` for implementation details and reports.
diff --git a/docs/projectplan/v1.2.0/RELEASE.md b/docs/projectplan/v1.2.0/RELEASE.md
new file mode 100644
index 0000000..4973ce3
--- /dev/null
+++ b/docs/projectplan/v1.2.0/RELEASE.md
@@ -0,0 +1,21 @@
+# StarPunk v1.2.0 Release
+
+**Status**: Released 2025-12-09
+**Codename**: IndieWeb Features
+
+## Features
+
+- Media upload via Micropub
+- Caption/alt text support for images
+- Media display CSS fixes (responsive images)
+- Feed media support (Media RSS namespace, JSON Feed image field)
+
+## Bugs Addressed
+
+- Images too large on homepage
+- Captions displaying when should be alt text only
+- Images missing from feeds in feed readers
+
+## Implementation
+
+See `docs/design/v1.2.0/` for implementation details and reports.
diff --git a/docs/projectplan/v1.3.0/RELEASE.md b/docs/projectplan/v1.3.0/RELEASE.md
new file mode 100644
index 0000000..4024a12
--- /dev/null
+++ b/docs/projectplan/v1.3.0/RELEASE.md
@@ -0,0 +1,69 @@
+# StarPunk v1.3.0 Release
+
+**Status**: Complete
+**Codename**: "Categories"
+**Branch**: `feature/v1.3.0-tags-microformats`
+**Approved**: 2025-12-10
+
+## Features
+
+### Tag/Category System (Complete)
+- Database schema for tags (`migrations/008_add_tags.sql`)
+- Tag management module (`starpunk/tags.py`)
+- Micropub category property support
+- Admin interface for tag editing
+- Tag archive pages (`/tag/`)
+- p-category microformats2 markup with `rel="tag"`
+
+### Strict Microformats2 Compliance (Complete)
+- h-feed required properties: p-name, p-author (h-card), u-url, u-photo
+- h-entry p-category for tags
+- u-photo placement outside e-content per draft spec
+- Author h-card with u-photo and p-note (graceful fallback)
+- mf2py validation test suite
+
+## Implementation Phases
+
+1. **Phase 1: Database and Backend** - Complete
+ - Migration 008 for tags schema
+ - Tags module with normalization
+ - Notes CRUD updated for tags parameter
+ - Micropub integration
+
+2. **Phase 2: Templates** - Complete
+ - h-feed enhancements on index.html
+ - p-category markup on note.html
+ - Tag archive template (tag.html)
+
+3. **Phase 3: Routes and Admin** - Complete
+ - Tag archive route
+ - Admin form tag input
+ - Tags pre-loaded in routes
+
+4. **Phase 4: Validation** - Complete
+ - mf2py validation tests
+ - Test fixtures for tags and media
+
+## Bugs Addressed
+
+*None*
+
+## Known Limitations
+
+- No pagination on tag archive pages (future enhancement)
+- No tag autocomplete in admin (future enhancement)
+- No tag-filtered feeds (moved to backlog)
+
+## Test Results
+
+- 333 tests passed
+- 1 pre-existing flaky test (unrelated to v1.3.0)
+- All microformats validation tests pass
+
+## Documentation
+
+- Design: `docs/design/v1.3.0/microformats-tags-design.md`
+- Phase 1 Report: `docs/design/v1.3.0/2025-12-10-phase1-implementation.md`
+- Phase 2 Report: `docs/design/v1.3.0/2025-12-10-phase2-implementation.md`
+- Phase 3 Report: `docs/design/v1.3.0/2025-12-10-phase3-implementation.md`
+- Phase 4 Report: `docs/design/v1.3.0/2025-12-10-phase4-implementation.md`
diff --git a/docs/projectplan/v1.3.1/RELEASE.md b/docs/projectplan/v1.3.1/RELEASE.md
new file mode 100644
index 0000000..8ed93c2
--- /dev/null
+++ b/docs/projectplan/v1.3.1/RELEASE.md
@@ -0,0 +1,154 @@
+# StarPunk v1.3.1 Release
+
+**Status**: Planning
+**Codename**: "Syndicate Tags"
+**Focus**: Feed Categories/Tags Support
+
+## Overview
+
+This patch release adds tags/categories support to all three syndication feed formats (RSS 2.0, Atom 1.0, JSON Feed 1.1). Tags were added to the backend in v1.3.0 but are not currently included in feed output.
+
+## Features
+
+### Feed Categories/Tags Support
+
+Add tag/category elements to all syndication feeds, enabling feed readers and aggregators to categorize and filter content by topic.
+
+#### RSS 2.0 Categories
+- Add `` elements for each tag on a note
+- Use `display_name` as element content for human readability
+- Optional: Consider using normalized `name` as `domain` attribute for taxonomy identification
+- Multiple `` elements per item (one per tag)
+- Reference: RSS 2.0 Specification (www.rssboard.org)
+
+**Example:**
+```xml
+
+ My Post
+ Machine Learning
+ Python
+
+```
+
+#### Atom 1.0 Categories
+- Add `` elements with RFC 4287 compliance
+- Required: `term` attribute (normalized tag name for machine processing)
+- Optional: `label` attribute (display name for human readability)
+- Optional: Consider `scheme` attribute for taxonomy URI
+- Multiple `` elements per entry (one per tag)
+- Reference: RFC 4287 Section 4.2.2
+
+**Example:**
+```xml
+
+ My Post
+
+
+
+```
+
+#### JSON Feed 1.1 Tags
+- Add `tags` array to each item object
+- Array contains `display_name` strings (human-readable)
+- Empty array or omit field if no tags
+- Reference: JSON Feed 1.1 Specification (jsonfeed.org)
+
+**Example:**
+```json
+{
+ "items": [{
+ "title": "My Post",
+ "tags": ["Machine Learning", "Python"]
+ }]
+}
+```
+
+## Implementation Scope
+
+### In Scope
+- RSS feed category elements (`starpunk/feeds/rss.py`)
+- Atom feed category elements (`starpunk/feeds/atom.py`)
+- JSON Feed tags array (`starpunk/feeds/json_feed.py`)
+- Load tags in feed generation routes (`starpunk/routes/public.py`)
+- Unit tests for each feed format with tags
+- Integration tests for feed generation with tagged notes
+
+### Out of Scope (Deferred)
+- Tag-filtered feeds (e.g., `/feed.rss?tag=python`) - consider for v1.4.0
+- Tag cloud/list in feeds - not part of feed specs
+- Category hierarchy/taxonomy URIs - keep simple for v1
+
+## Technical Notes
+
+### Tag Data Loading
+Notes are already loaded with `include_tags=True` capability in the model. Feed routes need to ensure tags are loaded when fetching notes:
+- Check if `get_note_tags()` is called or if notes have `.tags` populated
+- Pass tags to feed generation functions
+
+### Feed Generator Changes
+Each feed module needs modification to accept and render tags:
+
+1. **RSS (`generate_rss()` / `generate_rss_streaming()`):**
+ - Accept tags from note object
+ - Insert `` elements after description/enclosure
+
+2. **Atom (`generate_atom()` / `generate_atom_streaming()`):**
+ - Accept tags from note object
+ - Insert `` elements
+
+3. **JSON Feed (`_build_item_object()`):**
+ - Accept tags from note object
+ - Add `"tags": [...]` array to item object
+
+### Backward Compatibility
+- Tags are optional in all three feed specs
+- Notes without tags will simply have no category/tags elements
+- No breaking changes to existing feed consumers
+
+## Testing Requirements
+
+### Unit Tests
+- RSS: Notes with tags generate correct `` elements
+- RSS: Notes without tags have no `` elements
+- RSS: Multiple tags generate multiple `` elements
+- Atom: Notes with tags generate correct `` elements with term/label
+- Atom: Notes without tags have no `` elements
+- JSON Feed: Notes with tags have `tags` array
+- JSON Feed: Notes without tags have empty array or no `tags` field
+
+### Integration Tests
+- Full feed generation with mix of tagged and untagged notes
+- Feed validation against format specifications
+- Streaming feed generation with tags
+
+## Dependencies
+
+- v1.3.0 tags feature must be complete (database + backend)
+- No new external dependencies required
+
+## Estimated Effort
+
+- Small patch release (1-2 hours implementation)
+- Focused scope: feed modifications only
+- Well-defined specifications to follow
+
+## Success Criteria
+
+1. All three feed formats include tags/categories when present
+2. Feed output validates against respective specifications
+3. Existing tests continue to pass
+4. New tests cover tag rendering in feeds
+5. No regression in feed generation performance
+
+## Related Documentation
+
+- `docs/architecture/syndication-architecture.md` - Feed architecture overview
+- `docs/design/v1.3.0/microformats-tags-design.md` - Tags feature design
+- ADR-014: RSS Feed Implementation
+- ADR-059: Full Feed Media Standardization (future media enhancements)
+
+## Standards References
+
+- [RSS 2.0 Specification - category element](https://www.rssboard.org/rss-specification#ltcategorygtSubelementOfLtitemgt)
+- [RFC 4287 - Atom Syndication Format](https://datatracker.ietf.org/doc/html/rfc4287) (Section 4.2.2 for category)
+- [JSON Feed 1.1 Specification](https://www.jsonfeed.org/version/1.1/) (tags field)
diff --git a/docs/projectplan/v1.4.0/RELEASE.md b/docs/projectplan/v1.4.0/RELEASE.md
new file mode 100644
index 0000000..a8ba6b1
--- /dev/null
+++ b/docs/projectplan/v1.4.0/RELEASE.md
@@ -0,0 +1,401 @@
+# StarPunk v1.4.0 Release
+
+**Status**: Planning
+**Codename**: "Media"
+**Focus**: Micropub Media Endpoint, Large Image Support, Enhanced Feed Media
+
+## Overview
+
+This minor release significantly enhances StarPunk's media capabilities with three major features:
+
+1. **Micropub Media Endpoint** - W3C-compliant media upload via Micropub clients
+2. **Large Image Support** - Accept and resize images larger than 10MB
+3. **Enhanced Feed Media** - Multiple image sizes and complete Media RSS implementation
+
+## Features
+
+### 1. Micropub Media Endpoint
+
+Implement the W3C Micropub media endpoint specification, enabling Micropub clients to upload photos, audio, and video files.
+
+#### Specification Compliance (W3C Micropub)
+
+**Endpoint**: `POST /micropub/media`
+
+**Request Format**:
+- Content-Type: `multipart/form-data`
+- Single file part named `file`
+- Authorization: Bearer token with `media` or `create` scope
+
+**Response**:
+- `201 Created` with `Location` header containing the file URL
+- URL should be unguessable (UUID-based, which we already do)
+
+**Example Request**:
+```http
+POST /micropub/media HTTP/1.1
+Authorization: Bearer xxx
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
+
+------WebKitFormBoundary
+Content-Disposition: form-data; name="file"; filename="photo.jpg"
+Content-Type: image/jpeg
+
+[binary data]
+------WebKitFormBoundary--
+```
+
+**Example Response**:
+```http
+HTTP/1.1 201 Created
+Location: https://example.com/media/2025/01/abc123-def456.jpg
+```
+
+#### Configuration Discovery
+
+Update `q=config` response to advertise media endpoint:
+```json
+{
+ "media-endpoint": "https://example.com/micropub/media",
+ "syndicate-to": []
+}
+```
+
+#### Photo Property Support
+
+Enable `photo` property in Micropub create requests:
+
+**URL Reference** (photo already uploaded or external):
+```
+h=entry&content=Hello&photo=https://example.com/media/2025/01/abc.jpg
+```
+
+**JSON with Alt Text**:
+```json
+{
+ "type": ["h-entry"],
+ "properties": {
+ "content": ["Hello world"],
+ "photo": [{
+ "value": "https://example.com/media/2025/01/abc.jpg",
+ "alt": "A beautiful sunset"
+ }]
+ }
+}
+```
+
+**Multiple Photos**:
+```json
+{
+ "type": ["h-entry"],
+ "properties": {
+ "content": ["Photo gallery"],
+ "photo": [
+ "https://example.com/media/2025/01/photo1.jpg",
+ "https://example.com/media/2025/01/photo2.jpg"
+ ]
+ }
+}
+```
+
+#### Implementation Details
+
+**New Route**: `starpunk/routes/micropub.py`
+```python
+@bp.route('/media', methods=['POST'])
+def media_endpoint():
+ # Verify bearer token (media or create scope)
+ # Extract file from multipart/form-data
+ # Call save_media() from media.py
+ # Return 201 with Location header
+```
+
+**Modified**: `starpunk/micropub.py`
+- Update `get_micropub_config()` to return media-endpoint URL
+- Add `extract_photos()` function for photo property parsing
+- Modify `handle_create()` to process photo URLs and attach to note
+
+**Scope Validation**:
+- Media endpoint requires `media` or `create` scope
+- Photo property in create requests requires `create` scope
+
+---
+
+### 2. Large Image Support (>10MB)
+
+Remove the 10MB file size rejection and instead automatically resize large images to fit within acceptable limits.
+
+#### Current Behavior (v1.2.0)
+- Files >10MB are rejected with error
+- Files β€10MB are accepted and resized if >2048px
+
+#### New Behavior (v1.4.0)
+- Files of any reasonable size accepted (new limit: 50MB)
+- Images >10MB are automatically resized more aggressively
+- Final output always β€10MB after optimization
+
+#### Resize Strategy
+
+| Input Size | Max Dimension | Quality | Target Output |
+|------------|---------------|---------|---------------|
+| β€10MB | 2048px | 95% | β€5MB typical |
+| 10-25MB | 1600px | 90% | β€5MB target |
+| 25-50MB | 1280px | 85% | β€5MB target |
+| >50MB | Rejected | - | Error message |
+
+#### Implementation Details
+
+**Modified**: `starpunk/media.py`
+
+```python
+def validate_image(file_data, filename):
+ """Updated validation with higher limit."""
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB (was 10MB)
+ # ... rest of validation
+
+def optimize_image(image_data, original_size=None):
+ """Enhanced optimization based on input size."""
+ # Determine resize parameters based on original_size
+ # More aggressive resize for larger files
+ # Quality reduction for very large files
+ # Iterative optimization if output still too large
+```
+
+**Quality Iteration**:
+If first pass produces >10MB output:
+1. Reduce max dimension by 20%
+2. Reduce quality by 5%
+3. Repeat until β€10MB or min quality (70%) reached
+
+#### User Experience
+- No rejection for reasonable photo sizes
+- Transparent optimization (user doesn't need to pre-resize)
+- Warning in response if significant quality reduction applied
+- Original dimensions preserved in EXIF if possible
+
+---
+
+### 3. Enhanced Feed Media Support
+
+Implement ADR-059 Phase A: Multiple image sizes and complete Media RSS implementation.
+
+#### Multiple Image Sizes (Thumbnails)
+
+Generate multiple renditions on upload:
+
+| Variant | Dimensions | Use Case |
+|---------|------------|----------|
+| `thumb` | 150Γ150 (square crop) | Thumbnails, previews |
+| `small` | 320px width | Mobile, low bandwidth |
+| `medium` | 640px width | Standard display |
+| `large` | 1280px width | High-res display |
+| `original` | As uploaded (β€2048px) | Full quality |
+
+**Database Schema** (new migration):
+```sql
+CREATE TABLE media_variants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_id INTEGER NOT NULL,
+ variant_type TEXT NOT NULL, -- 'thumb', 'small', 'medium', 'large', 'original'
+ path TEXT NOT NULL,
+ width INTEGER NOT NULL,
+ height INTEGER NOT NULL,
+ size_bytes INTEGER NOT NULL,
+ FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
+ UNIQUE(media_id, variant_type)
+);
+
+CREATE INDEX idx_media_variants_media ON media_variants(media_id);
+```
+
+**Storage Structure**:
+```
+/media/2025/01/
+βββ abc123.jpg # Original/large
+βββ abc123_medium.jpg # 640px
+βββ abc123_small.jpg # 320px
+βββ abc123_thumb.jpg # 150Γ150
+```
+
+#### Complete Media RSS Implementation
+
+Enhance RSS feeds with full Media RSS specification support:
+
+```xml
+
+ My Photo Post
+
+
+
+
+
+
+ Image caption here
+
+```
+
+**New Media RSS Elements**:
+- `` - Container for multiple renditions
+- `` with full attributes (width, height, fileSize, medium, isDefault)
+- `` with dimensions
+- `` for captions (type="plain")
+
+#### Enhanced JSON Feed Attachments
+
+```json
+{
+ "items": [{
+ "id": "...",
+ "image": "https://.../large.jpg",
+ "attachments": [
+ {
+ "url": "https://.../large.jpg",
+ "mime_type": "image/jpeg",
+ "title": "Image caption",
+ "size_in_bytes": 245760
+ }
+ ],
+ "_starpunk": {
+ "media_variants": {
+ "thumb": "https://.../thumb.jpg",
+ "small": "https://.../small.jpg",
+ "medium": "https://.../medium.jpg",
+ "large": "https://.../large.jpg"
+ }
+ }
+ }]
+}
+```
+
+#### ATOM Feed Enclosures
+
+Add proper enclosure links to ATOM entries:
+```xml
+
+
+
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Large Image Support (4-6 hours)
+- Update `validate_image()` with 50MB limit
+- Implement tiered resize strategy in `optimize_image()`
+- Add iterative quality reduction
+- Update tests for new limits
+
+### Phase 2: Image Variants (8-12 hours)
+- Create migration for `media_variants` table
+- Implement variant generation in `save_media()`
+- Update `get_note_media()` to include variants
+- Add variant serving routes
+- Update templates for responsive images
+
+### Phase 3: Micropub Media Endpoint (6-8 hours)
+- Create `/micropub/media` route
+- Update `q=config` response
+- Implement `photo` property parsing
+- Update `handle_create()` for photo attachment
+- Add scope validation for media uploads
+
+### Phase 4: Enhanced Feed Media (6-8 hours)
+- Update RSS generator for `` and variants
+- Update JSON Feed for variants in `_starpunk` extension
+- Add ATOM enclosure links
+- Update feed tests
+
+### Phase 5: Testing & Documentation (4-6 hours)
+- Comprehensive test suite for all new features
+- Update API documentation
+- Update architecture documentation
+- Update CHANGELOG
+
+**Total Estimated Effort**: 28-40 hours
+
+---
+
+## Technical Notes
+
+### Backward Compatibility
+- Existing media continues to work (no variants, single size)
+- Feeds gracefully handle notes with/without variants
+- Micropub clients without media support continue to work
+
+### Performance Considerations
+- Variant generation is synchronous on upload (acceptable for single-user)
+- Consider background processing for v1.5.0 if needed
+- Cache headers on all media variants (1 year immutable)
+
+### Storage Impact
+- ~4x storage per image (thumb + small + medium + large)
+- Consider cleanup of unused uploaded media
+- Document storage requirements in deployment guide
+
+### Security
+- Media endpoint requires valid bearer token
+- File type validation (images only for v1.4.0)
+- UUID filenames prevent enumeration
+- Path traversal protection maintained
+
+---
+
+## Out of Scope (Deferred)
+
+### v1.5.0 or Later
+- Audio/podcast support (ADR-059 Phase B)
+- Video support (ADR-059 Phase C)
+- Micropub update/delete operations
+- Background media processing
+- CDN integration
+
+### Not Planned
+- External media hosting (Cloudinary, etc.)
+- Media transcoding
+- Live photo/HEIC support
+
+---
+
+## Dependencies
+
+- v1.3.x complete (tags feature)
+- Pillow library (already installed)
+- No new external dependencies
+
+## Success Criteria
+
+1. Micropub clients can upload media via media endpoint
+2. Photos attached to notes via Micropub `photo` property
+3. Images >10MB accepted and resized appropriately
+4. All image variants generated and served
+5. Feed readers see proper Media RSS with variants
+6. All existing tests continue to pass
+7. New comprehensive test coverage for media features
+
+## Related Documentation
+
+- [W3C Micropub Specification](https://www.w3.org/TR/micropub/)
+- [Media RSS Specification](https://www.rssboard.org/media-rss)
+- ADR-057: Media Attachment Model
+- ADR-058: Image Optimization Strategy
+- ADR-059: Full Feed Media Standardization
+- `docs/architecture/syndication-architecture.md`
diff --git a/docs/releases/INDEX.md b/docs/releases/INDEX.md
deleted file mode 100644
index 8fa56d6..0000000
--- a/docs/releases/INDEX.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Release Documentation Index
-
-This directory contains release-specific documentation, release notes, and version information.
-
-## Release Documentation
-
-- **[v1.0.1-hotfix-plan.md](v1.0.1-hotfix-plan.md)** - v1.0.1 hotfix plan and details
-
-## Release Process
-
-1. **Prepare Release**
- - Update version numbers
- - Update CHANGELOG.md
- - Run full test suite
- - Build container
-
-2. **Tag Release**
- - Create git tag matching version
- - Push tag to repository
-
-3. **Deploy**
- - Build and push container image
- - Deploy to production
- - Monitor for issues
-
-4. **Announce**
- - Post release notes
- - Update documentation
- - Notify users
-
-## Version History
-
-See [CHANGELOG.md](../../CHANGELOG.md) for complete version history.
-
-See [docs/projectplan/ROADMAP.md](../projectplan/ROADMAP.md) for future releases.
-
-## Related Documentation
-- **[../standards/versioning-strategy.md](../standards/versioning-strategy.md)** - Versioning guidelines
-- **[../standards/version-implementation-guide.md](../standards/version-implementation-guide.md)** - How to implement versions
-- **[CHANGELOG.md](../../CHANGELOG.md)** - Change log
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
diff --git a/docs/reports/INDEX.md b/docs/reports/INDEX.md
deleted file mode 100644
index f24a632..0000000
--- a/docs/reports/INDEX.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Implementation Reports Index
-
-This directory contains implementation reports created by developers for architect review. Reports document completed work, implementation details, test results, and decisions made during development.
-
-## Report Format
-
-Reports typically include:
-- **Date**: YYYY-MM-DD-description.md format
-- **Summary**: What was implemented
-- **Technical Details**: How it was implemented
-- **Test Results**: Coverage and test outcomes
-- **Issues Encountered**: Problems and solutions
-- **Next Steps**: Follow-up tasks
-
-## All Reports (Chronological)
-
-### November 2025
-
-#### v1.1.0 Implementation
-- **[2025-11-25-v1.0.1-micropub-url-fix.md](2025-11-25-v1.0.1-micropub-url-fix.md)** - Micropub URL double-slash fix
-
-#### v1.0.0 Implementation & Fixes
-- **[2025-11-24-v1.0.0-rc.5-implementation.md](2025-11-24-v1.0.0-rc.5-implementation.md)** - RC.5 implementation
-- **[2025-11-24-phase1-indieauth-server-removal.md](2025-11-24-phase1-indieauth-server-removal.md)** - Custom IndieAuth server removal
-- **[2025-11-24-indieauth-removal-complete.md](2025-11-24-indieauth-removal-complete.md)** - IndieAuth removal completion
-- **[2025-11-24-endpoint-discovery-analysis.md](2025-11-24-endpoint-discovery-analysis.md)** - Endpoint discovery analysis
-- **[2025-11-24-migration-fix-v1.0.0-rc.2.md](2025-11-24-migration-fix-v1.0.0-rc.2.md)** - Migration fix for RC.2
-- **[2025-11-24-migration-detection-hotfix-rc3.md](2025-11-24-migration-detection-hotfix-rc3.md)** - Migration detection hotfix
-
-#### Phase 5 Implementation
-- **[2025-11-19-container-implementation-summary.md](2025-11-19-container-implementation-summary.md)** - Container deployment
-- **[2025-11-19-migration-system-implementation-report.md](2025-11-19-migration-system-implementation-report.md)** - Migration system
-- **[2025-11-19-migration-system-implementation-guidance.md](2025-11-19-migration-system-implementation-guidance.md)** - Migration guidance
-- **[2025-11-19-migration-implementation-quick-reference.md](2025-11-19-migration-implementation-quick-reference.md)** - Quick reference
-
-#### Phase 1-4 Implementation
-- **[2025-11-18-auth-redirect-loop-fix.md](2025-11-18-auth-redirect-loop-fix.md)** - Auth redirect loop resolution
-- **[2025-11-18-quickfix-auth-loop.md](2025-11-18-quickfix-auth-loop.md)** - Quick fix implementation
-
-### Specific Feature Reports
-
-#### Authentication & IndieAuth
-- **[indieauth-client-discovery-analysis.md](indieauth-client-discovery-analysis.md)** - Client discovery analysis
-- **[indieauth-client-discovery-fix-implementation.md](indieauth-client-discovery-fix-implementation.md)** - Fix implementation
-- **[indieauth-client-discovery-root-cause-analysis.md](indieauth-client-discovery-root-cause-analysis.md)** - Root cause
-- **[indieauth-detailed-logging-implementation.md](indieauth-detailed-logging-implementation.md)** - Logging implementation
-- **[indieauth-fix-summary.md](indieauth-fix-summary.md)** - Fix summary
-- **[indieauth-removal-analysis.md](indieauth-removal-analysis.md)** - Removal analysis
-- **[indieauth-removal-questions.md](indieauth-removal-questions.md)** - Q&A
-- **[indieauth-spec-url-standardization-2025-11-24.md](indieauth-spec-url-standardization-2025-11-24.md)** - URL standardization
-
-#### Database & Migrations
-- **[database-migration-conflict-diagnosis.md](database-migration-conflict-diagnosis.md)** - Conflict diagnosis
-- **[migration-failure-diagnosis-v1.0.0-rc.1.md](migration-failure-diagnosis-v1.0.0-rc.1.md)** - Failure diagnosis
-- **[migration-race-condition-fix-implementation.md](migration-race-condition-fix-implementation.md)** - Race condition fix
-- **[v1.0.0-rc.5-migration-race-condition-implementation.md](v1.0.0-rc.5-migration-race-condition-implementation.md)** - RC.5 migration fix
-
-#### Micropub
-- **[micropub-401-diagnosis.md](micropub-401-diagnosis.md)** - 401 error diagnosis
-- **[micropub-v1-implementation-progress.md](micropub-v1-implementation-progress.md)** - Implementation progress
-
-#### Bug Fixes
-- **[custom-slug-bug-diagnosis.md](custom-slug-bug-diagnosis.md)** - Custom slug bug
-- **[custom-slug-bug-implementation.md](custom-slug-bug-implementation.md)** - Bug fix
-- **[delete-nonexistent-note-error-analysis.md](delete-nonexistent-note-error-analysis.md)** - Delete error
-- **[delete-route-404-fix-implementation.md](delete-route-404-fix-implementation.md)** - 404 fix
-- **[delete-route-fix-summary.md](delete-route-fix-summary.md)** - Fix summary
-- **[delete-route-implementation-spec.md](delete-route-implementation-spec.md)** - Implementation spec
-
-#### Testing
-- **[2025-11-19-todo-test-updates.md](2025-11-19-todo-test-updates.md)** - Test updates
-- **[test-failure-analysis-deleted-at-attribute.md](test-failure-analysis-deleted-at-attribute.md)** - Test failure analysis
-- **[phase-4-test-fixes.md](phase-4-test-fixes.md)** - Phase 4 test fixes
-
-### Version-Specific Reports
-
-#### ADR Implementation
-- **[ADR-025-implementation-report.md](ADR-025-implementation-report.md)** - ADR-025 implementation
-- **[ADR-025-implementation-summary.md](ADR-025-implementation-summary.md)** - Summary
-- **[ADR-025-versioning-guidance.md](ADR-025-versioning-guidance.md)** - Versioning guidance
-
-#### Phase Implementation
-- **[phase-2.1-implementation-20251118.md](phase-2.1-implementation-20251118.md)** - Phase 2.1
-- **[phase-2-implementation-report.md](phase-2-implementation-report.md)** - Phase 2
-- **[phase-3-authentication-20251118.md](phase-3-authentication-20251118.md)** - Phase 3
-- **[phase-4-architectural-assessment-20251118.md](phase-4-architectural-assessment-20251118.md)** - Phase 4 assessment
-- **[phase-5-container-implementation-report.md](phase-5-container-implementation-report.md)** - Phase 5
-- **[phase-5-pre-implementation-review.md](phase-5-pre-implementation-review.md)** - Pre-implementation review
-- **[phase-5-rss-implementation-20251119.md](phase-5-rss-implementation-20251119.md)** - RSS implementation
-
-#### Version Releases
-- **[v0.9.1-implementation-report.md](v0.9.1-implementation-report.md)** - v0.9.1 release
-- **[v1.0.0-rc.1-hotfix-instructions.md](v1.0.0-rc.1-hotfix-instructions.md)** - RC.1 hotfix
-- **[v1.1.0-implementation-plan.md](v1.1.0-implementation-plan.md)** - v1.1.0 plan
-- **[v1.1.0-implementation-report.md](v1.1.0-implementation-report.md)** - v1.1.0 report
-
-### Special Reports
-- **[ARCHITECT-FINAL-ANALYSIS.md](ARCHITECT-FINAL-ANALYSIS.md)** - Comprehensive architectural analysis
-- **[implementation-guide-expose-deleted-at.md](implementation-guide-expose-deleted-at.md)** - Implementation guide
-- **[oauth-metadata-implementation-2025-11-19.md](oauth-metadata-implementation-2025-11-19.md)** - OAuth metadata
-- **[identity-domain-validation-2025-11-19.md](identity-domain-validation-2025-11-19.md)** - Identity validation
-- **[setup-complete-2025-11-18.md](setup-complete-2025-11-18.md)** - Setup completion
-
-## How to Use Reports
-
-### For Architects
-- Review reports to verify implementation quality
-- Check that decisions align with ADRs
-- Identify patterns for future standards
-
-### For Developers
-- Learn from past implementations
-- Find solutions to similar problems
-- Understand implementation context
-
-### For Project Management
-- Track implementation progress
-- Understand what was delivered
-- Plan future work based on lessons learned
-
-## Creating New Reports
-
-When completing work, create a report with:
-1. **Filename**: `YYYY-MM-DD-brief-description.md`
-2. **Summary**: What was done
-3. **Implementation**: Technical details
-4. **Testing**: Test results and coverage
-5. **Issues**: Problems encountered and solutions
-6. **Next Steps**: Follow-up tasks
-
-## Related Documentation
-- **[../architecture/](../architecture/)** - System architecture
-- **[../decisions/](../decisions/)** - ADRs referenced in reports
-- **[../design/](../design/)** - Design specs implemented
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
-**Total Reports**: 57
diff --git a/docs/reviews/INDEX.md b/docs/reviews/INDEX.md
deleted file mode 100644
index b220562..0000000
--- a/docs/reviews/INDEX.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Architectural Reviews Index
-
-This directory contains architectural reviews, design critiques, and retrospectives conducted by the architect agent.
-
-## Phase Reviews
-
-- **[phase-2-architectural-review.md](phase-2-architectural-review.md)** - Phase 2 architecture review
-- **[phase-3-authentication-architectural-review.md](phase-3-authentication-architectural-review.md)** - Phase 3 authentication review
-- **[phase-5-container-architectural-review.md](phase-5-container-architectural-review.md)** - Phase 5 container deployment review
-- **[phase-5-approval-summary.md](phase-5-approval-summary.md)** - Phase 5 approval summary
-
-## Feature Reviews
-
-### Micropub
-- **[micropub-phase1-architecture-review.md](micropub-phase1-architecture-review.md)** - Phase 1 Micropub review
-- **[micropub-phase3-architecture-review.md](micropub-phase3-architecture-review.md)** - Phase 3 Micropub review
-
-### Error Handling
-- **[error-handling-rest-vs-web-patterns.md](error-handling-rest-vs-web-patterns.md)** - REST vs Web error handling patterns
-
-## Purpose of Reviews
-
-Architectural reviews ensure:
-- Design quality and consistency
-- Adherence to standards
-- Alignment with project philosophy
-- Technical soundness
-- Maintainability
-
-## Related Documentation
-- **[../decisions/](../decisions/)** - ADRs resulting from reviews
-- **[../architecture/](../architecture/)** - Architectural documentation
-- **[../reports/](../reports/)** - Implementation reports
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
diff --git a/docs/security/INDEX.md b/docs/security/INDEX.md
deleted file mode 100644
index 0f0f1f0..0000000
--- a/docs/security/INDEX.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Security Documentation Index
-
-This directory contains security-related documentation, vulnerability analyses, and security best practices.
-
-## Security Guides
-
-- **[indieauth-endpoint-discovery-security.md](indieauth-endpoint-discovery-security.md)** - Security considerations for IndieAuth endpoint discovery
-
-## Security Topics
-
-### Authentication & Authorization
-- IndieAuth security
-- Token management
-- Session security
-
-### Data Protection
-- Secure storage
-- Encryption
-- Data privacy
-
-### Network Security
-- HTTPS enforcement
-- Endpoint validation
-- CSRF protection
-
-## Security Principles
-
-StarPunk follows these security principles:
-- **Secure by Default**: Security is enabled by default
-- **Minimal Attack Surface**: Fewer features mean fewer vulnerabilities
-- **Defense in Depth**: Multiple layers of security
-- **Fail Closed**: Deny access when uncertain
-- **Principle of Least Privilege**: Minimal permissions by default
-
-## Reporting Security Issues
-
-If you discover a security vulnerability:
-1. **Do NOT** create a public issue
-2. Email security details to project maintainer
-3. Allow time for patch before disclosure
-4. Coordinated disclosure benefits everyone
-
-## Related Documentation
-- **[../decisions/](../decisions/)** - Security-related ADRs
-- **[../standards/](../standards/)** - Security coding standards
-- **[../architecture/](../architecture/)** - Security architecture
-
----
-
-**Last Updated**: 2025-11-25
-**Maintained By**: Documentation Manager Agent
diff --git a/migrations/008_add_tags.sql b/migrations/008_add_tags.sql
new file mode 100644
index 0000000..40ab584
--- /dev/null
+++ b/migrations/008_add_tags.sql
@@ -0,0 +1,26 @@
+-- Migration 008: Add tag support for notes
+-- Version: 1.3.0
+-- Per microformats2 p-category specification
+
+-- Tags table (normalized tag storage)
+CREATE TABLE IF NOT EXISTS tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL, -- Normalized tag name (lowercase, trimmed)
+ display_name TEXT NOT NULL, -- Original display name (preserves case)
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Note-tag junction table
+CREATE TABLE IF NOT EXISTS note_tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ note_id INTEGER NOT NULL,
+ tag_id INTEGER NOT NULL,
+ FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
+ FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
+ UNIQUE(note_id, tag_id)
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
+CREATE INDEX IF NOT EXISTS idx_note_tags_note ON note_tags(note_id);
+CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_id);
diff --git a/starpunk/__init__.py b/starpunk/__init__.py
index 2ea04b8..6d411d6 100644
--- a/starpunk/__init__.py
+++ b/starpunk/__init__.py
@@ -325,5 +325,5 @@ def create_app(config=None):
# Package version (Semantic Versioning 2.0.0)
# See docs/standards/versioning-strategy.md for details
-__version__ = "1.2.0"
-__version_info__ = (1, 2, 0)
+__version__ = "1.3.0rc1"
+__version_info__ = (1, 3, 0, "rc", 1)
diff --git a/starpunk/micropub.py b/starpunk/micropub.py
index 0e2a4e8..2b6b2e5 100644
--- a/starpunk/micropub.py
+++ b/starpunk/micropub.py
@@ -318,7 +318,8 @@ def handle_create(data: dict, token_info: dict):
content=content,
published=True, # Micropub posts are published by default
created_at=published_date,
- custom_slug=custom_slug
+ custom_slug=custom_slug,
+ tags=tags if tags else None # Pass tags to create_note (v1.3.0)
)
# Build permalink URL
@@ -403,9 +404,9 @@ def handle_query(args: dict, token_info: dict):
if note.title:
mf2["properties"]["name"] = [note.title]
- # Tags not implemented in V1, skip category property
- # if hasattr(note, 'tags') and note.tags:
- # mf2["properties"]["category"] = note.tags
+ # Add tags if present (v1.3.0)
+ if note.tags:
+ mf2["properties"]["category"] = [tag["display_name"] for tag in note.tags]
return jsonify(mf2), 200
diff --git a/starpunk/models.py b/starpunk/models.py
index 98662e1..70c95a8 100644
--- a/starpunk/models.py
+++ b/starpunk/models.py
@@ -121,6 +121,11 @@ class Note:
default=None, repr=False, compare=False, init=False
)
+ # Cached tags (loaded separately, not from database row)
+ _cached_tags: Optional[list[dict]] = field(
+ default=None, repr=False, compare=False, init=False
+ )
+
@classmethod
def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
"""
@@ -358,8 +363,27 @@ class Note:
"""
return self.published
+ @property
+ def tags(self) -> list[dict]:
+ """
+ Get note tags (lazy-loaded, but prefer pre-loading in routes)
+
+ Routes should pre-load tags using:
+ object.__setattr__(note, '_cached_tags', tags)
+
+ This property exists as a fallback for lazy loading.
+
+ Returns:
+ List of tag dicts with 'name' and 'display_name'
+ """
+ if self._cached_tags is None:
+ from starpunk.tags import get_note_tags
+ tags = get_note_tags(self.id)
+ object.__setattr__(self, "_cached_tags", tags)
+ return self._cached_tags
+
def to_dict(
- self, include_content: bool = False, include_html: bool = False
+ self, include_content: bool = False, include_html: bool = False, include_tags: bool = False
) -> dict[str, Any]:
"""
Serialize note to dictionary
@@ -370,6 +394,7 @@ class Note:
Args:
include_content: Include markdown content in output
include_html: Include rendered HTML in output
+ include_tags: Include tags in output (v1.3.0)
Returns:
Dictionary with note data
@@ -410,6 +435,9 @@ class Note:
if include_html:
data["html"] = self.html
+ if include_tags:
+ data["tags"] = [tag["display_name"] for tag in self.tags]
+
return data
def verify_integrity(self) -> bool:
diff --git a/starpunk/notes.py b/starpunk/notes.py
index 7cb7415..5440092 100644
--- a/starpunk/notes.py
+++ b/starpunk/notes.py
@@ -134,7 +134,11 @@ def _get_existing_slugs(db) -> set[str]:
def create_note(
- content: str, published: bool = False, created_at: Optional[datetime] = None, custom_slug: Optional[str] = None
+ content: str,
+ published: bool = False,
+ created_at: Optional[datetime] = None,
+ custom_slug: Optional[str] = None,
+ tags: Optional[list[str]] = None
) -> Note:
"""
Create a new note
@@ -148,6 +152,7 @@ def create_note(
published: Whether the note should be published (default: False)
created_at: Creation timestamp (default: current UTC time)
custom_slug: Optional custom slug (from Micropub mp-slug property)
+ tags: Optional list of tag display names (v1.3.0)
Returns:
Note object with all metadata and content loaded
@@ -294,7 +299,16 @@ def create_note(
# Create Note object
note = Note.from_row(row, data_dir)
- # 9. UPDATE FTS INDEX (if available)
+ # 9. ADD TAGS (v1.3.0)
+ if tags:
+ try:
+ from starpunk.tags import add_tags_to_note
+ add_tags_to_note(note_id, tags)
+ except Exception as e:
+ # Tag addition failure should not prevent note creation
+ current_app.logger.warning(f"Failed to add tags to note {slug}: {e}")
+
+ # 10. UPDATE FTS INDEX (if available)
try:
from starpunk.search import update_fts_index, has_fts_table
db_path = Path(current_app.config["DATABASE_PATH"])
@@ -540,6 +554,7 @@ def update_note(
id: Optional[int] = None,
content: Optional[str] = None,
published: Optional[bool] = None,
+ tags: Optional[list[str]] = None
) -> Note:
"""
Update a note's content and/or published status
@@ -553,6 +568,7 @@ def update_note(
id: Note ID to update (mutually exclusive with slug)
content: New markdown content (None = no change)
published: New published status (None = no change)
+ tags: New tags list (None = no change, [] = remove all tags) (v1.3.0)
Returns:
Updated Note object with new content and metadata
@@ -608,8 +624,8 @@ def update_note(
if slug is not None and id is not None:
raise ValueError("Cannot provide both slug and id")
- if content is None and published is None:
- raise ValueError("Must provide at least one of content or published to update")
+ if content is None and published is None and tags is None:
+ raise ValueError("Must provide at least one of content, published, or tags to update")
# Validate content if provided
if content is not None:
@@ -695,7 +711,16 @@ def update_note(
f"Failed to update note: {existing_note.slug}",
)
- # 6. UPDATE FTS INDEX (if available and content changed)
+ # 6. UPDATE TAGS (v1.3.0)
+ if tags is not None:
+ try:
+ from starpunk.tags import add_tags_to_note
+ add_tags_to_note(existing_note.id, tags)
+ except Exception as e:
+ # Tag update failure should not prevent note update
+ current_app.logger.warning(f"Failed to update tags for note {existing_note.slug}: {e}")
+
+ # 7. UPDATE FTS INDEX (if available and content changed)
if content is not None:
try:
from starpunk.search import update_fts_index, has_fts_table
@@ -707,7 +732,7 @@ def update_note(
# FTS update failure should not prevent note update
current_app.logger.warning(f"Failed to update FTS index for note {existing_note.slug}: {e}")
- # 7. RETURN UPDATED NOTE
+ # 8. RETURN UPDATED NOTE
updated_note = get_note(slug=existing_note.slug, load_content=True)
return updated_note
diff --git a/starpunk/routes/admin.py b/starpunk/routes/admin.py
index 5b67848..69fc38f 100644
--- a/starpunk/routes/admin.py
+++ b/starpunk/routes/admin.py
@@ -78,6 +78,7 @@ def create_note_submit():
custom_slug: Optional custom slug (v1.2.0 Phase 1)
media_files: Multiple file upload (v1.2.0 Phase 3)
captions[]: Captions for each media file (v1.2.0 Phase 3)
+ tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
@@ -85,21 +86,27 @@ def create_note_submit():
Decorator: @require_auth
"""
from starpunk.media import save_media, attach_media_to_note
+ from starpunk.tags import parse_tag_input
content = request.form.get("content", "").strip()
published = "published" in request.form
custom_slug = request.form.get("custom_slug", "").strip()
+ tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.new_note_form"))
+ # Parse tags (v1.3.0 Phase 3)
+ tags = parse_tag_input(tags_input)
+
try:
# Create note first (per Q4)
note = create_note(
content,
published=published,
- custom_slug=custom_slug if custom_slug else None
+ custom_slug=custom_slug if custom_slug else None,
+ tags=tags if tags else None
)
# Handle media uploads (v1.2.0 Phase 3)
@@ -167,12 +174,18 @@ def edit_note_form(note_id: int):
Decorator: @require_auth
Template: templates/admin/edit.html
"""
+ from starpunk.tags import get_note_tags
+
note = get_note(id=note_id)
if not note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
+ # Pre-load tags for the edit form (v1.3.0 Phase 3)
+ tags = get_note_tags(note.id)
+ object.__setattr__(note, '_cached_tags', tags)
+
return render_template("admin/edit.html", note=note)
@@ -191,12 +204,15 @@ def update_note_submit(note_id: int):
Form data:
content: Updated markdown content (required)
published: Checkbox for published status (optional)
+ tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
+ from starpunk.tags import parse_tag_input
+
# Check if note exists first
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
@@ -205,13 +221,22 @@ def update_note_submit(note_id: int):
content = request.form.get("content", "").strip()
published = "published" in request.form
+ tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
+ # Parse tags (v1.3.0 Phase 3)
+ tags = parse_tag_input(tags_input)
+
try:
- note = update_note(id=note_id, content=content, published=published)
+ note = update_note(
+ id=note_id,
+ content=content,
+ published=published,
+ tags=tags if tags else None
+ )
flash(f"Note updated: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
diff --git a/starpunk/routes/public.py b/starpunk/routes/public.py
index 04c856f..4a6dc04 100644
--- a/starpunk/routes/public.py
+++ b/starpunk/routes/public.py
@@ -228,16 +228,21 @@ def index():
Microformats: h-feed containing h-entry items with u-photo
"""
from starpunk.media import get_note_media
+ from starpunk.tags import get_note_tags
# Get recent published notes (limit 20)
notes = list_notes(published_only=True, limit=20)
- # Attach media to each note for display
+ # Attach media and tags to each note for display
for note in notes:
media = get_note_media(note.id)
# Use object.__setattr__ since Note is frozen dataclass
object.__setattr__(note, 'media', media)
+ # Attach tags (v1.3.0 Phase 3)
+ tags = get_note_tags(note.id)
+ object.__setattr__(note, '_cached_tags', tags)
+
return render_template("index.html", notes=notes)
@@ -259,6 +264,7 @@ def note(slug: str):
Microformats: h-entry
"""
from starpunk.media import get_note_media
+ from starpunk.tags import get_note_tags
# Get note by slug
note_obj = get_note(slug=slug)
@@ -274,9 +280,60 @@ def note(slug: str):
# Use object.__setattr__ since Note is frozen dataclass
object.__setattr__(note_obj, 'media', media)
+ # Attach tags to note (v1.3.0 Phase 3)
+ tags = get_note_tags(note_obj.id)
+ object.__setattr__(note_obj, '_cached_tags', tags)
+
return render_template("note.html", note=note_obj)
+@bp.route("/tag/")
+def tag(tag: str):
+ """
+ Tag archive page
+
+ Lists all notes with a specific tag.
+
+ Args:
+ tag: Tag name (will be normalized before lookup)
+
+ Returns:
+ Rendered tag archive template
+
+ Raises:
+ 404: If tag doesn't exist
+
+ Note:
+ URL accepts any format - normalized before lookup.
+ /tag/IndieWeb and /tag/indieweb resolve to same tag.
+
+ Template: templates/tag.html
+ Microformats: h-feed containing h-entry items
+ """
+ from starpunk.tags import get_notes_by_tag, get_tag_by_name, normalize_tag
+ from starpunk.media import get_note_media
+
+ # Normalize the tag name before lookup
+ normalized_name, _ = normalize_tag(tag)
+
+ tag_info = get_tag_by_name(normalized_name)
+ if not tag_info:
+ abort(404)
+
+ notes = get_notes_by_tag(normalized_name)
+
+ # Attach media to each note (tags already pre-loaded by get_notes_by_tag)
+ for note in notes:
+ media = get_note_media(note.id)
+ object.__setattr__(note, 'media', media)
+
+ return render_template(
+ "tag.html",
+ tag=tag_info,
+ notes=notes
+ )
+
+
@bp.route("/feed")
def feed():
"""
diff --git a/starpunk/tags.py b/starpunk/tags.py
new file mode 100644
index 0000000..21714b5
--- /dev/null
+++ b/starpunk/tags.py
@@ -0,0 +1,243 @@
+"""
+Tag management for StarPunk
+
+Functions:
+ normalize_tag: Normalize tag string for storage
+ get_or_create_tag: Get existing tag or create new one
+ add_tags_to_note: Associate tags with a note
+ remove_tags_from_note: Remove tag associations
+ get_note_tags: Get all tags for a note
+ get_notes_by_tag: Get all notes with a specific tag
+"""
+
+import re
+from typing import Optional
+
+from flask import current_app
+
+from starpunk.database import get_db
+
+
+def normalize_tag(tag: str) -> tuple[str, str]:
+ """
+ Normalize a tag string
+
+ Args:
+ tag: Raw tag string
+
+ Returns:
+ Tuple of (normalized_name, display_name)
+
+ Examples:
+ >>> normalize_tag(" IndieWeb ")
+ ('indieweb', 'IndieWeb')
+ >>> normalize_tag("Machine Learning")
+ ('machine-learning', 'Machine Learning')
+ """
+ # Step 1: Strip whitespace for display_name
+ display_name = tag.strip()
+
+ # Step 2: Strip leading # characters
+ normalized = display_name.lstrip('#')
+
+ # Step 3: Replace spaces and slashes with hyphens
+ normalized = normalized.replace(' ', '-').replace('/', '-')
+
+ # Step 4: Remove characters not in [a-zA-Z0-9_-]
+ normalized = re.sub(r'[^a-zA-Z0-9_-]', '', normalized)
+
+ # Step 5: Collapse consecutive hyphens to single hyphen
+ normalized = re.sub(r'-+', '-', normalized)
+
+ # Step 6: Strip leading/trailing hyphens
+ normalized = normalized.strip('-')
+
+ # Step 7: Convert to lowercase for normalized name
+ normalized = normalized.lower()
+
+ return normalized, display_name
+
+
+def get_or_create_tag(display_name: str) -> int:
+ """
+ Get existing tag ID or create new tag
+
+ Args:
+ display_name: Tag display name
+
+ Returns:
+ Tag ID
+ """
+ db = get_db(current_app)
+ normalized_name, clean_display = normalize_tag(display_name)
+
+ # Try to find existing tag
+ tag = db.execute(
+ "SELECT id FROM tags WHERE name = ?",
+ (normalized_name,)
+ ).fetchone()
+
+ if tag:
+ return tag['id']
+
+ # Create new tag
+ cursor = db.execute(
+ "INSERT INTO tags (name, display_name) VALUES (?, ?)",
+ (normalized_name, clean_display)
+ )
+ db.commit()
+ return cursor.lastrowid
+
+
+def add_tags_to_note(note_id: int, tags: list[str]) -> None:
+ """
+ Associate tags with a note
+
+ Replaces all existing tags for the note.
+
+ Args:
+ note_id: Note database ID
+ tags: List of tag display names
+ """
+ db = get_db(current_app)
+
+ # Remove existing tags
+ db.execute("DELETE FROM note_tags WHERE note_id = ?", (note_id,))
+
+ # Add new tags
+ for tag_display in tags:
+ tag_id = get_or_create_tag(tag_display)
+ db.execute(
+ "INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)",
+ (note_id, tag_id)
+ )
+
+ db.commit()
+
+
+def get_note_tags(note_id: int) -> list[dict]:
+ """
+ Get all tags for a note
+
+ Args:
+ note_id: Note database ID
+
+ Returns:
+ List of tag dicts with 'name' and 'display_name'
+ Ordered alphabetically by display_name (case-insensitive)
+
+ Query should use: ORDER BY LOWER(tags.display_name) ASC
+ """
+ db = get_db(current_app)
+ tags = db.execute(
+ """
+ SELECT tags.name, tags.display_name
+ FROM tags
+ JOIN note_tags ON tags.id = note_tags.tag_id
+ WHERE note_tags.note_id = ?
+ ORDER BY LOWER(tags.display_name) ASC
+ """,
+ (note_id,)
+ ).fetchall()
+
+ return [dict(tag) for tag in tags]
+
+
+def get_tag_by_name(name: str) -> Optional[dict]:
+ """
+ Get tag by normalized name
+
+ Args:
+ name: Tag name (will be normalized before lookup)
+
+ Returns:
+ Tag dict with 'id', 'name', 'display_name' or None
+ """
+ db = get_db(current_app)
+ normalized_name, _ = normalize_tag(name)
+
+ tag = db.execute(
+ "SELECT id, name, display_name FROM tags WHERE name = ?",
+ (normalized_name,)
+ ).fetchone()
+
+ return dict(tag) if tag else None
+
+
+def get_notes_by_tag(tag_name: str) -> list:
+ """
+ Get all published notes with a specific tag
+
+ Args:
+ tag_name: Normalized tag name
+
+ Returns:
+ List of Note objects with tags pre-loaded
+ """
+ from starpunk.notes import get_note
+
+ db = get_db(current_app)
+ rows = db.execute(
+ """
+ SELECT DISTINCT notes.id
+ FROM notes
+ JOIN note_tags ON notes.id = note_tags.note_id
+ JOIN tags ON note_tags.tag_id = tags.id
+ WHERE tags.name = ? AND notes.published = 1
+ ORDER BY notes.created_at DESC
+ """,
+ (tag_name,)
+ ).fetchall()
+
+ notes = []
+ for row in rows:
+ note = get_note(id=row['id'])
+ if note:
+ # Pre-load tags
+ tags = get_note_tags(note.id)
+ object.__setattr__(note, '_cached_tags', tags)
+ notes.append(note)
+
+ return notes
+
+
+def parse_tag_input(input_string: str) -> list[str]:
+ """
+ Parse comma-separated tag input from admin form
+
+ Splits on commas, trims whitespace, filters empties,
+ deduplicates by normalized name (keeps first occurrence).
+
+ Args:
+ input_string: Comma-separated tags (e.g., "Python, IndieWeb, Web")
+
+ Returns:
+ List of unique tag display names
+
+ Examples:
+ >>> parse_tag_input("Python, IndieWeb, Web")
+ ['Python', 'IndieWeb', 'Web']
+ >>> parse_tag_input("Python, python, PYTHON")
+ ['Python'] # First occurrence wins
+ >>> parse_tag_input(" , , valid , ")
+ ['valid']
+ """
+ if not input_string:
+ return []
+
+ # Split on commas and strip whitespace
+ tags = [tag.strip() for tag in input_string.split(',')]
+
+ # Filter empty strings
+ tags = [tag for tag in tags if tag]
+
+ # Deduplicate by normalized name (keep first occurrence)
+ seen = set()
+ unique_tags = []
+ for tag in tags:
+ normalized, _ = normalize_tag(tag)
+ if normalized and normalized not in seen:
+ seen.add(normalized)
+ unique_tags.append(tag)
+
+ return unique_tags
diff --git a/templates/admin/edit.html b/templates/admin/edit.html
index 44a7966..2e1b02c 100644
--- a/templates/admin/edit.html
+++ b/templates/admin/edit.html
@@ -37,6 +37,16 @@
+
+
+
+ Separate multiple tags with commas. Leave blank to remove all tags.
+
{% endif %}
- {# Media display at TOP (v1.2.0 Phase 3, per ADR-057) #}
+ {# u-photo placement: Per draft spec, u-photo must be direct child of h-entry, #}
+ {# NOT inside e-content. Media is rendered ABOVE e-content to meet this requirement. #}
{{ display_media(note.media) }}
{# e-content: note content BELOW media (per ADR-057) #}
@@ -36,14 +37,29 @@
{% endif %}
+ {# Tags / Categories #}
+ {# rel="tag" per microformats2 p-category specification #}
+ {% if note.tags %}
+
+
+ {% if notes %}
+ {% for note in notes %}
+
+ {# Detect if note has explicit title (starts with # heading) - per Q22 #}
+ {% set has_explicit_title = note.content.strip().startswith('#') %}
+
+ {# p-name only if note has explicit title (per Q22) #}
+ {% if has_explicit_title %}
+