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

, optional description

, and optional


that follows — # the guide template renders both from metadata separately html = re.sub(r'^\s*]*>.*?

\s*(?:

.*?

\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
 tags (shouldn't happen in prod)
                html = f"
{content}
" 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)