fix: Alpine.js JSON parsing error in x-data HTML attributes

Add tojson_attr Jinja2 filter that escapes double quotes to "
for safe embedding in HTML attributes. The browser decodes entities
before Alpine.js evaluates, so JSON parses correctly.

Replaces |tojson with |tojson_attr in x-data attributes (select_recipe,
recipe_list, base flash messages). Script tag usages are unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 19:31:49 +01:00
parent 6d5660b20d
commit 004f794c75
4 changed files with 23 additions and 3 deletions
+20
View File
@@ -1,9 +1,11 @@
"""TieMeasureFlow Client - Flask Entry Point.""" """TieMeasureFlow Client - Flask Entry Point."""
import json
import os import os
from flask import Flask, redirect, url_for, session, request from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel from flask_babel import Babel
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from markupsafe import Markup
from config import Config from config import Config
@@ -55,6 +57,24 @@ def create_app() -> Flask:
session["language"] = lang session["language"] = lang
return redirect(request.referrer or url_for("auth.login")) return redirect(request.referrer or url_for("auth.login"))
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
Unlike |tojson, this escapes double quotes to &#34; so the output
can be safely embedded inside double-quoted HTML attributes.
The browser decodes the entities before Alpine.js evaluates them.
"""
rv = json.dumps(value, ensure_ascii=False)
rv = (
rv.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("'", "\\u0027")
.replace('"', "&#34;")
)
return Markup(rv)
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
"""Inject global variables into all templates.""" """Inject global variables into all templates."""
+1 -1
View File
@@ -90,7 +90,7 @@
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="fixed top-16 right-4 z-50 flex flex-col gap-2 max-w-md w-full" <div class="fixed top-16 right-4 z-50 flex flex-col gap-2 max-w-md w-full"
x-data="{ messages: {{ messages|tojson }} }" x-data="{ messages: {{ messages|tojson_attr }} }"
x-init="setTimeout(() => { $el.remove() }, 8000)"> x-init="setTimeout(() => { $el.remove() }, 8000)">
{% for category, message in messages %} {% for category, message in messages %}
<div x-data="{ show: true }" <div x-data="{ show: true }"
+1 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl" <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
x-data="{ x-data="{
recipes: {{ recipes|tojson }}, recipes: {{ recipes|tojson_attr }},
search: '{{ search or '' }}', search: '{{ search or '' }}',
filter: 'all', filter: 'all',
deleteModal: false, deleteModal: false,
+1 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl" <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
x-data="{ x-data="{
recipes: {{ recipes|tojson }}, recipes: {{ recipes|tojson_attr }},
search: '{{ auto_recipe_code }}', search: '{{ auto_recipe_code }}',
lot_number: '{{ auto_lot }}', lot_number: '{{ auto_lot }}',
serial_number: '{{ auto_serial }}', serial_number: '{{ auto_serial }}',