Merges full history from art-dag/mono.git into the monorepo under the artdag/ directory. Contains: core (DAG engine), l1 (Celery rendering server), l2 (ActivityPub registry), common (shared templates/middleware), client (CLI), test (e2e). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> git-subtree-dir: artdag git-subtree-mainline:1a179de547git-subtree-split:4c2e716558
161 lines
4.3 KiB
Python
161 lines
4.3 KiB
Python
"""
|
|
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"])
|