Files
StarPunk/docs/design/v1.2.0/feature-specification.md
Phil Skentelbery dd822a35b5 feat: v1.2.0-rc.1 - IndieWeb Features Release Candidate
Complete implementation of v1.2.0 "IndieWeb Features" release.

## Phase 1: Custom Slugs
- Optional custom slug field in note creation form
- Auto-sanitization (lowercase, hyphens only)
- Uniqueness validation with auto-numbering
- Read-only after creation to preserve permalinks
- Matches Micropub mp-slug behavior

## Phase 2: Author Discovery + Microformats2
- Automatic h-card discovery from IndieAuth identity URL
- 24-hour caching with graceful fallback
- Never blocks login (per ADR-061)
- Complete h-entry, h-card, h-feed markup
- All required Microformats2 properties
- rel-me links for identity verification
- Passes IndieWeb validation

## Phase 3: Media Upload
- Upload up to 4 images per note (JPEG, PNG, GIF, WebP)
- Automatic optimization with Pillow
  - Auto-resize to 2048px
  - EXIF orientation correction
  - 95% quality compression
- Social media-style layout (media top, text below)
- Optional captions for accessibility
- Integration with all feed formats (RSS, ATOM, JSON Feed)
- Date-organized storage with UUID filenames
- Immutable caching (1 year)

## Database Changes
- migrations/006_add_author_profile.sql - Author discovery cache
- migrations/007_add_media_support.sql - Media storage

## New Modules
- starpunk/author_discovery.py - h-card discovery and caching
- starpunk/media.py - Image upload, validation, optimization

## Documentation
- 4 new ADRs (056, 057, 058, 061)
- Complete design specifications
- Developer Q&A with 40+ questions answered
- 3 implementation reports
- 3 architect reviews (all approved)

## Testing
- 56 new tests for v1.2.0 features
- 842 total tests in suite
- All v1.2.0 feature tests passing

## Dependencies
- Added: mf2py (Microformats2 parser)
- Added: Pillow (image processing)

Version: 1.2.0-rc.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 15:02:20 -07:00

26 KiB
Raw Blame History

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:

<div class="form-group">
  <label for="slug">Custom Slug (Optional)</label>
  <input
    type="text"
    id="slug"
    name="slug"
    pattern="[a-z0-9-]+"
    maxlength="200"
    placeholder="leave-blank-for-auto-generation"
    {% if editing %}readonly{% endif %}
  >
  <small>URL-safe characters only (lowercase letters, numbers, hyphens)</small>
  {% if editing %}
  <small class="text-warning">Slugs cannot be changed after creation to preserve permalinks</small>
  {% endif %}
</div>

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)

-- 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:

<article class="note">
  <!-- Media displayed first -->
  {% if note.media %}
  <div class="media-attachments">
    {% if note.media|length == 1 %}
      <!-- Single image: full width -->
      <img src="{{ media.url }}" alt="{{ media.caption or '' }}" class="single-image">
    {% elif note.media|length == 2 %}
      <!-- Two images: side by side -->
      <div class="media-grid media-grid-2">
        {% for media in note.media %}
        <img src="{{ media.url }}" alt="{{ media.caption or '' }}">
        {% endfor %}
      </div>
    {% else %}
      <!-- 3-4 images: grid layout -->
      <div class="media-grid media-grid-{{ note.media|length }}">
        {% for media in note.media[:4] %}
        <img src="{{ media.url }}" alt="{{ media.caption or '' }}">
        {% endfor %}
      </div>
    {% endif %}
  </div>
  {% endif %}

  <!-- Text content displayed below media -->
  <div class="content">
    {{ note.html|safe }}
  </div>
</article>

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

<div class="form-group">
  <label for="media">Attach Images</label>
  <input
    type="file"
    id="media"
    name="media"
    accept="image/*"
    multiple
    class="media-upload"
  >
  <small>Accepted formats: JPG, PNG, GIF, WebP (max 10MB each, max 4 images)</small>

  <!-- Preview attached media with captions -->
  <div id="media-preview" class="media-preview">
    <!-- Thumbnails appear here after upload with caption fields -->
  </div>
</div>

