# v1.3.0 Microformats2 Compliance and Tags Implementation Design

**Version**: 1.3.0
**Status**: Proposed
**Author**: StarPunk Architect
**Date**: 2025-12-10

## Executive Summary

This release implements full microformats2 compliance and introduces a tag/category system for notes. The scope has been refined to focus on five key areas:

1. **Tag/Category System** - Database schema, backend, and p-category markup
2. **h-card Enhancements** - Add photo and bio (p-note) with graceful fallback
3. **h-feed Minimum Properties** - name, author, url, photo per spec
4. **Author h-card on Individual Note Pages** - Already partially implemented, needs verification
5. **u-photo Placement Fix** - Move outside e-content per draft spec

### Out of Scope

- p-summary (not necessary for notes)
- p-name on notes without explicit headings (current behavior correct)
- Standalone h-card page (nested only is sufficient)
- CSS changes (out of scope)

## References

- [h-card Properties](https://microformats.org/wiki/h-card#Properties)
- [h-feed Specification](https://microformats.org/wiki/h-feed)
- [h-entry Specification](https://microformats.org/wiki/h-entry)
- ADR-040: Microformats2 Compliance

---

## 1. Tag/Category System

### 1.1 Database Schema

**Migration File**: `migrations/008_add_tags.sql`

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

### 1.2 Tag Normalization Rules

Tags are normalized for storage and lookup while preserving display formatting:

| Input | Normalized (`name`) | Display (`display_name`) |
|-------|---------------------|--------------------------|
| `"IndieWeb"` | `"indieweb"` | `"IndieWeb"` |
| `"  Python  "` | `"python"` | `"Python"` |
| `"Machine Learning"` | `"machine-learning"` | `"Machine Learning"` |
| `"#hashtag"` | `"hashtag"` | `"hashtag"` |
| `"tag/subtag"` | `"tag-subtag"` | `"tag/subtag"` |

**Normalization Algorithm**:
1. Strip leading/trailing whitespace (preserve for display_name)
2. Strip leading `#` characters
3. Replace spaces and slashes with hyphens
4. Remove characters not in `[a-zA-Z0-9_-]`
5. Collapse consecutive hyphens to single hyphen
6. Strip leading/trailing hyphens
7. Convert to lowercase for normalized name
8. Preserve original (after step 1 only) for display name

**URL Handling**: Tag URLs use the normalized name (`/tag/machine-learning`). The route normalizes any input before lookup, so `/tag/Machine%20Learning` redirects or resolves to the same tag.

### 1.3 Backend Changes

#### 1.3.1 New Module: `starpunk/tags.py`

```python
"""
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
"""

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')
    """
    pass


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
    """
    pass


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
    """
    pass


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
    """
    pass


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
    """
    pass


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']
    """
    pass
```

#### 1.3.2 Updates to `starpunk/notes.py`

**`create_note()` signature change**:

```python
def create_note(
    content: str,
    published: bool = False,
    created_at: Optional[datetime] = None,
    custom_slug: Optional[str] = None,
    tags: Optional[list[str]] = None  # NEW PARAMETER
) -> Note:
```

**`update_note()` signature change**:

```python
def update_note(
    slug: Optional[str] = None,
    id: Optional[int] = None,
    content: Optional[str] = None,
    published: Optional[bool] = None,
    tags: Optional[list[str]] = None  # NEW PARAMETER
) -> Note:
```

#### 1.3.3 Updates to `starpunk/models.py`

Add `tags` property to `Note` class:

```python
@dataclass(frozen=True)
class Note:
    # ... existing fields ...

    # Cached tags (loaded separately, not from database row)
    _cached_tags: Optional[list[dict]] = field(
        default=None, repr=False, compare=False, init=False
    )

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

**Update `to_dict()` method** to support tags:

```python
def to_dict(
    self,
    include_content: bool = False,
    include_html: bool = False,
    include_tags: bool = False  # NEW PARAMETER
) -> dict[str, Any]:
    # ... existing code ...

    if include_tags:
        data["tags"] = [tag["display_name"] for tag in self.tags]

    return data
```

### 1.4 Micropub Integration

#### 1.4.1 Updates to `starpunk/micropub.py`

The `extract_tags()` function already exists and correctly extracts tags from the `category` property. Update `handle_create()` to pass tags to `create_note()`:

```python
def handle_create(data: dict, token_info: dict):
    # ... existing code ...

    # Extract properties (including tags)
    properties = normalize_properties(data)
    content = extract_content(properties)
    title = extract_title(properties)
    tags = extract_tags(properties)  # Already implemented
    published_date = extract_published_date(properties)

    # Create note with tags
    note = create_note(
        content=content,
        published=True,
        created_at=published_date,
        custom_slug=custom_slug,
        tags=tags if tags else None  # Pass tags to create_note
    )
```

#### 1.4.2 Updates to `handle_query()` for `q=source`

Return tags in Micropub source response (uncomment and modify existing code):

```python
# In handle_query(), q=source section:
mf2 = {
    "type": ["h-entry"],
    "properties": {
        "content": [note.content],
        "published": [note.created_at.isoformat()],
        "url": [f"{site_url}notes/{note.slug}"],
    },
}

# Add tags if present (v1.3.0 - uncomment and implement)
if note.tags:
    mf2["properties"]["category"] = [tag["display_name"] for tag in note.tags]
```

#### 1.4.3 Micropub Update Semantics for Tags

When handling Micropub update requests:

- **`category` property absent**: Leave existing tags unchanged
- **`category: []` (empty array)**: Remove all tags from the note
- **`category: ["tag1", "tag2"]`**: Replace all tags with the provided list

This follows standard Micropub property replacement semantics.

---

## 2. h-card Enhancements

### 2.1 Current State

The `author_discovery.py` module already extracts:
- `name` (p-name)
- `photo` (u-photo)
- `url` (u-url)
- `note` (p-note / bio)
- `rel_me_links`

The database schema (`migration 006`) already stores all these fields.

### 2.2 Required Changes

**None for backend** - The author discovery system is complete.

**Template changes** - Ensure templates display photo and bio when available.

### 2.3 Graceful Fallback

Per user requirements, if photo or bio is not available:
- Photo: Simply omit the `<img>` element
- Bio: Simply omit the `<p class="p-note">` element

This is already the behavior via Jinja2 conditionals.

---

## 3. h-feed Minimum Properties

### 3.1 Required Properties (per spec)

The h-feed specification requires:
- `p-name` - Feed title
- `p-author` - Feed author (h-card)
- `u-url` - Feed URL
- `u-photo` - Feed photo (author photo)

### 3.2 Current State Analysis

Current `templates/index.html`:
```html
<div class="h-feed">
  <h2 class="p-name">{{ config.SITE_NAME or 'Recent Notes' }}</h2>

  {% if author %}
  <div class="p-author h-card" style="display: none;">
    <a class="p-name u-url" href="{{ author.url or author.me }}">{{ author.name or author.url }}</a>
  </div>
  {% endif %}
```

**Issues**:
1. Missing `u-url` on h-feed
2. Missing `u-photo` on h-feed
3. Hidden author h-card lacks photo

### 3.3 Required Template Changes

Update `templates/index.html`:

```html
<div class="h-feed">
  {# h-feed required properties per microformats.org/wiki/h-feed #}
  <h2 class="p-name">{{ config.SITE_NAME or 'Recent Notes' }}</h2>

  {# u-url for feed (self-reference) #}
  <a class="u-url" href="{{ url_for('public.index', _external=True) }}" style="display: none;">Feed URL</a>

  {# Feed-level author h-card with all properties #}
  {# Hidden because it's semantic-only markup for parsers, not visual content #}
  {# The visible author display is on individual note pages #}
  {% if author %}
  <div class="p-author h-card" style="display: none;">
    {% if author.photo %}
    <img class="u-photo" src="{{ author.photo }}" alt="{{ author.name or 'Author' }}">
    {% endif %}
    <a class="p-name u-url" href="{{ author.url or author.me }}">{{ author.name or author.url or author.me }}</a>
    {% if author.note %}
    <p class="p-note">{{ author.note }}</p>
    {% endif %}
  </div>
  {% endif %}

  {# u-photo at feed level (duplicate of author photo for broad parser compatibility) #}
  {# Some parsers expect feed-level u-photo, others look inside author h-card #}
  {% if author and author.photo %}
  <img class="u-photo" src="{{ author.photo }}" alt="" style="display: none;">
  {% endif %}

  {% if notes %}
    {% for note in notes %}
    {# ... existing h-entry markup ... #}
    {% endfor %}
  {% endif %}
</div>
```

### 3.4 Author Data Loading

The `author` variable is injected into templates via a Flask context processor. Verify this exists in `starpunk/__init__.py` or create one:

```python
@app.context_processor
def inject_author():
    from starpunk.author import get_author
    return {"author": get_author()}
```

If the context processor already exists, no changes needed.

---

## 4. Author h-card on Individual Note Pages

### 4.1 Current State

`templates/note.html` already includes author h-card:

```html
{% if author %}
<div class="p-author h-card">
  <a class="p-name u-url" href="{{ author.url or author.me }}">
    {{ author.name or author.url or author.me }}
  </a>
  {% if author.photo %}
  <img class="u-photo" src="{{ author.photo }}" alt="{{ author.name or 'Author' }}" width="48" height="48">
  {% endif %}
</div>
{% endif %}
```

### 4.2 Required Changes

Add `p-note` (bio) to the h-card on note pages for completeness:

```html
{% if author %}
<div class="p-author h-card">
  {% if author.photo %}
  <img class="u-photo" src="{{ author.photo }}" alt="{{ author.name or 'Author' }}" width="48" height="48">
  {% endif %}
  <a class="p-name u-url" href="{{ author.url or author.me }}">
    {{ author.name or author.url or author.me }}
  </a>
  {% if author.note %}
  <span class="p-note" style="display: none;">{{ author.note }}</span>
  {% endif %}
</div>
{% 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 %}
  <div class="note-media">
    {% for item in media_items %}
    <figure class="media-item">
      <img src="{{ url_for('public.media_file', path=item.path) }}"
           alt="{{ item.caption or 'Image' }}"
           class="u-photo"
           loading="lazy">
    </figure>
    {% endfor %}
  </div>
  {% endif %}
{% endmacro %}
```

`templates/note.html`:
```html
<article class="h-entry">
  {# ... #}

  {# 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) #}
  <div class="e-content">
    {{ note.html|safe }}
  </div>
```

### 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
<article class="h-entry">
  {# ... existing markup ... #}

  <footer class="note-meta">
    {# ... existing u-url, dt-published ... #}

    {# Tags / Categories #}
    {# rel="tag" per microformats2 p-category specification #}
    {% if note.tags %}
    <div class="note-tags">
      {% for tag in note.tags %}
      <a class="p-category" rel="tag" href="{{ url_for('public.tag', tag=tag.name) }}">
        {{ tag.display_name }}
      </a>
      {% endfor %}
    </div>
    {% endif %}

    {# ... author h-card ... #}
  </footer>
</article>
```

### 6.2 Index Page (`templates/index.html`)

Add p-category to note previews with `rel="tag"`:

```html
{% for note in notes %}
<article class="h-entry note-preview">
  {# ... existing markup ... #}

  <footer class="note-meta">
    {# ... existing content ... #}

    {# Tags in preview #}
    {% if note.tags %}
    <span class="note-tags">
      {% for tag in note.tags %}
      <a class="p-category" rel="tag" href="{{ url_for('public.tag', tag=tag.name) }}">{{ tag.display_name }}</a>
      {% endfor %}
    </span>
    {% endif %}
  </footer>
</article>
{% endfor %}
```

### 6.3 Tag Archive Route

Add new route to `starpunk/routes/public.py`.

**URL Pattern**: Use `/tag/<tag>` (singular) - consistent with `/note/<slug>`.

**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/<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 %}
<div class="h-feed">
  <h1 class="p-name">Notes tagged "{{ tag.display_name }}"</h1>

  {% if notes %}
    {% for note in notes %}
    <article class="h-entry note-preview">
      {# Same structure as index.html note previews #}
      {# ... #}
    </article>
    {% endfor %}
  {% else %}
    <p class="empty-state">No notes with this tag.</p>
  {% endif %}
</div>
{% 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/<slug>")
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
<div class="form-group">
  <label for="tags">Tags</label>
  <input type="text"
         id="tags"
         name="tags"
         value="{{ note.tags|map(attribute='display_name')|join(', ') if note.tags else '' }}"
         placeholder="Comma-separated tags (e.g., IndieWeb, Python, Thoughts)">
  <small>Separate multiple tags with commas</small>
</div>
```

### 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/<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 |
