feat: Add custom slug support via mp-slug property

Implements custom slug handling for Micropub as specified in ADR-035.

Changes:
- Created starpunk/slug_utils.py with validation/sanitization functions
- Added RESERVED_SLUGS constant (api, admin, auth, feed, etc.)
- Modified create_note() to accept optional custom_slug parameter
- Integrated mp-slug extraction in Micropub handle_create()
- Slug sanitization: lowercase, hyphens, no special chars
- Conflict resolution: sequential numbering (-2, -3, etc.)
- Hierarchical slugs (/) rejected (deferred to v1.2.0)

Features:
- Custom slugs via Micropub's mp-slug property
- Automatic sanitization of invalid characters
- Reserved slug protection
- Sequential conflict resolution (not random)
- Clear error messages for validation failures

Part of v1.1.0 (Phase 4).

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 10:05:38 -07:00
parent b3c1b16617
commit c7fcc21406
3 changed files with 297 additions and 10 deletions

View File

@@ -134,7 +134,7 @@ def _get_existing_slugs(db) -> set[str]:
def create_note(
content: str, published: bool = False, created_at: Optional[datetime] = None
content: str, published: bool = False, created_at: Optional[datetime] = None, custom_slug: Optional[str] = None
) -> Note:
"""
Create a new note
@@ -147,6 +147,7 @@ def create_note(
content: Markdown content for the note (must not be empty)
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)
Returns:
Note object with all metadata and content loaded
@@ -208,20 +209,27 @@ def create_note(
data_dir = Path(current_app.config["DATA_PATH"])
# 3. GENERATE UNIQUE SLUG
# 3. GENERATE OR VALIDATE SLUG
# Query all existing slugs from database
db = get_db(current_app)
existing_slugs = _get_existing_slugs(db)
# Generate base slug from content
base_slug = generate_slug(content, created_at)
if custom_slug:
# Use custom slug (from Micropub mp-slug property)
from starpunk.slug_utils import validate_and_sanitize_custom_slug
success, slug, error = validate_and_sanitize_custom_slug(custom_slug, existing_slugs)
if not success:
raise InvalidNoteDataError("slug", custom_slug, error)
else:
# Generate base slug from content
base_slug = generate_slug(content, created_at)
# Make unique if collision
slug = make_slug_unique(base_slug, existing_slugs)
# Make unique if collision
slug = make_slug_unique(base_slug, existing_slugs)
# Validate final slug (defensive check)
if not validate_slug(slug):
raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}")
# Validate final slug (defensive check)
if not validate_slug(slug):
raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}")
# 4. GENERATE FILE PATH
note_path = generate_note_path(slug, created_at, data_dir)