<script>
// Handle media as attachments, not inline insertion
document.getElementById('media').addEventListener('change', async (e) => {
  const preview = document.getElementById('media-preview');
  const files = Array.from(e.target.files).slice(0, 4); // Max 4

  for (const file of files) {
    // Upload and show thumbnail
    const url = await uploadMedia(file);
    addMediaThumbnail(preview, url, file.name);
  }
});

function addMediaThumbnail(container, url, filename) {
  const thumb = document.createElement('div');
  thumb.className = 'media-thumb';
  thumb.innerHTML = `
    <img src="${url}" alt="${filename}">
    <input type="text" name="caption[]" placeholder="Caption (optional)" class="media-caption">
    <button type="button" class="remove-media" data-url="${url}">×</button>
    <input type="hidden" name="attached_media[]" value="${url}">
  `;
  container.appendChild(thumb);
}
</script>

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:

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:

@bp.route('/media/<year>/<month>/<filename>')
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:

@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:

<!-- Embed media as HTML in description with CDATA -->
<item>
  <title>Note Title</title>
  <description><![CDATA[
    <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 class="content">
      <p>Note text content here...</p>
    </div>
  ]]></description>
  <pubDate>...</pubDate>
</item>

Rationale: RSS <enclosure> only supports single items and is meant for podcasts/downloads. HTML in description is standard for blog posts with images.

ATOM 1.0 Strategy:

<!-- Multiple link elements with rel="enclosure" for each media item -->
<entry>
  <title>Note Title</title>
  <link rel="enclosure"
        type="image/jpeg"
        href="https://site.com/media/2025/01/image1.jpg"
        length="123456" />
  <link rel="enclosure"
        type="image/jpeg"
        href="https://site.com/media/2025/01/image2.jpg"
        length="234567" />
  <content type="html">
    &lt;div class="media"&gt;
      &lt;img src="https://site.com/media/2025/01/image1.jpg" /&gt;
      &lt;img src="https://site.com/media/2025/01/image2.jpg" /&gt;
    &lt;/div&gt;
    &lt;div&gt;Note text content...&lt;/div&gt;
  </content>
</entry>

Rationale: ATOM supports multiple <link rel="enclosure"> elements. We include both enclosures (for feed readers that understand them) AND HTML content (for universal display).

JSON Feed 1.1 Strategy:

{
  "id": "...",
  "title": "Note Title",
  "content_html": "<div class='media'>...</div><div>Note text...</div>",
  "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 <link rel="enclosure"> 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:

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
);
  1. 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)
  2. 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)
<div class="h-card">
  <a class="p-name u-url" href="{{ author.me_url }}">
    {{ author.name or author.me_url }}
  </a>
  {% if author.photo %}
  <img class="u-photo" src="{{ author.photo }}" alt="{{ author.name }}">
  {% endif %}
  {% if author.bio %}
  <p class="p-note">{{ author.bio }}</p>
  {% endif %}
</div>

Enhanced h-entry

Location: templates/note.html

Complete properties with discovered author and media support:

  • p-name (note title, if exists)
  • e-content (note content)
  • dt-published (creation date)
  • dt-updated (modification date)
  • u-url (permalink)
  • p-author (nested h-card with discovered info)
  • u-uid (unique identifier)
  • u-photo (multiple for multi-photo posts)
  • p-category (tags, future)
