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>
26 KiB
v1.2.0 Feature Specification
Overview
Version 1.2.0 focuses on three essential improvements to the StarPunk web interface:
- Custom slug support in the web UI
- Media upload capability (web UI only, not Micropub)
- 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()ascustom_slugparameter - 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
- User selects multiple files via HTML file input
- Files validated (type, size)
- Files saved to
data/media/YYYY/MM/with generated names - Database records created in
mediatable - Associations created in
note_mediatable - Media displayed as thumbnails below textarea
- 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 pathgenerate_media_url(filename)- Create public URLtrack_media_upload(metadata)- Save to databaseattach_media_to_note(note_id, media_ids, captions)- Create note-media associations with captionsget_media_by_note(note_id)- List media for a note ordered by display_orderextract_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">
<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>
</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 populateattachmentsarray - Query
note_mediaJOINmediawhen 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:
-
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_profiletable)
-
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
);
-
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)
-
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-photoelements 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
rel=me Links
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 profileparse_hcard(html, url)- Extract h-card propertiesparse_rel_me(html, url)- Extract rel-me linkscache_author_profile(profile_data)- Store in databaseget_cached_author(me_url)- Retrieve from cacherefresh_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:
- https://indiewebify.me/ - Complete IndieWeb validation
- https://microformats.io/ - Microformats parser
- 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:
-
Custom Slugs (simplest, least dependencies)
- Modify forms
- Update backend
- Test uniqueness
-
Microformats2 (template-only changes)
- Add h-card partial
- Enhance h-entry
- Add rel=me links
- Validate with tools
-
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
slugcolumn
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
-
File Upload Security
- Validate MIME types
- Check file extensions
- Limit file sizes
- Sanitize filenames
- Store outside web root if possible
-
Slug Validation
- Prevent directory traversal
- Enforce URL-safe characters
- Check uniqueness
-
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
-
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)
-
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
-
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
-
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
-
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
-
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
-
Database Design Rationale:
- Junction table allows flexible ordering
- Supports future media reuse across notes
- Per-attachment captions for accessibility
- Efficient queries for feed generation
-
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
-
Absolute No-Edit Policy: Once created, slugs are immutable
- No admin override
- No database updates allowed
- Prevents broken permalinks completely
-
Validation Pattern:
^[a-z0-9-]+$- Lowercase only for consistency
- No underscores (hyphens preferred)
- No special characters
Author Discovery Edge Cases
-
Multiple h-cards on Profile:
- Use first representative h-card (class="h-card" on body or first found)
- Log if multiple found for debugging
-
Missing Properties:
- Name: Falls back to domain
- Photo: Omit if not found
- Bio: Omit if not found
- All properties are optional except URL
-
Network Failures:
- Use cached data even if expired
- Log failure for monitoring
- Never block login due to discovery failure
-
Invalid Markup:
- Best-effort parsing
- Log parsing errors
- Use whatever can be extracted
Success Metrics
v1.2.0 is successful when:
- Users can specify custom slugs via web UI (immutable after creation)
- Users can upload images via web UI with auto-insertion
- Author info discovered from IndieAuth profile
- Site passes IndieWebify.me Level 2
- All existing tests continue to pass
- No regression in existing functionality
- Media tracked in database with metadata
- Graceful handling of discovery failures