mtg_python_deckbuilder/code/web/services/docs_service.py

292 lines
9.9 KiB
Python

"""Service for rendering and caching user documentation.
Follows the R9 BaseService pattern. Provides web-accessible documentation
from markdown files in docs/user_guides/.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
from code.web.services.base import CachedService, NotFoundError, ServiceError
logger = logging.getLogger(__name__)
# Try importing markdown with extensions
try:
import markdown
from markdown.extensions.fenced_code import FencedCodeExtension
from markdown.extensions.tables import TableExtension
from markdown.extensions.toc import TocExtension
MARKDOWN_AVAILABLE = True
except ImportError:
MARKDOWN_AVAILABLE = False
logger.warning("markdown library not available - docs service will be limited")
@dataclass
class GuideMetadata:
"""Metadata about a documentation guide."""
name: str
title: str
description: Optional[str]
file_path: Path
@dataclass
class GuideContent:
"""Rendered guide content with table of contents."""
html: str
toc_html: str # Table of contents HTML
class DocsService(CachedService[str, GuideContent]):
"""Service for rendering user documentation from markdown files.
Provides caching with TTL to avoid re-rendering unchanged files.
Supports GitHub-flavored markdown with syntax highlighting.
"""
def __init__(
self,
docs_dir: Optional[Path] = None,
ttl_seconds: int = 300, # 5 minute cache
max_size: int = 50
) -> None:
"""Initialize docs service.
Args:
docs_dir: Path to documentation directory (defaults to docs/user_guides)
ttl_seconds: Cache TTL in seconds
max_size: Maximum number of cached documents
"""
super().__init__(ttl_seconds=ttl_seconds, max_size=max_size)
if docs_dir is None:
# Default to docs/user_guides relative to project root
docs_dir = Path(__file__).parent.parent.parent.parent / "docs" / "user_guides"
self.docs_dir = docs_dir
self._validate()
def _validate(self) -> None:
"""Validate service configuration.
Raises:
ServiceError: If docs directory doesn't exist or markdown unavailable
"""
if not self.docs_dir.exists():
raise ServiceError(f"Documentation directory not found: {self.docs_dir}")
if not MARKDOWN_AVAILABLE:
logger.warning("Markdown library not available - install with: pip install markdown")
def _compute_value(self, guide_name: str) -> GuideContent:
"""Render markdown guide to HTML with table of contents.
Args:
guide_name: Name of guide (without .md extension)
Returns:
GuideContent with rendered HTML and TOC
Raises:
NotFoundError: If guide doesn't exist
ServiceError: If rendering fails
"""
file_path = self.docs_dir / f"{guide_name}.md"
if not file_path.exists():
raise NotFoundError(f"Guide not found: {guide_name}")
try:
# Read markdown content
content = file_path.read_text(encoding="utf-8")
# Render to HTML with GitHub-flavored extensions
if MARKDOWN_AVAILABLE:
md = markdown.Markdown(extensions=[
'fenced_code',
'tables',
'toc',
'nl2br', # Convert newlines to <br>
'sane_lists', # Better list handling
])
html = md.convert(content)
# Rewrite relative .md links to /help/ routes
# e.g. href="random_build.md" -> href="/help/random_build"
html = re.sub(
r'href="([^":/]+)\.md(?:#([^"]*))?"',
lambda m: f'href="/help/{m.group(1)}"' + (f'#{m.group(2)}' if m.group(2) else ''),
html
)
# Strip the first <h1>, optional description <p>, and optional <hr> that follows —
# the guide template renders both from metadata separately
html = re.sub(r'^\s*<h1[^>]*>.*?</h1>\s*(?:<p>.*?</p>\s*)?(?:<hr\s*/?>)?\s*', '', html, count=1, flags=re.DOTALL)
# Extract table of contents (generated by toc extension)
toc_html = getattr(md, 'toc', '')
else:
# Fallback: wrap in <pre> tags (shouldn't happen in prod)
html = f"<pre>{content}</pre>"
toc_html = ""
logger.warning(f"Markdown rendering unavailable for {guide_name}")
return GuideContent(html=html, toc_html=toc_html)
except Exception as e:
logger.error(f"Failed to render guide {guide_name}: {e}")
raise ServiceError(f"Failed to render guide: {e}")
def get_guide(self, guide_name: str, force_reload: bool = False) -> GuideContent:
"""Get rendered HTML and TOC for a guide.
Args:
guide_name: Name of guide (without .md extension)
force_reload: Force re-render even if cached
Returns:
GuideContent with HTML and TOC
Raises:
NotFoundError: If guide doesn't exist
ServiceError: If rendering fails
"""
return self.get(guide_name, force_recompute=force_reload)
def list_guides(self) -> List[GuideMetadata]:
"""List all available documentation guides.
Returns:
List of guide metadata sorted by logical workflow order
"""
# Define logical ordering for deckbuilding workflow
guide_order = [
"build_wizard",
"theme_browser",
"partner_mechanics",
"budget_mode",
"bracket_compliance",
"owned_cards",
"include_exclude",
"multi_copy",
"land_bases",
"quick_build_skip_controls",
"locks_replace_permalinks",
"random_build",
"batch_build_compare",
]
guides = []
for path in sorted(self.docs_dir.glob("*.md")):
name = path.stem
metadata = self._extract_metadata(path)
guides.append(GuideMetadata(
name=name,
title=metadata.get("title", self._title_from_name(name)),
description=metadata.get("description"),
file_path=path
))
# Sort by defined order (unrecognized guides at end, alphabetically)
def sort_key(g: GuideMetadata) -> tuple:
try:
return (0, guide_order.index(g.name))
except ValueError:
return (1, g.title.lower())
return sorted(guides, key=sort_key)
def get_metadata(self, guide_name: str) -> GuideMetadata:
"""Get metadata for a specific guide.
Args:
guide_name: Name of guide (without .md extension)
Returns:
Guide metadata
Raises:
NotFoundError: If guide doesn't exist
"""
file_path = self.docs_dir / f"{guide_name}.md"
if not file_path.exists():
raise NotFoundError(f"Guide not found: {guide_name}")
metadata = self._extract_metadata(file_path)
return GuideMetadata(
name=guide_name,
title=metadata.get("title", self._title_from_name(guide_name)),
description=metadata.get("description"),
file_path=file_path
)
def _extract_metadata(self, file_path: Path) -> Dict[str, str]:
"""Extract metadata from markdown file.
Looks for:
- Title: First H1 heading (# Title)
- Description: First paragraph after title
Args:
file_path: Path to markdown file
Returns:
Metadata dictionary
"""
try:
content = file_path.read_text(encoding="utf-8")
metadata = {}
# Extract title from first H1
title_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
if title_match:
metadata["title"] = title_match.group(1).strip()
# Extract description from first paragraph after title
# Look for non-empty line after title that's not a heading
lines = content.split('\n')
found_title = False
for line in lines:
line = line.strip()
if not found_title and line.startswith('# '):
found_title = True
continue
if found_title and line and not line.startswith('#'):
metadata["description"] = line[:200] # Limit description length
break
return metadata
except Exception as e:
logger.warning(f"Failed to extract metadata from {file_path}: {e}")
return {}
@staticmethod
def _title_from_name(name: str) -> str:
"""Convert file name to readable title.
Args:
name: File name (without extension)
Returns:
Human-readable title
"""
# Replace underscores with spaces and title case
return name.replace('_', ' ').title()
def invalidate_guide(self, guide_name: Optional[str] = None) -> None:
"""Invalidate cached guide(s).
Args:
guide_name: Guide to invalidate (None = invalidate all)
"""
self.invalidate(guide_name)