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

@@ -360,6 +360,7 @@
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"syntaxError": "Syntax error", "syntaxError": "Syntax error",
"undefinedVar": "Unknown variable",
"line": "line" "line": "line"
} }
} }

View File

@@ -360,6 +360,7 @@
"expand": "Развернуть", "expand": "Развернуть",
"collapse": "Свернуть", "collapse": "Свернуть",
"syntaxError": "Ошибка синтаксиса", "syntaxError": "Ошибка синтаксиса",
"undefinedVar": "Неизвестная переменная",
"line": "строка" "line": "строка"
} }
} }

View File

@@ -25,6 +25,7 @@
let slotPreview = $state<Record<string, string>>({}); let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({}); let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({}); let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {}; let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function validateSlot(slotKey: string, template: string) { function validateSlot(slotKey: string, template: string) {
@@ -33,6 +34,7 @@
if (!template) { if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' }; slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null }; slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
return; return;
} }
@@ -42,10 +44,12 @@
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) }); const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' }; slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null }; slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
} catch { } catch {
// Network error, don't show as template error // Network error, don't show as template error
slotErrors = { ...slotErrors, [slotKey]: '' }; slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null }; slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
} }
}, 800); }, 800);
} }
@@ -189,7 +193,11 @@
{#if (slot.rows || 2) > 2} {#if (slot.rows || 2) > 2}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} /> <JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]} {#if slotErrors[slot.key]}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p> {#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if} {/if}
{#if slotPreview[slot.key]} {#if slotPreview[slot.key]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm"> <div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">

View File

@@ -6,7 +6,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user from ..auth.dependencies import get_current_user
from ..database.engine import get_session 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"]) 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 = { _SAMPLE_CONTEXT = {
# Event variables
"album_name": "Family Photos", "album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123", "album_url": "https://immich.example.com/share/abc123",
"album_created": "01.01.2024",
"album_updated": "19.03.2026",
"added_count": 3, "added_count": 3,
"removed_count": 0, "removed_count": 1,
"change_type": "assets_added", "change_type": "assets_added",
"people": "Alice, Bob", "people": ["Alice", "Bob"],
"assets": "\n • 🖼️ IMG_001.jpg\n • 🖼️ IMG_002.jpg\n • 🎬 VID_003.mp4", "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"}],
"common_date": " from 19.03.2026", "removed_assets": ["asset-id-1", "asset-id-2"],
"common_location": " in Paris, France", "shared": True,
"video_warning": "", "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", "old_name": "Old Album",
"new_name": "New Album", "new_name": "New Album",
"album_count": 5, # Scheduled/periodic variables
"albums": "\n • Family Photos: https://example.com/share/abc", "albums": [_SAMPLE_ALBUM, {**_SAMPLE_ALBUM, "name": "Vacation 2025", "asset_count": 120}],
"asset_count": 10, "assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"more_count": 7, "date": "2026-03-19",
} }
@@ -167,20 +192,32 @@ async def preview_raw(
body: PreviewRequest, body: PreviewRequest,
user: User = Depends(get_current_user), 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: try:
env = SandboxedEnvironment(autoescape=False) env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(body.template) env.from_string(body.template)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except TemplateSyntaxError as e: except TemplateSyntaxError as e:
return { return {
"rendered": None, "rendered": None,
"error": e.message, "error": e.message,
"error_line": e.lineno, "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: 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: except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None} return {"rendered": None, "error": str(e), "error_line": None}