diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 9129c3c..f300d9e 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -69,7 +69,7 @@ def register(url_prefix="/"): return sx_response(sx_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf}"}}', + hdrs={"X-CSRFToken": csrf}, 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}", checked=checked, diff --git a/account/sx/newsletters.sx b/account/sx/newsletters.sx index 4b0626b..b051d2a 100644 --- a/account/sx/newsletters.sx +++ b/account/sx/newsletters.sx @@ -54,7 +54,7 @@ :toggle (~account-newsletter-toggle :id (str "nl-" nid) :url toggle-url - :hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}") + :hdrs {:X-CSRFToken csrf} :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) :checked checked diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index d06157e..4cbaf70 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -138,7 +138,7 @@ def _render_calendar_view( e_id = getattr(e, "id", None) e_name = esc(getattr(e, "name", "")) 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: entry_btns.append( diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index c5808af..70cf5c4 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -156,14 +156,10 @@ def register(): csrf = generate_csrf_token() def _like_btn(liked): - if liked: - colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post" - else: - colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post" - return sx_call("market-like-toggle-button", - colour=colour, action=like_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', - label=label, icon_cls=icon) + return sx_call("blog-like-toggle", + like_url=like_url, + hx_headers={"X-CSRFToken": csrf}, + heart="\u2764\ufe0f" if liked else "\U0001f90d") if not g.user: return sx_response(_like_btn(False), status=403) diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py index 7595a17..0a42fe5 100644 --- a/blog/services/blog_page.py +++ b/blog/services/blog_page.py @@ -1,6 +1,13 @@ """Blog page data service — provides serialized dicts for .sx defpages.""" 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: """Service for blog page data, callable via (service "blog-page" ...).""" @@ -424,7 +431,7 @@ class BlogPageService: "authors": authors, "feature_image": post.get("feature_image"), "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): diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index c7b2cd9..d227407 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -206,7 +206,7 @@ (when is-admin (~blog-snippet-visibility-select :patch-url (get s "patch_url") - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") + :hx-headers {:X-CSRFToken csrf} :options (<> (~blog-snippet-option :value "private" :selected (= vis "private") :label "private") (~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared") @@ -217,7 +217,7 @@ :trigger-target "#snippets-list" :title "Delete snippet?" :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")))))) (or snippets (list))))))) @@ -240,7 +240,7 @@ :edit-url (get mi "edit_url") :delete-url (get mi "delete_url") :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))))))) ;; Tag Groups — receives serialized tag group data from service diff --git a/blog/sx/cards.sx b/blog/sx/cards.sx index cf4f8fa..2fde9ee 100644 --- a/blog/sx/cards.sx +++ b/blog/sx/cards.sx @@ -2,8 +2,7 @@ (defcomp ~blog-like-button (&key like-url hx-headers heart) (div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl" - (button :sx-post like-url :sx-swap "outerHTML" - :sx-headers hx-headers :class "cursor-pointer" heart))) + (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (defcomp ~blog-draft-status (&key publish-requested timestamp) (<> (div :class "flex justify-center gap-2 mt-1" @@ -56,7 +55,7 @@ (when has-like (~blog-like-button :like-url like-url - :sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}") + :hx-headers {:X-CSRFToken csrf-token} :heart (if liked "❤️" "🤍"))) (a :href href :sx-get href :sx-target "#main-panel" :sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true" diff --git a/blog/sx/detail.sx b/blog/sx/detail.sx index c7ab534..c00ac6e 100644 --- a/blog/sx/detail.sx +++ b/blog/sx/detail.sx @@ -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")) 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) (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" - (button :sx-post like-url :sx-swap "outerHTML" - :sx-headers hx-headers :class "cursor-pointer" heart))) + (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (defcomp ~blog-detail-excerpt (&key excerpt) (div :class "w-full text-center italic text-3xl p-2" excerpt)) @@ -55,8 +58,8 @@ :like (when has-user (~blog-detail-like :like-url like-url - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") - :heart (if liked "\u2764\ufe0f" "\U0001f90d"))) + :hx-headers {:X-CSRFToken csrf} + :heart (if liked "❤️" "🤍"))) :excerpt (when (not (= custom-excerpt "")) (~blog-detail-excerpt :excerpt custom-excerpt)) :at-bar (~blog-at-bar :tags tags :authors authors))))) diff --git a/blog/sx/settings.sx b/blog/sx/settings.sx index c72f04a..b9a8978 100644 --- a/blog/sx/settings.sx +++ b/blog/sx/settings.sx @@ -2,7 +2,7 @@ (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" - :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" (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" @@ -140,7 +140,7 @@ (~blog-associated-entry :confirm-text (get e "confirm_text") :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")) :name (get e "name") :date-str (get e "date_str"))) diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py index 01470e1..24471e3 100644 --- a/events/sxc/pages/calendar.py +++ b/events/sxc/pages/calendar.py @@ -323,7 +323,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str: cal_name = getattr(cal, "name", "") href = prefix + url_for("calendar.get", 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", href=href, name=cal_name, slug=cal_slug, 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", "") market_href = call_url(ctx, "market_url", f"/{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", href=market_href, name=m_name, slug=m_slug, del_url=del_url, diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py index 1a1acd0..506483e 100644 --- a/events/sxc/pages/entries.py +++ b/events/sxc/pages/entries.py @@ -552,7 +552,7 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> items += sx_call("events-entry-post-item", img=img_html, title=ep_title, 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)) else: posts_html = sx_call("events-entry-posts-none") diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index be5137d..b005fbb 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -387,7 +387,7 @@ async def _h_slots_data(calendar_slug=None, **kw) -> dict: csrf = generate_csrf_token() cal_slug = getattr(calendar, "slug", "") 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) slots_list = [] @@ -624,7 +624,7 @@ async def _h_ticket_types_data(calendar_slug=None, entry_id=None, cal_slug = getattr(calendar, "slug", "") hx_select = getattr(g, "hx_select_search", "#main-panel") eid = entry.id if entry else 0 - csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} types_list = [] for tt in (ticket_types or []): @@ -964,7 +964,7 @@ async def _h_markets_data(**kw) -> dict: post = ctx.get("post") or {} slug = post.get("slug", "") - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} markets_list = [] for m in markets_raw: diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py index a0b2ded..bd95562 100644 --- a/events/sxc/pages/slots.py +++ b/events/sxc/pages/slots.py @@ -263,7 +263,7 @@ def render_slots_table(slots, calendar) -> str: time_str=f"{time_start} - {time_end}", cost_str=cost_str, action_btn=action_btn, del_url=del_url, - csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + csrf_hdr={"X-CSRFToken": csrf}) else: 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) 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) day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"), diff --git a/events/sxc/pages/tickets.py b/events/sxc/pages/tickets.py index 669468c..4588bf7 100644 --- a/events/sxc/pages/tickets.py +++ b/events/sxc/pages/tickets.py @@ -419,7 +419,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) - tt_name=tt.name, cost_str=cost_str, count=str(tt.count), action_btn=action_btn, del_url=del_url, - csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + csrf_hdr={"X-CSRFToken": csrf}) else: 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", calendar_slug=cal_slug, entry_id=entry.id, 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", post_url=post_url, csrf=csrf_hdr, diff --git a/market/sxc/pages/helpers.py b/market/sxc/pages/helpers.py index 24c1f3e..81b71ad 100644 --- a/market/sxc/pages/helpers.py +++ b/market/sxc/pages/helpers.py @@ -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 "") href = prefix + f"/{post_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({ "href": href, "name": m_name, "slug": m_slug, "del-url": del_url, "csrf-hdr": csrf_hdr, diff --git a/market/sxc/pages/renders.py b/market/sxc/pages/renders.py index 2a4d1cf..a316d0d 100644 --- a/market/sxc/pages/renders.py +++ b/market/sxc/pages/renders.py @@ -189,7 +189,7 @@ def render_like_toggle_button(slug: str, liked: bool, *, return sx_call( "market-like-toggle-button", colour=colour, action=like_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + hx_headers={"X-CSRFToken": csrf}, label=label, icon_cls=icon, ) diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 8ce9710..070f0bc 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -251,6 +251,18 @@ 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 --- var PRIMITIVES = {}; @@ -1420,7 +1432,7 @@ } else if (attrVal === true) { el.setAttribute(attrName, ""); } else { - el.setAttribute(attrName, String(attrVal)); + el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal)); } } else { // Child @@ -1851,7 +1863,7 @@ cancelButtonText: "Cancel" }).then(function (result) { if (!result.isConfirmed) return; - return _doFetch(el, method, url, extraParams); + return _doFetch(el, verbInfo, method, url, extraParams); }); } if (!window.confirm(confirmMsg)) return Promise.resolve(); @@ -1866,10 +1878,10 @@ 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 var sync = el.getAttribute("sx-sync"); if (sync && sync.indexOf("replace") >= 0) abortPrevious(el); @@ -1895,12 +1907,12 @@ var cssHeader = _getSxCssHeader(); 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"); if (extraH) { try { - var parsed = JSON.parse(extraH); - for (var k in parsed) headers[k] = parsed[k]; + var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH); + for (var k in parsed) headers[k] = String(parsed[k]); } catch (e) { /* ignore */ } } @@ -1974,7 +1986,7 @@ var valsAttr = el.getAttribute("sx-vals"); if (valsAttr) { try { - var vals = JSON.parse(valsAttr); + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); if (method === "GET") { for (var vk in vals) { url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); diff --git a/shared/sx/html.py b/shared/sx/html.py index 50f5092..8123ada 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -206,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str: return "" return _render_list(expr, env) - # --- dict → skip (data, not renderable) ------------------------------- + # --- dict → skip (data, not renderable as HTML content) ----------------- if isinstance(expr, dict): return "" @@ -540,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str: parts.append(f" {attr_name}") elif attr_val is True: 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: parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"') parts.append(">") diff --git a/shared/sx/page.py b/shared/sx/page.py index 6ca1c27..29309c8 100644 --- a/shared/sx/page.py +++ b/shared/sx/page.py @@ -30,8 +30,8 @@ from typing import Any from .jinja_bridge import sx -SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}' -SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}' +SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"} +SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"} def render_page(source: str, **kwargs: Any) -> str: diff --git a/sx/sx/examples-content.sx b/sx/sx/examples-content.sx index a497927..4b38a10 100644 --- a/sx/sx/examples-content.sx +++ b/sx/sx/examples-content.sx @@ -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." :demo-description "Click each button to see what the server receives." :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 (...))')" :comp-placeholder-id "vals-comp" :wire-placeholder-id "vals-wire")) diff --git a/sx/sxc/examples.sx b/sx/sxc/examples.sx index 16cb855..d1765a5 100644 --- a/sx/sxc/examples.sx +++ b/sx/sxc/examples.sx @@ -705,7 +705,7 @@ :sx-get "/examples/api/echo-headers" :sx-target "#headers-result" :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" "Send with headers") (div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" diff --git a/sx/sxc/reference.sx b/sx/sxc/reference.sx index 74374a1..1fa16e8 100644 --- a/sx/sxc/reference.sx +++ b/sx/sxc/reference.sx @@ -272,7 +272,7 @@ (defcomp ~ref-headers-demo () (div :class "space-y-3" (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-swap "innerHTML" :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"