Jinja2 syntax validation with debounced API check
All checks were successful
Validate / Hassfest (push) Successful in 3s

Two-pass validation in preview-raw endpoint:
1. Syntax check (catches {% if %}, unclosed tags)
2. StrictUndefined render (catches {{ asset.a }}, {{ bad_var }})

Frontend shows:
- Red error for syntax errors with line number
- Amber warning for undefined variables
- Error line highlighted in editor

Sample context now uses proper structured data (lists of dicts
for assets/albums) so valid field access like {{ asset.filename }}
renders correctly during preview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:49:58 +03:00
parent 59108a834c
commit afb8be8101
4 changed files with 67 additions and 20 deletions

View File

@@ -6,7 +6,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError
from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
@@ -14,26 +14,51 @@ from ..database.models import TemplateConfig, User
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
# Sample data for template preview
# Sample asset for template preview
_SAMPLE_ASSET = {
"filename": "IMG_001.jpg",
"type": "IMAGE",
"created_at": "2026-03-19T10:30:00",
"owner": "Alice",
"description": "Family picnic",
"url": "https://immich.example.com/photos/abc123",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
"playback_url": "",
"is_favorite": True,
"rating": 5,
"city": "Paris",
"state": "Île-de-France",
"country": "France",
}
_SAMPLE_ALBUM = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
# Full context covering all possible template variables
_SAMPLE_CONTEXT = {
# Event variables
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
"album_created": "01.01.2024",
"album_updated": "19.03.2026",
"added_count": 3,
"removed_count": 0,
"removed_count": 1,
"change_type": "assets_added",
"people": "Alice, Bob",
"assets": "\n • 🖼️ IMG_001.jpg\n • 🖼️ IMG_002.jpg\n • 🎬 VID_003.mp4",
"common_date": " from 19.03.2026",
"common_location": " in Paris, France",
"video_warning": "",
"people": ["Alice", "Bob"],
"added_assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "VID_002.mp4", "type": "VIDEO", "is_favorite": False, "rating": None, "playback_url": "https://immich.example.com/api/assets/def456/video"}],
"removed_assets": ["asset-id-1", "asset-id-2"],
"shared": True,
"video_warning": "\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit.",
# Rename variables
"old_name": "Old Album",
"new_name": "New Album",
"album_count": 5,
"albums": "\n • Family Photos: https://example.com/share/abc",
"asset_count": 10,
"more_count": 7,
# Scheduled/periodic variables
"albums": [_SAMPLE_ALBUM, {**_SAMPLE_ALBUM, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"date": "2026-03-19",
}
@@ -167,20 +192,32 @@ async def preview_raw(
body: PreviewRequest,
user: User = Depends(get_current_user),
):
"""Render arbitrary Jinja2 template text with sample data. For live preview while editing."""
"""Render arbitrary Jinja2 template text with sample data.
Two-pass validation:
1. Parse with default Undefined (catches syntax errors)
2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
"""
# Pass 1: syntax check
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(body.template)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
env.from_string(body.template)
except TemplateSyntaxError as e:
return {
"rendered": None,
"error": e.message,
"error_line": e.lineno,
}
# Pass 2: render with strict undefined to catch unknown variables
try:
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except UndefinedError as e:
return {"rendered": None, "error": str(e), "error_line": None}
# Still a valid template syntactically, but references unknown variable
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}