Jinja2 syntax validation with debounced API check
All checks were successful
Validate / Hassfest (push) Successful in 3s
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:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,7 @@
|
|||||||
"expand": "Развернуть",
|
"expand": "Развернуть",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
"syntaxError": "Ошибка синтаксиса",
|
"syntaxError": "Ошибка синтаксиса",
|
||||||
|
"undefinedVar": "Неизвестная переменная",
|
||||||
"line": "строка"
|
"line": "строка"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user