mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-05 20:57:16 +02:00
feat: web documentation portal with contextual help links and consistent page headers (#67)
This commit is contained in:
parent
46637cf27f
commit
13f6fa5dbf
44 changed files with 2232 additions and 140 deletions
292
code/web/services/docs_service.py
Normal file
292
code/web/services/docs_service.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue