""" Jinja2 template rendering system for Art-DAG servers. Provides a unified template environment that can load from: 1. The shared artdag_common/templates directory 2. App-specific template directories Usage: from artdag_common import create_jinja_env, render # In app initialization templates = create_jinja_env("app/templates") # In route handler return render(templates, "runs/detail.html", request, run=run, user=user) """ from pathlib import Path from typing import Any, Optional, Union from fastapi import Request from fastapi.responses import HTMLResponse from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader, select_autoescape from .constants import ( TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN, DAGRE_CDN, CYTOSCAPE_DAGRE_CDN, TAILWIND_CONFIG, NODE_COLORS, STATUS_COLORS, ) def create_jinja_env(*template_dirs: Union[str, Path]) -> Environment: """ Create a Jinja2 environment with the shared templates and optional app-specific dirs. Args: *template_dirs: Additional template directories to search (app-specific) Returns: Configured Jinja2 Environment Example: env = create_jinja_env("/app/templates", "/app/custom") """ loaders = [] # Add app-specific directories first (higher priority) for template_dir in template_dirs: path = Path(template_dir) if path.exists(): loaders.append(FileSystemLoader(str(path))) # Add shared templates from this package (lower priority, fallback) loaders.append(PackageLoader("artdag_common", "templates")) env = Environment( loader=ChoiceLoader(loaders), autoescape=select_autoescape(["html", "xml"]), trim_blocks=True, lstrip_blocks=True, ) # Add global context available to all templates env.globals.update({ "TAILWIND_CDN": TAILWIND_CDN, "HTMX_CDN": HTMX_CDN, "CYTOSCAPE_CDN": CYTOSCAPE_CDN, "DAGRE_CDN": DAGRE_CDN, "CYTOSCAPE_DAGRE_CDN": CYTOSCAPE_DAGRE_CDN, "TAILWIND_CONFIG": TAILWIND_CONFIG, "NODE_COLORS": NODE_COLORS, "STATUS_COLORS": STATUS_COLORS, }) # Add custom filters env.filters["truncate_hash"] = truncate_hash env.filters["format_size"] = format_size env.filters["status_color"] = status_color return env def render( env: Environment, template_name: str, request: Request, status_code: int = 200, **context: Any, ) -> HTMLResponse: """ Render a template to an HTMLResponse. Args: env: Jinja2 environment template_name: Template file path (e.g., "runs/detail.html") request: FastAPI request object status_code: HTTP status code (default 200) **context: Template context variables Returns: HTMLResponse with rendered content """ template = env.get_template(template_name) html = template.render(request=request, **context) return HTMLResponse(html, status_code=status_code) def render_fragment( env: Environment, template_name: str, **context: Any, ) -> str: """ Render a template fragment to a string (for HTMX partial updates). Args: env: Jinja2 environment template_name: Template file path **context: Template context variables Returns: Rendered HTML string """ template = env.get_template(template_name) return template.render(**context) # Custom Jinja2 filters def truncate_hash(value: str, length: int = 16) -> str: """Truncate a hash to specified length with ellipsis.""" if not value: return "" if len(value) <= length: return value return f"{value[:length]}..." def format_size(size_bytes: Optional[int]) -> str: """Format file size in human-readable form.""" if size_bytes is None: return "Unknown" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" elif size_bytes < 1024 * 1024 * 1024: return f"{size_bytes / (1024 * 1024):.1f} MB" else: return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" def status_color(status: str) -> str: """Get Tailwind CSS class for a status.""" return STATUS_COLORS.get(status, STATUS_COLORS["pending"])