<article class="h-entry">
  <!-- Multiple u-photo for multi-photo posts (social media style) -->
  {% if note.media %}
    {% for media in note.media %}
    <img class="u-photo" src="{{ media.url }}" alt="{{ media.caption or '' }}">
    {% endfor %}
  {% endif %}

  <!-- Text content -->
  <div class="e-content">
    {{ note.html|safe }}
  </div>

  <!-- Title only if exists (most notes won't have titles) -->
  {% if note.has_explicit_title %}
  <h1 class="p-name">{{ note.title }}</h1>
  {% endif %}

  <footer>
    <a class="u-url u-uid" href="{{ url }}">
      <time class="dt-published" datetime="{{ iso_date }}">
        {{ formatted_date }}
      </time>
    </a>

    {% if note.updated_at %}
    <time class="dt-updated" datetime="{{ updated_iso }}">
      Updated: {{ updated_formatted }}
    </time>
    {% endif %}

    <!-- Author h-card only within h-entry -->
    <div class="p-author h-card">
      <a class="p-name u-url" href="{{ author.me_url }}">
        {{ author.name or author.me_url }}
      </a>
      {% if author.photo %}
      <img class="u-photo" src="{{ author.photo }}" alt="{{ author.name }}">
      {% endif %}
    </div>
  </footer>
</article>

Multi-photo Implementation Notes:

  • Multiple u-photo elements indicate a multi-photo post (like Instagram, Twitter)
  • Photos are considered primary content when present
  • Consuming applications (like Bridgy) will respect platform limits (e.g., Twitter's 4-photo max)
  • Photos appear BEFORE text content, matching social media conventions

Enhanced h-feed

Location: templates/index.html

Required structure:

  • h-feed container
  • p-name (feed title)
  • p-author (feed author)
  • Multiple h-entry children

Location: templates/base.html

Add to <head> using discovered rel-me links:

{% if author.rel_me_links %}
  {% for profile in author.rel_me_links %}
  <link rel="me" href="{{ profile }}">
  {% endfor %}
{% endif %}

Discovery Module

Location: New module starpunk/author_discovery.py

Key functions:

  • discover_author_info(me_url) - Fetch and parse h-card from profile
  • parse_hcard(html, url) - Extract h-card properties
  • parse_rel_me(html, url) - Extract rel-me links
  • cache_author_profile(profile_data) - Store in database
  • get_cached_author(me_url) - Retrieve from cache
  • refresh_author_profile(me_url) - Force refresh

Integration points:

  • Called during IndieAuth login success in auth_external.py
  • Admin settings page for manual refresh (/admin/settings)
  • Template context processor to inject author data globally

Microformats Parsing

Use existing library for parsing:

  • Option 1: mf2py - Python microformats2 parser
  • Option 2: Custom minimal parser (lighter weight)

Parse these specific properties:

  • h-card properties: name, photo, url, note, email
  • rel-me links for identity verification
  • Store as JSON in database for flexibility

Testing & Validation

Use these tools to validate:

  1. https://indiewebify.me/ - Complete IndieWeb validation
  2. https://microformats.io/ - Microformats parser
  3. https://search.google.com/test/rich-results - Google's structured data test

Acceptance Criteria

  • Author info discovered from IndieAuth profile URL
  • h-card present within h-entries only (not standalone)
  • h-entry has all required properties
  • h-feed properly structures the homepage
  • rel=me links in HTML head (from discovery)
  • Passes indiewebify.me Level 2 tests
  • Parsed correctly by microformats.io
  • Graceful fallback when discovery fails
  • Author profile cached in database
  • Manual refresh option in admin

Implementation Order

Recommended implementation sequence:

  1. Custom Slugs (simplest, least dependencies)

    • Modify forms
    • Update backend
    • Test uniqueness
  2. Microformats2 (template-only changes)

    • Add h-card partial
    • Enhance h-entry
    • Add rel=me links
    • Validate with tools
  3. Media Upload (most complex)

    • Create media module
    • Add upload forms
    • Implement storage
    • Add serving route

Out of Scope

The following are explicitly NOT included in v1.2.0:

  • Micropub media endpoint
  • Video upload support
  • Thumbnail generation (separate from main image)
  • CDN integration
  • Media gallery interface
  • Webmention support
  • Multi-user support
  • Self-hosted IndieAuth (see ADR-056)

Database Schema Changes

Required schema changes for v1.2.0:

1. Media Tables

-- 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
    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);

2. Author Profile Table

CREATE TABLE author_profile (
    id INTEGER PRIMARY KEY,
    me_url TEXT NOT NULL UNIQUE,
    name TEXT,
    photo TEXT,
    bio TEXT,
    rel_me_links TEXT,  -- JSON array
    discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

3. No Changes Required For:

  • Custom slugs: Already supported via existing slug column

Configuration Changes

New configuration variables:

# Media settings
MAX_UPLOAD_SIZE=10485760  # 10MB in bytes
ALLOWED_MEDIA_TYPES=image/jpeg,image/png,image/gif,image/webp
MEDIA_PATH=data/media  # Storage location

# Author discovery settings
AUTHOR_CACHE_TTL=604800  # 7 days in seconds
AUTHOR_DISCOVERY_TIMEOUT=5.0  # HTTP timeout for profile fetch

Note: Author information is NOT configured via environment variables. It is discovered from the authenticated user's IndieAuth profile URL.


Security Considerations

  1. File Upload Security

    • Validate MIME types
    • Check file extensions
    • Limit file sizes
    • Sanitize filenames
    • Store outside web root if possible
  2. Slug Validation

    • Prevent directory traversal
    • Enforce URL-safe characters
    • Check uniqueness
  3. Microformats

    • No security implications
    • Ensure proper HTML escaping continues

Testing Requirements

Unit Tests

  • Slug validation logic
  • Media file validation
  • Unique filename generation

Integration Tests

  • Custom slug creation flow
  • Media upload and serving
  • Microformats parsing

Manual Testing

  • Upload various image formats
  • Try invalid slugs
  • Validate microformats output
  • Test with screen readers

Additional Design Considerations

Media Upload Details

  1. Social Media Model: Media works like Twitter/Mastodon posts

    • Media displays at TOP of note
    • Text appears BELOW media
    • Multiple images supported (max 4)
    • No inline markdown images (attachments only)
    • Display order is upload order (no reordering)
  2. File Type Restrictions:

    • Accept: image/jpeg, image/png, image/gif, image/webp
    • Reject: SVG (security), video formats (v1.2.0 scope)
    • Validate MIME type server-side, not just extension
  3. Image Processing (using Pillow):

    • Automatic resize if >2048px (longest edge)
    • EXIF orientation correction
    • File integrity validation
    • Preserve aspect ratio
    • Quality setting: 95 (high quality)
    • No separate thumbnail generation
  4. Display Layout:

    • 1 image: Full width
    • 2 images: Side by side (50% each)
    • 3 images: Grid (1 large + 2 small, or equal grid)
    • 4 images: 2x2 grid
  5. Image Limits (per ADR-058):

    • Max file size: 10MB per image
    • Max dimensions: 4096x4096 pixels
    • Auto-resize threshold: 2048 pixels (longest edge)
    • Max images per note: 4
  6. Accessibility Features:

    • Optional caption field for each image
    • Captions stored in note_media.caption
    • Used as alt text in HTML output
    • Included in syndication feeds
  7. Database Design Rationale:

    • Junction table allows flexible ordering
    • Supports future media reuse across notes
    • Per-attachment captions for accessibility
    • Efficient queries for feed generation
  8. Feed Syndication Strategy:

    • RSS: HTML with images in description (universal support)
    • ATOM: Both enclosures AND HTML content (best compatibility)
    • JSON Feed: Native attachments array (cleanest implementation)

Slug Handling

  1. Absolute No-Edit Policy: Once created, slugs are immutable

    • No admin override
    • No database updates allowed
    • Prevents broken permalinks completely
  2. Validation Pattern: ^[a-z0-9-]+$

    • Lowercase only for consistency
    • No underscores (hyphens preferred)
    • No special characters

Author Discovery Edge Cases

  1. Multiple h-cards on Profile:

    • Use first representative h-card (class="h-card" on body or first found)
    • Log if multiple found for debugging
  2. Missing Properties:

    • Name: Falls back to domain
    • Photo: Omit if not found
    • Bio: Omit if not found
    • All properties are optional except URL
  3. Network Failures:

    • Use cached data even if expired
    • Log failure for monitoring
    • Never block login due to discovery failure
  4. Invalid Markup:

    • Best-effort parsing
    • Log parsing errors
    • Use whatever can be extracted

Success Metrics

v1.2.0 is successful when:

  1. Users can specify custom slugs via web UI (immutable after creation)
  2. Users can upload images via web UI with auto-insertion
  3. Author info discovered from IndieAuth profile
  4. Site passes IndieWebify.me Level 2
  5. All existing tests continue to pass
  6. No regression in existing functionality
  7. Media tracked in database with metadata
  8. Graceful handling of discovery failures