mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-04 12:17:17 +02:00
292 lines
9.9 KiB
Python
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)
|