Replace JSON sx-headers with SX dict expressions, fix blog like component
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s

sx-headers attributes now use native SX dict format {:key val} instead of
JSON strings. Eliminates manual JSON string construction in both .sx files
and Python callers.

- sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback,
  add _serializeDict for dict→attribute serialization, fix verbInfo scope in
  _doFetch error handler
- html.py: serialize dict attribute values via SX serialize() not str()
- All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}")
- All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON
- Blog like: extract ~blog-like-toggle, fix POST returning wrong component,
  fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers
  keyword mismatch, wrap sx_content in SxExpr for evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 09:25:28 +00:00
parent 2a04aaad5e
commit 64aa417d63
22 changed files with 70 additions and 50 deletions

View File

@@ -69,7 +69,7 @@ def register(url_prefix="/"):
return sx_response(sx_call( return sx_response(sx_call(
"account-newsletter-toggle", "account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url, id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}', hdrs={"X-CSRFToken": csrf},
target=f"#nl-{nid}", target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}", cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked, checked=checked,

View File

@@ -54,7 +54,7 @@
:toggle (~account-newsletter-toggle :toggle (~account-newsletter-toggle
:id (str "nl-" nid) :id (str "nl-" nid)
:url toggle-url :url toggle-url
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}") :hdrs {:X-CSRFToken csrf}
:target (str "#nl-" nid) :target (str "#nl-" nid)
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg) :cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
:checked checked :checked checked

View File

@@ -138,7 +138,7 @@ def _render_calendar_view(
e_id = getattr(e, "id", None) e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", "")) e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id) t_url = toggle_url_fn(e_id)
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}' hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
if e_id in associated_entry_ids: if e_id in associated_entry_ids:
entry_btns.append( entry_btns.append(

View File

@@ -156,14 +156,10 @@ def register():
csrf = generate_csrf_token() csrf = generate_csrf_token()
def _like_btn(liked): def _like_btn(liked):
if liked: return sx_call("blog-like-toggle",
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post" like_url=like_url,
else: hx_headers={"X-CSRFToken": csrf},
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post" heart="\u2764\ufe0f" if liked else "\U0001f90d")
return sx_call("market-like-toggle-button",
colour=colour, action=like_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
label=label, icon_cls=icon)
if not g.user: if not g.user:
return sx_response(_like_btn(False), status=403) return sx_response(_like_btn(False), status=403)

View File

@@ -1,6 +1,13 @@
"""Blog page data service — provides serialized dicts for .sx defpages.""" """Blog page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations from __future__ import annotations
from shared.sx.parser import SxExpr
def _sx_content_expr(raw: str) -> SxExpr | None:
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
return SxExpr(raw) if raw else None
class BlogPageService: class BlogPageService:
"""Service for blog page data, callable via (service "blog-page" ...).""" """Service for blog page data, callable via (service "blog-page" ...)."""
@@ -424,7 +431,7 @@ class BlogPageService:
"authors": authors, "authors": authors,
"feature_image": post.get("feature_image"), "feature_image": post.get("feature_image"),
"html_content": post.get("html", ""), "html_content": post.get("html", ""),
"sx_content": post.get("sx_content", ""), "sx_content": _sx_content_expr(post.get("sx_content", "")),
} }
async def preview_data(self, session, *, slug=None, **kw): async def preview_data(self, session, *, slug=None, **kw):

View File

@@ -206,7 +206,7 @@
(when is-admin (when is-admin
(~blog-snippet-visibility-select (~blog-snippet-visibility-select
:patch-url (get s "patch_url") :patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :hx-headers {:X-CSRFToken csrf}
:options (<> :options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private") (~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared") (~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
@@ -217,7 +217,7 @@
:trigger-target "#snippets-list" :trigger-target "#snippets-list"
:title "Delete snippet?" :title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?") :text (str "Delete \u201c" name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :sx-headers {:X-CSRFToken csrf}
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))) :cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list))))))) (or snippets (list)))))))
@@ -240,7 +240,7 @@
:edit-url (get mi "edit_url") :edit-url (get mi "edit_url")
:delete-url (get mi "delete_url") :delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?") :confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))) :hx-headers {:X-CSRFToken csrf}))
(or menu-items (list))))))) (or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service ;; Tag Groups — receives serialized tag group data from service

View File

@@ -2,8 +2,7 @@
(defcomp ~blog-like-button (&key like-url hx-headers heart) (defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl" (div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :sx-post like-url :sx-swap "outerHTML" (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp) (defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1" (<> (div :class "flex justify-center gap-2 mt-1"
@@ -56,7 +55,7 @@
(when has-like (when has-like
(~blog-like-button (~blog-like-button
:like-url like-url :like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}") :hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍"))) :heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true" :sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"

View File

@@ -12,10 +12,13 @@
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested")) (when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
edit)) edit))
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart))
(defcomp ~blog-detail-like (&key like-url hx-headers heart) (defcomp ~blog-detail-like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :sx-post like-url :sx-swap "outerHTML" (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-detail-excerpt (&key excerpt) (defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt)) (div :class "w-full text-center italic text-3xl p-2" excerpt))
@@ -55,8 +58,8 @@
:like (when has-user :like (when has-user
(~blog-detail-like (~blog-detail-like
:like-url like-url :like-url like-url
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :hx-headers {:X-CSRFToken csrf}
:heart (if liked "\u2764\ufe0f" "\U0001f90d"))) :heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt "")) :excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt)) (~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors))))) :at-bar (~blog-at-bar :tags tags :authors authors)))))

View File

@@ -2,7 +2,7 @@
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger) (defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML" (form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3" :sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer" (label :class "flex items-center gap-3 cursor-pointer"
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked (input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500" :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
@@ -140,7 +140,7 @@
(~blog-associated-entry (~blog-associated-entry
:confirm-text (get e "confirm_text") :confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url") :toggle-url (get e "toggle_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title")) :img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name") :name (get e "name")
:date-str (get e "date_str"))) :date-str (get e "date_str")))

View File

@@ -323,7 +323,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
cal_name = getattr(cal, "name", "") cal_name = getattr(cal, "name", "")
href = prefix + url_for("calendar.get", calendar_slug=cal_slug) href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendar.delete", calendar_slug=cal_slug) del_url = url_for("calendar.delete", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item", parts.append(sx_call("crud-item",
href=href, name=cal_name, slug=cal_slug, href=href, name=cal_name, slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr, del_url=del_url, csrf_hdr=csrf_hdr,
@@ -656,7 +656,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug) del_url = url_for("markets.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
parts.append(sx_call("crud-item", parts.append(sx_call("crud-item",
href=market_href, name=m_name, href=market_href, name=m_name,
slug=m_slug, del_url=del_url, slug=m_slug, del_url=del_url,

View File

@@ -552,7 +552,7 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
items += sx_call("events-entry-post-item", items += sx_call("events-entry-post-item",
img=img_html, title=ep_title, img=img_html, title=ep_title,
del_url=del_url, entry_id=eid_s, del_url=del_url, entry_id=eid_s,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') csrf_hdr={"X-CSRFToken": csrf})
posts_html = sx_call("events-entry-posts-list", items=SxExpr(items)) posts_html = sx_call("events-entry-posts-list", items=SxExpr(items))
else: else:
posts_html = sx_call("events-entry-posts-none") posts_html = sx_call("events-entry-posts-none")

View File

@@ -387,7 +387,7 @@ async def _h_slots_data(calendar_slug=None, **kw) -> dict:
csrf = generate_csrf_token() csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "") cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel") hx_select = getattr(g, "hx_select_search", "#main-panel")
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug) add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
slots_list = [] slots_list = []
@@ -624,7 +624,7 @@ async def _h_ticket_types_data(calendar_slug=None, entry_id=None,
cal_slug = getattr(calendar, "slug", "") cal_slug = getattr(calendar, "slug", "")
hx_select = getattr(g, "hx_select_search", "#main-panel") hx_select = getattr(g, "hx_select_search", "#main-panel")
eid = entry.id if entry else 0 eid = entry.id if entry else 0
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
types_list = [] types_list = []
for tt in (ticket_types or []): for tt in (ticket_types or []):
@@ -964,7 +964,7 @@ async def _h_markets_data(**kw) -> dict:
post = ctx.get("post") or {} post = ctx.get("post") or {}
slug = post.get("slug", "") slug = post.get("slug", "")
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
markets_list = [] markets_list = []
for m in markets_raw: for m in markets_raw:

View File

@@ -263,7 +263,7 @@ def render_slots_table(slots, calendar) -> str:
time_str=f"{time_start} - {time_end}", time_str=f"{time_start} - {time_end}",
cost_str=cost_str, action_btn=action_btn, cost_str=cost_str, action_btn=action_btn,
del_url=del_url, del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') csrf_hdr={"X-CSRFToken": csrf})
else: else:
rows_html = sx_call("events-slots-empty-row") rows_html = sx_call("events-slots-empty-row")
@@ -343,7 +343,7 @@ def render_slot_add_form(calendar) -> str:
post_url = url_for("calendar.slots.post", calendar_slug=cal_slug) post_url = url_for("calendar.slots.post", calendar_slug=cal_slug)
cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug) cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
# Days checkboxes (all unchecked for add) # Days checkboxes (all unchecked for add)
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"), day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),

View File

@@ -419,7 +419,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
tt_name=tt.name, cost_str=cost_str, tt_name=tt.name, cost_str=cost_str,
count=str(tt.count), action_btn=action_btn, count=str(tt.count), action_btn=action_btn,
del_url=del_url, del_url=del_url,
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') csrf_hdr={"X-CSRFToken": csrf})
else: else:
rows_html = sx_call("events-ticket-types-empty-row") rows_html = sx_call("events-ticket-types-empty-row")
@@ -699,7 +699,7 @@ def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button", cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
calendar_slug=cal_slug, entry_id=entry.id, calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day) year=year, month=month, day=day)
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
return sx_call("events-ticket-type-add-form", return sx_call("events-ticket-type-add-form",
post_url=post_url, csrf=csrf_hdr, post_url=post_url, csrf=csrf_hdr,

View File

@@ -129,7 +129,7 @@ async def _h_page_admin_data(slug=None, **kw) -> dict:
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "") m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
href = prefix + f"/{post_slug}/{m_slug}/" href = prefix + f"/{post_slug}/{m_slug}/"
del_url = url_for("page_admin.delete_market", market_slug=m_slug) del_url = url_for("page_admin.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' csrf_hdr = {"X-CSRFToken": csrf}
markets.append({ markets.append({
"href": href, "name": m_name, "slug": m_slug, "href": href, "name": m_name, "slug": m_slug,
"del-url": del_url, "csrf-hdr": csrf_hdr, "del-url": del_url, "csrf-hdr": csrf_hdr,

View File

@@ -189,7 +189,7 @@ def render_like_toggle_button(slug: str, liked: bool, *,
return sx_call( return sx_call(
"market-like-toggle-button", "market-like-toggle-button",
colour=colour, action=like_url, colour=colour, action=like_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', hx_headers={"X-CSRFToken": csrf},
label=label, icon_cls=icon, label=label, icon_cls=icon,
) )

View File

@@ -251,6 +251,18 @@
return results; return results;
} }
/** Serialize a JS object as SX dict {:key "val" ...} for attribute values. */
function _serializeDict(obj) {
var parts = [];
for (var k in obj) {
if (!obj.hasOwnProperty(k)) continue;
var v = obj[k];
var vs = typeof v === "string" ? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' : String(v);
parts.push(":" + k + " " + vs);
}
return "{" + parts.join(" ") + "}";
}
// --- Primitives --- // --- Primitives ---
var PRIMITIVES = {}; var PRIMITIVES = {};
@@ -1420,7 +1432,7 @@
} else if (attrVal === true) { } else if (attrVal === true) {
el.setAttribute(attrName, ""); el.setAttribute(attrName, "");
} else { } else {
el.setAttribute(attrName, String(attrVal)); el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal));
} }
} else { } else {
// Child // Child
@@ -1851,7 +1863,7 @@
cancelButtonText: "Cancel" cancelButtonText: "Cancel"
}).then(function (result) { }).then(function (result) {
if (!result.isConfirmed) return; if (!result.isConfirmed) return;
return _doFetch(el, method, url, extraParams); return _doFetch(el, verbInfo, method, url, extraParams);
}); });
} }
if (!window.confirm(confirmMsg)) return Promise.resolve(); if (!window.confirm(confirmMsg)) return Promise.resolve();
@@ -1866,10 +1878,10 @@
extraParams.promptValue = promptVal; extraParams.promptValue = promptVal;
} }
return _doFetch(el, method, url, extraParams); return _doFetch(el, verbInfo, method, url, extraParams);
} }
function _doFetch(el, method, url, extraParams) { function _doFetch(el, verbInfo, method, url, extraParams) {
// sx-sync: abort previous // sx-sync: abort previous
var sync = el.getAttribute("sx-sync"); var sync = el.getAttribute("sx-sync");
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el); if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
@@ -1895,12 +1907,12 @@
var cssHeader = _getSxCssHeader(); var cssHeader = _getSxCssHeader();
if (cssHeader) headers["SX-Css"] = cssHeader; if (cssHeader) headers["SX-Css"] = cssHeader;
// Extra headers from sx-headers // Extra headers from sx-headers (SX dict {:key "val"} or JSON)
var extraH = el.getAttribute("sx-headers"); var extraH = el.getAttribute("sx-headers");
if (extraH) { if (extraH) {
try { try {
var parsed = JSON.parse(extraH); var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH);
for (var k in parsed) headers[k] = parsed[k]; for (var k in parsed) headers[k] = String(parsed[k]);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
@@ -1974,7 +1986,7 @@
var valsAttr = el.getAttribute("sx-vals"); var valsAttr = el.getAttribute("sx-vals");
if (valsAttr) { if (valsAttr) {
try { try {
var vals = JSON.parse(valsAttr); var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr);
if (method === "GET") { if (method === "GET") {
for (var vk in vals) { for (var vk in vals) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);

View File

@@ -206,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str:
return "" return ""
return _render_list(expr, env) return _render_list(expr, env)
# --- dict → skip (data, not renderable) ------------------------------- # --- dict → skip (data, not renderable as HTML content) -----------------
if isinstance(expr, dict): if isinstance(expr, dict):
return "" return ""
@@ -540,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
parts.append(f" {attr_name}") parts.append(f" {attr_name}")
elif attr_val is True: elif attr_val is True:
parts.append(f" {attr_name}") parts.append(f" {attr_name}")
elif isinstance(attr_val, dict):
from .parser import serialize as _sx_serialize
parts.append(f' {attr_name}="{escape_attr(_sx_serialize(attr_val))}"')
else: else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"') parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">") parts.append(">")

View File

@@ -30,8 +30,8 @@ from typing import Any
from .jinja_bridge import sx from .jinja_bridge import sx
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}' SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"}
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}' SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"}
def render_page(source: str, **kwargs: Any) -> str: def render_page(source: str, **kwargs: Any) -> str:

View File

@@ -277,7 +277,7 @@
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received." :description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
:demo-description "Click each button to see what the server receives." :demo-description "Click each button to see what the server receives."
:demo (~vals-headers-demo) :demo (~vals-headers-demo)
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers \"{\\\"X-Custom-Token\\\": \\\"abc123\\\"}\"\n \"Send with headers\")" :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")"
:handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')" :handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')"
:comp-placeholder-id "vals-comp" :comp-placeholder-id "vals-comp"
:wire-placeholder-id "vals-wire")) :wire-placeholder-id "vals-wire"))

View File

@@ -705,7 +705,7 @@
:sx-get "/examples/api/echo-headers" :sx-get "/examples/api/echo-headers"
:sx-target "#headers-result" :sx-target "#headers-result"
:sx-swap "innerHTML" :sx-swap "innerHTML"
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}" :sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Send with headers") "Send with headers")
(div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" (div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400"

View File

@@ -272,7 +272,7 @@
(defcomp ~ref-headers-demo () (defcomp ~ref-headers-demo ()
(div :class "space-y-3" (div :class "space-y-3"
(button :sx-get "/reference/api/echo-headers" (button :sx-get "/reference/api/echo-headers"
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}" :sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
:sx-target "#ref-headers-result" :sx-target "#ref-headers-result"
:sx-swap "innerHTML" :sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"