Merge branch 'macros' into worktree-sx-meta-eval

This commit is contained in:
2026-03-05 10:03:15 +00:00
301 changed files with 22850 additions and 18171 deletions

View File

@@ -151,20 +151,20 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
root_header_sx, post_header_sx,
header_child_sx, full_page_sx, sx_call,
)
hdr = root_header_sx(ctx)
hdr = await root_header_sx(ctx)
# Post breadcrumb if we resolved a post
post = (post_data or {}).get("post") or ctx.get("post") or {}
if post.get("slug"):
ctx["post"] = post
post_row = post_header_sx(ctx)
post_row = await post_header_sx(ctx)
if post_row:
hdr = "(<> " + hdr + " " + header_child_sx(post_row) + ")"
hdr = "(<> " + hdr + " " + await header_child_sx(post_row) + ")"
# Error content
error_content = sx_call("error-content", errnum=errnum, message=message, image=image)
return full_page_sx(ctx, header_rows=hdr, content=error_content)
return await full_page_sx(ctx, header_rows=hdr, content=error_content)
except Exception:
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
return None

View File

@@ -1,42 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='social-lite-row', oob=oob) %}
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ url_for('ap_social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('ap_social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }}
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% else %}
<nav class="flex gap-3 text-sm items-center">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% endif %}
</div>
{% endcall %}
{% endmacro %}

View File

@@ -1,10 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% block social_content %}{% endblock %}
{% endblock %}

View File

@@ -1,63 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,53 +0,0 @@
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
{% if item.boosted_by %}
<div class="text-sm text-stone-500 mb-2">
Boosted by {{ item.boosted_by }}
</div>
{% endif %}
<div class="flex items-start gap-3">
{% if item.actor_icon %}
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
<span class="text-sm text-stone-500">
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
</span>
<span class="text-sm text-stone-400 ml-auto">
{% if item.published %}
{{ item.published.strftime('%b %d, %H:%M') }}
{% endif %}
</span>
</div>
{% if item.summary %}
<details class="mt-2">
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
</details>
{% else %}
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
{% endif %}
<div class="mt-2 flex gap-3 text-sm text-stone-400">
{% if item.url and item.post_type == "remote" %}
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
original
</a>
{% endif %}
{% if item.object_id %}
<a href="{{ federation_url('/social/') }}" class="hover:underline">
View on Hub
</a>
{% endif %}
</div>
</div>
</div>
</article>

View File

@@ -1,61 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,13 +0,0 @@
{% for item in items %}
{% include "social/_post_card.html" %}
{% endfor %}
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -1,53 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "social/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = [] %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Social — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Social</h1>
{% if actor %}
<div class="space-y-3">
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Search</div>
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
</a>
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Following</div>
<div class="text-sm text-stone-500">Accounts you follow</div>
</a>
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Followers</div>
<div class="text-sm text-stone-500">Accounts following you here</div>
</a>
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Hub</div>
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
</a>
</div>
{% else %}
<p class="text-stone-500">
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
</p>
{% endif %}
{% endblock %}

View File

@@ -1,32 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Search — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
sx-get="{{ url_for('ap_social.search_page') }}"
sx-target="#search-results"
sx-push-url="{{ url_for('ap_social.search') }}">
<div class="flex gap-2">
<input type="text" name="q" value="{{ query }}"
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
placeholder="Search users or @user@instance.tld">
<button type="submit"
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
Search
</button>
</div>
</form>
{% if query and total %}
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
{% elif query %}
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
{% endif %}
<div id="search-results">
{% include "social/_search_results.html" %}
</div>
{% endblock %}

31
shared/contracts/likes.py Normal file
View File

@@ -0,0 +1,31 @@
"""Protocol for the Likes domain service."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
@runtime_checkable
class LikesService(Protocol):
async def is_liked(
self, session: AsyncSession, *,
user_id: int, target_type: str,
target_slug: str | None = None, target_id: int | None = None,
) -> bool: ...
async def liked_slugs(
self, session: AsyncSession, *,
user_id: int, target_type: str,
) -> list[str]: ...
async def liked_ids(
self, session: AsyncSession, *,
user_id: int, target_type: str,
) -> list[int]: ...
async def toggle(
self, session: AsyncSession, *,
user_id: int, target_type: str,
target_slug: str | None = None, target_id: int | None = None,
) -> bool: ...

View File

@@ -13,7 +13,7 @@ import time
import sys
WATCH_EXTENSIONS = {".sx", ".sx", ".js", ".css"}
SENTINEL = os.path.join(os.path.dirname(__file__), "_reload_sentinel.py")
SENTINEL = "/app/_reload_sentinel.py"
POLL_INTERVAL = 1.5 # seconds

View File

@@ -57,6 +57,77 @@ def _is_aggregate(app_name: str) -> bool:
return app_name == "federation"
async def _render_profile_sx(actor, activities, total):
"""Render the federation actor profile page using SX."""
from markupsafe import escape
from shared.sx.page import get_template_context
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
def _e(v):
s = str(v) if v else ""
return str(escape(s)).replace('"', '\\"')
username = _e(actor.preferred_username)
display_name = _e(actor.display_name or actor.preferred_username)
ap_domain = config().get("ap_domain", "rose-ash.com")
summary_el = ""
if actor.summary:
summary_el = f'(p :class "mt-2" "{_e(actor.summary)}")'
activity_items = []
for a in activities:
ts = ""
if a.published:
ts = a.published.strftime("%Y-%m-%d %H:%M")
obj_el = ""
if a.object_type:
obj_el = f'(span :class "text-sm text-stone-500" "{_e(a.object_type)}")'
activity_items.append(
f'(div :class "bg-white rounded-lg shadow p-4"'
f' (div :class "flex justify-between items-start"'
f' (span :class "font-medium" "{_e(a.activity_type)}")'
f' (span :class "text-sm text-stone-400" "{_e(ts)}"))'
f' {obj_el})')
if activities:
activities_el = ('(div :class "space-y-4" ' +
" ".join(activity_items) + ")")
else:
activities_el = '(p :class "text-stone-500" "No activities yet.")'
content = (
f'(div :id "main-panel"'
f' (div :class "py-8"'
f' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
f' (h1 :class "text-2xl font-bold" "{display_name}")'
f' (p :class "text-stone-500" "@{username}@{_e(ap_domain)}")'
f' {summary_el})'
f' (h2 :class "text-xl font-bold mb-4" "Activities ({total})")'
f' {activities_el}))')
tctx = await get_template_context()
if is_htmx_request():
# Import federation layout for OOB headers
try:
from federation.sxc.pages import _social_oob
oob_headers = await _social_oob(tctx)
except ImportError:
oob_headers = ""
return sx_response(await oob_page_sx(oobs=oob_headers, content=content))
else:
try:
from federation.sxc.pages import _social_full
header_rows = await _social_full(tctx)
except ImportError:
from shared.sx.helpers import root_header_sx
header_rows = await root_header_sx(tctx)
return await full_page_sx(tctx, header_rows=header_rows, content=content)
def create_activitypub_blueprint(app_name: str) -> Blueprint:
"""Return a Blueprint with AP endpoints for *app_name*."""
bp = Blueprint("activitypub", __name__)
@@ -272,16 +343,10 @@ def create_activitypub_blueprint(app_name: str) -> Blueprint:
# HTML: federation renders its own profile; other apps redirect there
if aggregate:
from quart import render_template
activities, total = await services.federation.get_outbox(
g._ap_s, username, page=1, per_page=20,
)
return await render_template(
"federation/profile.html",
actor=actor,
activities=activities,
total=total,
)
return await _render_profile_sx(actor, activities, total)
from quart import redirect
return redirect(f"https://{fed_domain}/users/{username}")

View File

@@ -2,13 +2,15 @@
Lightweight social UI for blog/market/events. Federation keeps the full
social hub (timeline, compose, notifications, interactions).
All rendering uses s-expressions (no Jinja templates).
"""
from __future__ import annotations
import logging
from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
from quart import Blueprint, request, g, redirect, url_for, abort, Response
from shared.services.registry import services
@@ -77,15 +79,36 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
abort(403, "You need to choose a federation username first")
return actor
async def _render_social_page(content: str, actor=None, title: str = "Social"):
"""Render a full social page or OOB response depending on request type."""
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.page import get_template_context
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
from shared.infrastructure.ap_social_sx import (
_social_full_headers, _social_oob_headers,
)
tctx = await get_template_context()
kw = {"actor": actor}
if is_htmx_request():
oob_headers = await _social_oob_headers(tctx, **kw)
return sx_response(await oob_page_sx(
oobs=oob_headers,
content=content,
))
else:
header_rows = await _social_full_headers(tctx, **kw)
return await full_page_sx(tctx, header_rows=header_rows, content=content)
# -- Index ----------------------------------------------------------------
@bp.get("/")
async def index():
actor = getattr(g, "_social_actor", None)
return await render_template(
"social/index.html",
actor=actor,
)
from shared.infrastructure.ap_social_sx import social_index_content_sx
content = social_index_content_sx(actor)
return await _render_social_page(content, actor, title="Social")
# -- Search ---------------------------------------------------------------
@@ -103,15 +126,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"social/search.html",
query=query,
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
from shared.infrastructure.ap_social_sx import social_search_content_sx
content = social_search_content_sx(query, actors, total, 1, followed_urls, actor)
return await _render_social_page(content, actor, title="Search")
@bp.get("/search/page")
async def search_page():
@@ -130,15 +147,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"social/_search_results.html",
actors=actors,
total=total,
page=page,
query=query,
followed_urls=followed_urls,
actor=actor,
)
from shared.infrastructure.ap_social_sx import search_results_sx
from shared.sx.helpers import sx_response
content = search_results_sx(actors, total, page, query, followed_urls, actor)
return sx_response(content)
# -- Follow / Unfollow ----------------------------------------------------
@@ -169,7 +181,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
return redirect(request.referrer or url_for("ap_social.search"))
async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX."""
"""Re-render a single actor card after follow/unfollow."""
remote_dto = await services.federation.get_or_fetch_remote_actor(
g._ap_s, remote_actor_url,
)
@@ -181,15 +193,12 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
list_type = "followers"
else:
list_type = "following"
return await render_template(
"social/_actor_list_items.html",
actors=[remote_dto],
total=0,
page=1,
list_type=list_type,
followed_urls=followed_urls,
actor=actor,
from shared.infrastructure.ap_social_sx import actor_list_items_sx
from shared.sx.helpers import sx_response
content = actor_list_items_sx(
[remote_dto], 0, 1, list_type, followed_urls, actor,
)
return sx_response(content)
# -- Followers ------------------------------------------------------------
@@ -203,14 +212,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"social/followers.html",
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
from shared.infrastructure.ap_social_sx import social_followers_content_sx
content = social_followers_content_sx(actors, total, 1, followed_urls, actor)
return await _render_social_page(content, actor, title="Followers")
@bp.get("/followers/page")
async def followers_list_page():
@@ -223,15 +227,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"social/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="followers",
followed_urls=followed_urls,
actor=actor,
)
from shared.infrastructure.ap_social_sx import actor_list_items_sx
from shared.sx.helpers import sx_response
content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
return sx_response(content)
# -- Following ------------------------------------------------------------
@@ -241,13 +240,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
actors, total = await services.federation.get_following(
g._ap_s, actor.preferred_username,
)
return await render_template(
"social/following.html",
actors=actors,
total=total,
page=1,
actor=actor,
)
from shared.infrastructure.ap_social_sx import social_following_content_sx
content = social_following_content_sx(actors, total, 1, actor)
return await _render_social_page(content, actor, title="Following")
@bp.get("/following/page")
async def following_list_page():
@@ -256,15 +251,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
actors, total = await services.federation.get_following(
g._ap_s, actor.preferred_username, page=page,
)
return await render_template(
"social/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="following",
followed_urls=set(),
actor=actor,
)
from shared.infrastructure.ap_social_sx import actor_list_items_sx
from shared.sx.helpers import sx_response
content = actor_list_items_sx(actors, total, page, "following", set(), actor)
return sx_response(content)
# -- Actor timeline -------------------------------------------------------
@@ -295,13 +285,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
)
).scalar_one_or_none()
is_following = existing is not None
return await render_template(
"social/actor_timeline.html",
remote_actor=remote_dto,
items=items,
is_following=is_following,
actor=actor,
)
from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx
content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor)
return await _render_social_page(content, actor)
@bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int):
@@ -316,12 +302,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
items = await services.federation.get_actor_timeline(
g._ap_s, id, before=before,
)
return await render_template(
"social/_timeline_items.html",
items=items,
timeline_type="actor",
actor_id=id,
actor=actor,
)
from shared.infrastructure.ap_social_sx import timeline_items_sx
from shared.sx.helpers import sx_response
content = timeline_items_sx(items, "actor", id, actor)
return sx_response(content)
return bp

View File

@@ -0,0 +1,592 @@
"""SX content builders for the per-app AP social blueprint.
Builds s-expression source strings for all social pages, replacing
the Jinja templates in shared/browser/templates/social/.
All dynamic values (URLs, CSRF tokens) are resolved server-side in Python
and embedded as string literals — the SX is rendered client-side where
server primitives like url-for and csrf-token are unavailable.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sx.helpers import (
root_header_sx, oob_header_sx,
mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx,
)
# ---------------------------------------------------------------------------
# Layout — "social-lite": root header + social nav row
# ---------------------------------------------------------------------------
def setup_social_layout() -> None:
"""Register the social-lite layout. Called once during app startup."""
from shared.sx.layouts import register_custom_layout
register_custom_layout(
"social-lite",
_social_full_headers,
_social_oob_headers,
_social_mobile,
)
def _social_nav_items(actor: Any) -> str:
"""Build the social nav items as sx source.
All URLs resolved server-side via Quart's url_for.
"""
from quart import url_for
from shared.infrastructure.urls import app_url
search_url = _e(url_for("ap_social.search"))
hub_url = _e(app_url("federation", "/social/"))
parts: list[str] = []
if actor:
following_url = _e(url_for("ap_social.following_list"))
followers_url = _e(url_for("ap_social.followers_list"))
username = _e(getattr(actor, "preferred_username", ""))
try:
profile_url = _e(url_for("activitypub.actor_profile",
username=actor.preferred_username))
except Exception:
profile_url = ""
parts.append(f'(a :href "{search_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
parts.append(f'(a :href "{following_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Following")')
parts.append(f'(a :href "{followers_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Followers")')
if profile_url:
parts.append(f'(a :href "{profile_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm"'
f' "@{username}")')
parts.append(f'(a :href "{hub_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
f' "Hub")')
else:
parts.append(f'(a :href "{search_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
parts.append(f'(a :href "{hub_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
f' "Hub")')
return " ".join(parts)
def _social_header_row(actor: Any) -> str:
"""Build the social nav header row as sx source."""
nav = _social_nav_items(actor)
return (
f'(div :id "social-lite-header-child"'
f' :class "flex flex-col items-center md:flex-row justify-center'
f' md:justify-between w-full p-1 bg-stone-300"'
f' (div :class "w-full flex flex-row items-center gap-2 flex-wrap"'
f' (nav :class "flex gap-3 text-sm items-center flex-wrap" {nav})))'
)
async def _social_full_headers(ctx: dict, **kw: Any) -> str:
root_hdr = await root_header_sx(ctx)
actor = kw.get("actor")
social_row = _social_header_row(actor)
return "(<> " + root_hdr + " " + social_row + ")"
async def _social_oob_headers(ctx: dict, **kw: Any) -> str:
root_hdr = await root_header_sx(ctx)
actor = kw.get("actor")
social_row = _social_header_row(actor)
rows = "(<> " + root_hdr + " " + social_row + ")"
return await oob_header_sx("root-header-child", "social-lite-header-child", rows)
async def _social_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(await mobile_root_nav_sx(ctx))
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _e(val: Any) -> str:
"""Escape a value for safe embedding in sx source strings."""
s = str(val) if val else ""
return str(escape(s)).replace('"', '\\"')
def _esc_raw(html: str) -> str:
"""Escape raw HTML for embedding as a string literal in sx.
The string will be passed to (raw! ...) so it should NOT be HTML-escaped,
only the sx string delimiters need escaping.
"""
return html.replace("\\", "\\\\").replace('"', '\\"')
def _actor_initial(a: Any) -> str:
"""Get the uppercase first character of an actor's display name or username."""
name = _actor_name(a)
return name[0].upper() if name else "?"
def _actor_name(a: Any) -> str:
"""Get display name or preferred username from an actor (DTO or dict)."""
if isinstance(a, dict):
return a.get("display_name") or a.get("preferred_username") or ""
return getattr(a, "display_name", None) or getattr(a, "preferred_username", "") or ""
def _attr(a: Any, key: str, default: str = "") -> Any:
"""Get attribute from DTO or dict."""
if isinstance(a, dict):
return a.get(key, default)
return getattr(a, key, default)
def _strip_tags(s: str) -> str:
import re
return re.sub(r"<[^>]+>", "", s)
def _csrf() -> str:
"""Get the CSRF token as a string."""
from quart import current_app
fn = current_app.jinja_env.globals.get("csrf_token")
if callable(fn):
return str(fn())
return ""
# ---------------------------------------------------------------------------
# Actor card — used in search results, followers, following
# ---------------------------------------------------------------------------
def _actor_card_sx(a: Any, followed_urls: set, actor: Any,
list_type: str = "search") -> str:
"""Build sx source for a single actor card."""
from quart import url_for
actor_url = _attr(a, "actor_url", "")
safe_id = actor_url.replace("/", "_").replace(":", "_")
icon_url = _attr(a, "icon_url", "")
display_name = _actor_name(a)
username = _attr(a, "preferred_username", "")
domain = _attr(a, "domain", "")
summary = _attr(a, "summary", "")
actor_id = _attr(a, "id")
csrf = _e(_csrf())
# Avatar
if icon_url:
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-12 h-12 rounded-full")'
else:
initial = _actor_initial(a)
avatar = (f'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold" "{initial}")')
# Name link
if (list_type in ("following", "search")) and actor_id:
tl_url = _e(url_for("ap_social.actor_timeline", id=actor_id))
name_el = (f'(a :href "{tl_url}"'
f' :class "font-semibold text-stone-900 hover:underline"'
f' "{_e(display_name)}")')
else:
name_el = (f'(a :href "https://{_e(domain)}/@{_e(username)}"'
f' :target "_blank" :rel "noopener"'
f' :class "font-semibold text-stone-900 hover:underline"'
f' "{_e(display_name)}")')
handle = f'(div :class "text-sm text-stone-500" "@{_e(username)}@{_e(domain)}")'
# Summary
summary_el = ""
if summary:
clean = _strip_tags(summary)
summary_el = (f'(div :class "text-sm text-stone-600 mt-1 truncate"'
f' "{_e(clean)}")')
# Follow/unfollow button
button_el = ""
if actor:
is_followed = (list_type == "following" or actor_url in (followed_urls or set()))
if is_followed:
unfollow_url = _e(url_for("ap_social.unfollow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{unfollow_url}"'
f' :sx-post "{unfollow_url}"'
f' :sx-target "closest article" :sx-swap "outerHTML"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100"'
f' "Unfollow")))')
else:
follow_url = _e(url_for("ap_social.follow"))
label = "Follow Back" if list_type == "followers" else "Follow"
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{follow_url}"'
f' :sx-post "{follow_url}"'
f' :sx-target "closest article" :sx-swap "outerHTML"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700"'
f' "{label}")))')
return (
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200'
f' p-4 mb-3 flex items-center gap-4" :id "actor-{_e(safe_id)}"'
f' {avatar}'
f' (div :class "flex-1 min-w-0" {name_el} {handle} {summary_el})'
f' {button_el})'
)
# ---------------------------------------------------------------------------
# Actor list items — paginated fragment
# ---------------------------------------------------------------------------
def actor_list_items_sx(actors: list, total: int, page: int,
list_type: str, followed_urls: set, actor: Any) -> str:
"""Build sx source for a list of actor cards with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, followed_urls, actor, list_type) for a in actors]
# Infinite scroll sentinel
if len(actors) >= 20:
next_page = page + 1
ep = f"ap_social.{list_type}_list_page"
next_url = _e(url_for(ep, page=next_page))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Search results — paginated fragment
# ---------------------------------------------------------------------------
def search_results_sx(actors: list, total: int, page: int,
query: str, followed_urls: set, actor: Any) -> str:
"""Build sx source for search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, followed_urls, actor, "search") for a in actors]
if len(actors) >= 20:
next_page = page + 1
next_url = _e(url_for("ap_social.search_page", q=query, page=next_page))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Post card — timeline item
# ---------------------------------------------------------------------------
def _post_card_sx(item: Any) -> str:
"""Build sx source for a single post/status card."""
from shared.infrastructure.urls import app_url
actor_name = _attr(item, "actor_name", "")
actor_username = _attr(item, "actor_username", "")
actor_domain = _attr(item, "actor_domain", "")
actor_icon = _attr(item, "actor_icon", "")
content = _attr(item, "content", "")
summary = _attr(item, "summary", "")
published = _attr(item, "published")
boosted_by = _attr(item, "boosted_by", "")
url = _attr(item, "url", "")
object_id = _attr(item, "object_id", "")
post_type = _attr(item, "post_type", "")
boost_el = ""
if boosted_by:
boost_el = (f'(div :class "text-sm text-stone-500 mb-2"'
f' "Boosted by {_e(boosted_by)}")')
# Avatar
if actor_icon:
avatar = f'(img :src "{_e(actor_icon)}" :alt "" :class "w-10 h-10 rounded-full")'
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = (f'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold text-sm" "{initial}")')
# Handle
handle_text = f"@{_e(actor_username)}"
if actor_domain:
handle_text += f"@{_e(actor_domain)}"
# Timestamp
time_el = ""
if published:
if hasattr(published, "strftime"):
ts = published.strftime("%b %d, %H:%M")
else:
ts = str(published)
time_el = f'(span :class "text-sm text-stone-400 ml-auto" "{_e(ts)}")'
# Content — raw HTML from AP, render with raw!
if summary:
content_el = (
f'(details :class "mt-2"'
f' (summary :class "text-stone-500 cursor-pointer" "CW: {_e(summary)}")'
f' (div :class "mt-2 prose prose-sm prose-stone max-w-none"'
f' (raw! "{_esc_raw(content)}")))')
else:
content_el = (
f'(div :class "mt-2 prose prose-sm prose-stone max-w-none"'
f' (raw! "{_esc_raw(content)}"))')
# Links
links: list[str] = []
if url and post_type == "remote":
links.append(
f'(a :href "{_e(url)}" :target "_blank" :rel "noopener"'
f' :class "hover:underline" "original")')
if object_id:
hub_url = _e(app_url("federation", "/social/"))
links.append(
f'(a :href "{hub_url}"'
f' :class "hover:underline" "View on Hub")')
links_el = ""
if links:
links_el = ('(div :class "mt-2 flex gap-3 text-sm text-stone-400" '
+ " ".join(links) + ")")
return (
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
f' {boost_el}'
f' (div :class "flex items-start gap-3"'
f' {avatar}'
f' (div :class "flex-1 min-w-0"'
f' (div :class "flex items-baseline gap-2"'
f' (span :class "font-semibold text-stone-900" "{_e(actor_name)}")'
f' (span :class "text-sm text-stone-500" "{handle_text}")'
f' {time_el})'
f' {content_el}'
f' {links_el})))'
)
# ---------------------------------------------------------------------------
# Timeline items — paginated fragment
# ---------------------------------------------------------------------------
def timeline_items_sx(items: list, timeline_type: str = "",
actor_id: int | None = None, actor: Any = None) -> str:
"""Build sx source for timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_sx(item) for item in items]
if items and timeline_type == "actor" and actor_id:
last = items[-1]
published = _attr(last, "published")
if published and hasattr(published, "isoformat"):
before = published.isoformat()
else:
before = str(published) if published else ""
if before:
next_url = _e(url_for("ap_social.actor_timeline_page",
id=actor_id, before=before))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Full page content builders
# ---------------------------------------------------------------------------
def social_index_content_sx(actor: Any) -> str:
"""Build sx source for the social index page content."""
from quart import url_for
from shared.infrastructure.urls import app_url
search_url = _e(url_for("ap_social.search"))
hub_url = _e(app_url("federation", "/social/"))
if actor:
following_url = _e(url_for("ap_social.following_list"))
followers_url = _e(url_for("ap_social.followers_list"))
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
f' (div :class "space-y-3"'
f' (a :href "{search_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Search")'
f' (div :class "text-sm text-stone-500" "Find and follow accounts on the fediverse"))'
f' (a :href "{following_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Following")'
f' (div :class "text-sm text-stone-500" "Accounts you follow"))'
f' (a :href "{followers_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Followers")'
f' (div :class "text-sm text-stone-500" "Accounts following you here"))'
f' (a :href "{hub_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Hub")'
f' (div :class "text-sm text-stone-500"'
f' "Full social experience \\u2014 timeline, compose, notifications"))))')
else:
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
f' (p :class "text-stone-500"'
f' (a :href "{search_url}" :class "underline" "Search")'
f' " for accounts on the fediverse, or visit the "'
f' (a :href "{hub_url}" :class "underline" "Hub")'
f' " to get started."))')
def social_search_content_sx(query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Build sx source for the search page content."""
from quart import url_for
search_url = _e(url_for("ap_social.search"))
search_page_url = _e(url_for("ap_social.search_page"))
# Results message
msg = ""
if query and total:
s = "s" if total != 1 else ""
msg = (f'(p :class "text-sm text-stone-500 mb-4"'
f' "{total} result{s} for " (strong "{_e(query)}"))')
elif query:
msg = (f'(p :class "text-stone-500 mb-4"'
f' "No results found for " (strong "{_e(query)}"))')
results = search_results_sx(actors, total, page, query, followed_urls, actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Search")'
f' (form :method "get" :action "{search_url}"'
f' :sx-get "{search_page_url}"'
f' :sx-target "#search-results"'
f' :sx-push-url "{search_url}"'
f' :class "mb-6"'
f' (div :class "flex gap-2"'
f' (input :type "text" :name "q" :value "{_e(query)}"'
f' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2'
f' focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' :placeholder "Search users or @user@instance.tld")'
f' (button :type "submit"'
f' :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700"'
f' "Search")))'
f' {msg}'
f' (div :id "search-results" {results}))'
)
def social_followers_content_sx(actors: list, total: int, page: int,
followed_urls: set, actor: Any) -> str:
"""Build sx source for the followers page content."""
items = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Followers "'
f' (span :class "text-stone-400 font-normal" "({total})"))'
f' (div :id "actor-list" {items}))'
)
def social_following_content_sx(actors: list, total: int,
page: int, actor: Any) -> str:
"""Build sx source for the following page content."""
items = actor_list_items_sx(actors, total, page, "following", set(), actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Following "'
f' (span :class "text-stone-400 font-normal" "({total})"))'
f' (div :id "actor-list" {items}))'
)
def social_actor_timeline_content_sx(remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Build sx source for the actor timeline page content."""
from quart import url_for
ra = remote_actor
display_name = _actor_name(ra)
username = _attr(ra, "preferred_username", "")
domain = _attr(ra, "domain", "")
icon_url = _attr(ra, "icon_url", "")
summary = _attr(ra, "summary", "")
actor_url = _attr(ra, "actor_url", "")
ra_id = _attr(ra, "id")
csrf = _e(_csrf())
# Avatar
if icon_url:
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-16 h-16 rounded-full")'
else:
initial = display_name[0].upper() if display_name else "?"
avatar = (f'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold text-xl" "{initial}")')
# Summary — raw HTML from AP
summary_el = ""
if summary:
summary_el = (f'(div :class "text-sm text-stone-600 mt-2"'
f' (raw! "{_esc_raw(summary)}"))')
# Follow/unfollow button
button_el = ""
if actor:
if is_following:
unfollow_url = _e(url_for("ap_social.unfollow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{unfollow_url}"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100"'
f' "Unfollow")))')
else:
follow_url = _e(url_for("ap_social.follow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{follow_url}"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"'
f' "Follow")))')
tl = timeline_items_sx(items, "actor", ra_id, actor)
return (
f'(div :id "main-panel"'
f' (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
f' (div :class "flex items-center gap-4"'
f' {avatar}'
f' (div :class "flex-1"'
f' (h1 :class "text-xl font-bold" "{_e(display_name)}")'
f' (div :class "text-stone-500" "@{_e(username)}@{_e(domain)}")'
f' {summary_el})'
f' {button_el}))'
f' (div :id "timeline" {tl}))'
)

View File

@@ -117,6 +117,15 @@ def create_base_app(
load_shared_components()
load_relation_registry()
# Load defquery/defaction definitions from {service}/queries.sx and actions.sx
from shared.sx.query_registry import load_service_protocols
_app_root = Path(os.getcwd())
load_service_protocols(name, str(_app_root))
# Register /internal/schema endpoint for protocol introspection
from shared.infrastructure.schema_blueprint import create_schema_blueprint
app.register_blueprint(create_schema_blueprint(name))
# Load CSS registry (tw.css → class-to-rule lookup for on-demand CSS)
from shared.sx.css_registry import load_css_registry, registry_loaded
_styles = BASE_DIR / "static" / "styles"
@@ -160,6 +169,8 @@ def create_base_app(
# Auto-register per-app social blueprint (not federation — it has its own)
if name in AP_APPS and name != "federation":
from shared.infrastructure.ap_social import create_ap_social_blueprint
from shared.infrastructure.ap_social_sx import setup_social_layout
setup_social_layout()
app.register_blueprint(create_ap_social_blueprint(name))
# --- device id (all apps, including account) ---
@@ -330,6 +341,14 @@ def create_base_app(
response.headers["HX-Preserve-Search"] = value
return response
# Prevent browser caching of static files in dev (forces fresh fetch on reload)
if app.config["NO_PAGE_CACHE"]:
@app.after_request
async def _no_cache_static(response):
if request.path.startswith("/static/"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
# --- context processor ---
if context_fn is not None:
@app.context_processor

View File

@@ -0,0 +1,58 @@
"""Protocol manifest — aggregates /internal/schema from all services.
Can be used as a CLI tool or imported for dev-mode inspection.
Usage::
python -m shared.infrastructure.protocol_manifest
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from shared.infrastructure.data_client import fetch_data
# Service names that have inter-service protocols
_SERVICES = [
"blog", "market", "cart", "events", "account",
"likes", "relations", "orders",
]
async def fetch_service_schema(service: str) -> dict[str, Any] | None:
"""Fetch /internal/schema from a single service."""
try:
from shared.infrastructure.urls import service_url
import aiohttp
url = service_url(service, "/internal/schema")
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
if resp.status == 200:
return await resp.json()
except Exception:
return None
return None
async def generate_manifest() -> dict[str, Any]:
"""Fetch schemas from all services and produce a unified protocol map."""
results = await asyncio.gather(
*(fetch_service_schema(s) for s in _SERVICES),
return_exceptions=True,
)
manifest = {"services": {}}
for service, result in zip(_SERVICES, results):
if isinstance(result, dict):
manifest["services"][service] = result
else:
manifest["services"][service] = {"error": str(result) if isinstance(result, Exception) else "unavailable"}
return manifest
if __name__ == "__main__":
m = asyncio.run(generate_manifest())
print(json.dumps(m, indent=2))

View File

@@ -0,0 +1,127 @@
"""
Blueprint factories for sx-dispatched data and action routes.
Replaces per-service boilerplate in ``bp/data/routes.py`` and
``bp/actions/routes.py`` by dispatching to defquery/defaction definitions
from the sx query registry. Falls back to Python ``_handlers`` dicts
for queries/actions not yet converted.
Usage::
from shared.infrastructure.query_blueprint import (
create_data_blueprint, create_action_blueprint,
)
# In service's bp/data/routes.py:
def register() -> Blueprint:
bp, _handlers = create_data_blueprint("events")
# Optional Python fallback handlers:
# _handlers["some-query"] = _some_python_handler
return bp
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Awaitable
from quart import Blueprint, g, jsonify, request
logger = logging.getLogger("sx.query_blueprint")
def create_data_blueprint(
service_name: str,
) -> tuple[Blueprint, dict[str, Callable[[], Awaitable[Any]]]]:
"""Create a data blueprint that dispatches to sx queries with Python fallback.
Returns (blueprint, python_handlers_dict) so the caller can register
Python fallback handlers for queries not yet converted to sx.
"""
from shared.infrastructure.data_client import DATA_HEADER
bp = Blueprint("data", __name__, url_prefix="/internal/data")
_handlers: dict[str, Callable[[], Awaitable[Any]]] = {}
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
@bp.get("/<query_name>")
async def handle_query(query_name: str):
# 1. Check sx query registry first
from shared.sx.query_registry import get_query
from shared.sx.query_executor import execute_query
qdef = get_query(service_name, query_name)
if qdef is not None:
result = await execute_query(qdef, dict(request.args))
return jsonify(result)
# 2. Fall back to Python handlers
handler = _handlers.get(query_name)
if handler is not None:
result = await handler()
return jsonify(result)
return jsonify({"error": "unknown query"}), 404
return bp, _handlers
def create_action_blueprint(
service_name: str,
) -> tuple[Blueprint, dict[str, Callable[[], Awaitable[Any]]]]:
"""Create an action blueprint that dispatches to sx actions with Python fallback.
Returns (blueprint, python_handlers_dict) so the caller can register
Python fallback handlers for actions not yet converted to sx.
"""
from shared.infrastructure.actions import ACTION_HEADER
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
_handlers: dict[str, Callable[[], Awaitable[Any]]] = {}
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
@bp.post("/<action_name>")
async def handle_action(action_name: str):
# 1. Check sx action registry first
from shared.sx.query_registry import get_action
from shared.sx.query_executor import execute_action
adef = get_action(service_name, action_name)
if adef is not None:
try:
payload = await request.get_json(force=True) or {}
result = await execute_action(adef, payload)
return jsonify(result)
except Exception as exc:
logger.exception("SX action %s:%s failed", service_name, action_name)
return jsonify({"error": str(exc)}), 500
# 2. Fall back to Python handlers
handler = _handlers.get(action_name)
if handler is not None:
try:
result = await handler()
return jsonify(result)
except Exception as exc:
logger.exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
return jsonify({"error": "unknown action"}), 404
return bp, _handlers

View File

@@ -0,0 +1,31 @@
"""Schema endpoint for inter-service protocol introspection.
Every service exposes ``GET /internal/schema`` which returns a JSON
manifest of all defquery and defaction definitions with their parameter
signatures and docstrings.
Usage::
from shared.infrastructure.schema_blueprint import create_schema_blueprint
app.register_blueprint(create_schema_blueprint("events"))
"""
from __future__ import annotations
from quart import Blueprint, jsonify, request
def create_schema_blueprint(service_name: str) -> Blueprint:
"""Create a blueprint exposing ``/internal/schema``."""
bp = Blueprint(
f"schema_{service_name}",
__name__,
url_prefix="/internal",
)
@bp.get("/schema")
async def get_schema():
from shared.sx.query_registry import schema_for_service
return jsonify(schema_for_service(service_name))
return bp

View File

@@ -0,0 +1,42 @@
"""Service methods for account data queries.
Extracted from account/bp/data/routes.py to enable sx defquery conversion.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import User
class SqlAccountDataService:
async def user_by_email(
self, session: AsyncSession, *, email: str,
) -> dict[str, Any] | None:
email = (email or "").strip().lower()
if not email:
return None
result = await session.execute(
select(User.id).where(User.email.ilike(email))
)
row = result.first()
if not row:
return None
return {"user_id": row[0]}
async def newsletters(self, session: AsyncSession) -> list[dict[str, Any]]:
from shared.models.ghost_membership_entities import GhostNewsletter
result = await session.execute(
select(
GhostNewsletter.id, GhostNewsletter.ghost_id,
GhostNewsletter.name, GhostNewsletter.slug,
).order_by(GhostNewsletter.name)
)
return [
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
for row in result.all()
]

View File

@@ -0,0 +1,37 @@
"""Extra cart query methods not in the CartService protocol.
cart-items returns raw CartItem data without going through CartSummaryDTO.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.market import CartItem
class SqlCartItemsService:
async def cart_items(
self, session: AsyncSession, *,
user_id: int | None = None, session_id: str | None = None,
) -> list[dict[str, Any]]:
filters = [CartItem.deleted_at.is_(None)]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
elif session_id is not None:
filters.append(CartItem.session_id == session_id)
else:
return []
result = await session.execute(select(CartItem).where(*filters))
return [
{
"product_id": item.product_id,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in result.scalars().all()
]

View File

@@ -0,0 +1,110 @@
"""SQL implementation of the LikesService protocol.
Extracted from likes/bp/data/routes.py and likes/bp/actions/routes.py
to enable sx defquery/defaction conversion.
"""
from __future__ import annotations
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
def _Like():
from models.like import Like
return Like
class SqlLikesService:
async def is_liked(
self, session: AsyncSession, *,
user_id: int, target_type: str,
target_slug: str | None = None, target_id: int | None = None,
) -> bool:
Like = _Like()
if not user_id or not target_type:
return False
filters = [
Like.user_id == user_id,
Like.target_type == target_type,
Like.deleted_at.is_(None),
]
if target_slug is not None:
filters.append(Like.target_slug == target_slug)
elif target_id is not None:
filters.append(Like.target_id == target_id)
else:
return False
row = await session.scalar(select(Like.id).where(*filters))
return row is not None
async def liked_slugs(
self, session: AsyncSession, *,
user_id: int, target_type: str,
) -> list[str]:
Like = _Like()
if not user_id or not target_type:
return []
result = await session.execute(
select(Like.target_slug).where(
Like.user_id == user_id,
Like.target_type == target_type,
Like.target_slug.isnot(None),
Like.deleted_at.is_(None),
)
)
return list(result.scalars().all())
async def liked_ids(
self, session: AsyncSession, *,
user_id: int, target_type: str,
) -> list[int]:
Like = _Like()
if not user_id or not target_type:
return []
result = await session.execute(
select(Like.target_id).where(
Like.user_id == user_id,
Like.target_type == target_type,
Like.target_id.isnot(None),
Like.deleted_at.is_(None),
)
)
return list(result.scalars().all())
async def toggle(
self, session: AsyncSession, *,
user_id: int, target_type: str,
target_slug: str | None = None, target_id: int | None = None,
) -> bool:
"""Toggle a like. Returns True if now liked, False if unliked."""
Like = _Like()
filters = [
Like.user_id == user_id,
Like.target_type == target_type,
Like.deleted_at.is_(None),
]
if target_slug is not None:
filters.append(Like.target_slug == target_slug)
elif target_id is not None:
filters.append(Like.target_id == target_id)
else:
raise ValueError("target_slug or target_id required")
existing = await session.scalar(select(Like).where(*filters))
if existing:
await session.execute(
update(Like).where(Like.id == existing.id).values(deleted_at=func.now())
)
return False
else:
new_like = Like(
user_id=user_id,
target_type=target_type,
target_slug=target_slug,
target_id=target_id,
)
session.add(new_like)
await session.flush()
return True

View File

@@ -0,0 +1,55 @@
"""Extra market query methods for raw-SQLAlchemy data lookups.
products-by-ids and marketplaces-by-ids use direct selects rather
than the MarketService protocol methods.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
class SqlMarketDataService:
async def products_by_ids(
self, session: AsyncSession, *, ids: list[int],
) -> list[dict[str, Any]]:
if not ids:
return []
from shared.models.market import Product
rows = (await session.execute(
select(Product).where(Product.id.in_(ids))
)).scalars().all()
return [
{
"id": p.id,
"title": p.title,
"slug": p.slug,
"image": p.image,
"regular_price": str(p.regular_price) if p.regular_price is not None else None,
"special_price": str(p.special_price) if p.special_price is not None else None,
}
for p in rows
]
async def marketplaces_by_ids(
self, session: AsyncSession, *, ids: list[int],
) -> list[dict[str, Any]]:
if not ids:
return []
from shared.models.market_place import MarketPlace
rows = (await session.execute(
select(MarketPlace).where(MarketPlace.id.in_(ids))
)).scalars().all()
return [
{
"id": m.id,
"name": m.name,
"slug": m.slug,
"container_type": m.container_type,
"container_id": m.container_id,
}
for m in rows
]

View File

@@ -0,0 +1,137 @@
"""SQL implementation of PageConfig service methods.
Extracted from blog/bp/data/routes.py and blog/bp/actions/routes.py
to enable sx defquery/defaction conversion.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from shared.models.page_config import PageConfig
def _to_dict(pc: PageConfig) -> dict[str, Any]:
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}
class SqlPageConfigService:
async def ensure(
self, session: AsyncSession, *,
container_type: str = "page", container_id: int,
) -> dict[str, Any]:
"""Get or create a PageConfig. Returns minimal dict with id."""
row = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
session.add(row)
await session.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
async def get_by_container(
self, session: AsyncSession, *,
container_type: str = "page", container_id: int,
) -> dict[str, Any] | None:
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
return _to_dict(pc) if pc else None
async def get_by_id(
self, session: AsyncSession, *, id: int,
) -> dict[str, Any] | None:
pc = await session.get(PageConfig, id)
return _to_dict(pc) if pc else None
async def get_batch(
self, session: AsyncSession, *,
container_type: str = "page", ids: list[int],
) -> list[dict[str, Any]]:
if not ids:
return []
result = await session.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id.in_(ids),
)
)
return [_to_dict(pc) for pc in result.scalars().all()]
async def update(
self, session: AsyncSession, *,
container_type: str = "page", container_id: int,
features: dict | None = None,
sumup_merchant_code: str | None = None,
sumup_checkout_prefix: str | None = None,
sumup_api_key: str | None = None,
) -> dict[str, Any]:
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=features or {},
)
session.add(pc)
await session.flush()
if features is not None:
merged = dict(pc.features or {})
for key, val in features.items():
if isinstance(val, bool):
merged[key] = val
elif val in ("true", "1", "on"):
merged[key] = True
elif val in ("false", "0", "off", None):
merged[key] = False
pc.features = merged
flag_modified(pc, "features")
if sumup_merchant_code is not None:
pc.sumup_merchant_code = sumup_merchant_code or None
if sumup_checkout_prefix is not None:
pc.sumup_checkout_prefix = sumup_checkout_prefix or None
if sumup_api_key is not None:
pc.sumup_api_key = sumup_api_key or None
await session.flush()
result = _to_dict(pc)
result["sumup_configured"] = bool(pc.sumup_api_key)
return result

View File

@@ -15,12 +15,15 @@ Usage::
"""
from __future__ import annotations
from typing import Any
from shared.contracts.protocols import (
CalendarService,
MarketService,
CartService,
FederationService,
)
from shared.contracts.likes import LikesService
class _ServiceRegistry:
@@ -36,6 +39,8 @@ class _ServiceRegistry:
self._market: MarketService | None = None
self._cart: CartService | None = None
self._federation: FederationService | None = None
self._likes: LikesService | None = None
self._extra: dict[str, Any] = {}
# -- calendar -------------------------------------------------------------
@property
@@ -70,6 +75,17 @@ class _ServiceRegistry:
def cart(self, impl: CartService) -> None:
self._cart = impl
# -- likes ----------------------------------------------------------------
@property
def likes(self) -> LikesService:
if self._likes is None:
raise RuntimeError("LikesService not registered")
return self._likes
@likes.setter
def likes(self, impl: LikesService) -> None:
self._likes = impl
# -- federation -----------------------------------------------------------
@property
def federation(self) -> FederationService:
@@ -81,10 +97,27 @@ class _ServiceRegistry:
def federation(self, impl: FederationService) -> None:
self._federation = impl
# -- generic registration --------------------------------------------------
def register(self, name: str, impl: Any) -> None:
"""Register a service by name (for services without typed properties)."""
self._extra[name] = impl
def __getattr__(self, name: str) -> Any:
# Fallback to _extra dict for dynamically registered services
try:
extra = object.__getattribute__(self, "_extra")
if name in extra:
return extra[name]
except AttributeError:
pass
raise AttributeError(f"No service registered as: {name}")
# -- introspection --------------------------------------------------------
def has(self, name: str) -> bool:
"""Check whether a domain service is registered."""
return getattr(self, f"_{name}", None) is not None
if getattr(self, f"_{name}", None) is not None:
return True
return name in self._extra
# Module-level singleton — import this everywhere.

View File

@@ -0,0 +1,164 @@
"""Service wrapper for relations module functions.
Wraps the module-level functions in shared.services.relationships into
a class so they can be called via the ``(service "relations" ...)`` primitive.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
def _serialize_rel(r) -> dict[str, Any]:
return {
"id": r.id,
"parent_type": r.parent_type,
"parent_id": r.parent_id,
"child_type": r.child_type,
"child_id": r.child_id,
"sort_order": r.sort_order,
"label": r.label,
"relation_type": r.relation_type,
"metadata": r.metadata_,
}
class SqlRelationsService:
async def get_children(
self, session: AsyncSession, *,
parent_type: str, parent_id: int,
child_type: str | None = None,
relation_type: str | None = None,
) -> list[dict[str, Any]]:
from shared.services.relationships import get_children
rels = await get_children(
session, parent_type, parent_id, child_type,
relation_type=relation_type,
)
return [_serialize_rel(r) for r in rels]
async def get_parents(
self, session: AsyncSession, *,
child_type: str, child_id: int,
parent_type: str | None = None,
relation_type: str | None = None,
) -> list[dict[str, Any]]:
from shared.services.relationships import get_parents
rels = await get_parents(
session, child_type, child_id, parent_type,
relation_type=relation_type,
)
return [_serialize_rel(r) for r in rels]
async def attach_child(
self, session: AsyncSession, *,
parent_type: str, parent_id: int,
child_type: str, child_id: int,
label: str | None = None,
sort_order: int | None = None,
relation_type: str | None = None,
metadata: dict | None = None,
) -> dict[str, Any]:
from shared.services.relationships import attach_child
rel = await attach_child(
session,
parent_type=parent_type, parent_id=parent_id,
child_type=child_type, child_id=child_id,
label=label, sort_order=sort_order,
relation_type=relation_type, metadata=metadata,
)
return _serialize_rel(rel)
async def detach_child(
self, session: AsyncSession, *,
parent_type: str, parent_id: int,
child_type: str, child_id: int,
relation_type: str | None = None,
) -> bool:
from shared.services.relationships import detach_child
return await detach_child(
session,
parent_type=parent_type, parent_id=parent_id,
child_type=child_type, child_id=child_id,
relation_type=relation_type,
)
async def relate(
self, session: AsyncSession, *,
relation_type: str,
from_id: int, to_id: int,
label: str | None = None,
sort_order: int | None = None,
metadata: dict | None = None,
) -> dict[str, Any]:
"""Registry-aware relation creation with cardinality enforcement."""
from shared.services.relationships import attach_child, get_children
from shared.sx.relations import get_relation
defn = get_relation(relation_type)
if defn is None:
raise ValueError(f"unknown relation_type: {relation_type}")
if defn.cardinality == "one-to-one":
existing = await get_children(
session,
parent_type=defn.from_type,
parent_id=from_id,
child_type=defn.to_type,
relation_type=relation_type,
)
if existing:
raise ValueError("one-to-one relation already exists")
rel = await attach_child(
session,
parent_type=defn.from_type, parent_id=from_id,
child_type=defn.to_type, child_id=to_id,
label=label, sort_order=sort_order,
relation_type=relation_type, metadata=metadata,
)
return _serialize_rel(rel)
async def unrelate(
self, session: AsyncSession, *,
relation_type: str, from_id: int, to_id: int,
) -> bool:
from shared.services.relationships import detach_child
from shared.sx.relations import get_relation
defn = get_relation(relation_type)
if defn is None:
raise ValueError(f"unknown relation_type: {relation_type}")
return await detach_child(
session,
parent_type=defn.from_type, parent_id=from_id,
child_type=defn.to_type, child_id=to_id,
relation_type=relation_type,
)
async def can_relate(
self, session: AsyncSession, *,
relation_type: str, from_id: int,
) -> dict[str, Any]:
from shared.services.relationships import get_children
from shared.sx.relations import get_relation
defn = get_relation(relation_type)
if defn is None:
return {"allowed": False, "reason": f"unknown relation_type: {relation_type}"}
if defn.cardinality == "one-to-one":
existing = await get_children(
session,
parent_type=defn.from_type,
parent_id=from_id,
child_type=defn.to_type,
relation_type=relation_type,
)
if existing:
return {"allowed": False, "reason": "one-to-one relation already exists"}
return {"allowed": True}

View File

@@ -542,8 +542,10 @@ document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener("sx:responseError", function (event) {
var resp = event.detail.response;
if (!resp) return;
var status = resp.status || 0;
// Don't show error modal when sx-retry will handle the failure
var triggerEl = event.target;
if (triggerEl && triggerEl.getAttribute("sx-retry")) return;
var status = resp.status || 0;
var form = triggerEl ? triggerEl.closest("form") : null;
var title = "Something went wrong";

View File

@@ -0,0 +1,292 @@
/**
* sx-test.js — String renderer for sx.js (Node-only, used by test harness).
*
* Provides Sx.renderToString() for server-side / test rendering.
* Assumes sx.js is loaded first and Sx global is available.
*/
;(function (Sx) {
"use strict";
// Pull references from Sx internals
var NIL = Sx.NIL;
var _eval = Sx._eval;
var _types = Sx._types;
var RawHTML = _types.RawHTML;
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSym(x) { return x && x._sym === true; }
function isKw(x) { return x && x._kw === true; }
function isLambda(x) { return x && x._lambda === true; }
function isComponent(x) { return x && x._component === true; }
function isMacro(x) { return x && x._macro === true; }
function isRaw(x) { return x && x._raw === true; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
function merge(target) {
for (var i = 1; i < arguments.length; i++) {
var src = arguments[i];
if (src) for (var k in src) target[k] = src[k];
}
return target;
}
// Use the same tag/attr sets as sx.js
var HTML_TAGS = Sx._renderDOM ? null : null; // We'll use a local copy
var _HTML_TAGS_STR =
"html head body title meta link style script base noscript " +
"header footer main nav aside section article address hgroup " +
"h1 h2 h3 h4 h5 h6 " +
"div p blockquote pre figure figcaption ul ol li dl dt dd hr " +
"a span em strong small s cite q abbr code var samp kbd sub sup " +
"i b u mark ruby rt rp bdi bdo br wbr time data " +
"ins del " +
"img picture source iframe embed object param video audio track canvas map area " +
"table caption colgroup col thead tbody tfoot tr td th " +
"form input textarea button select option optgroup label fieldset legend " +
"details summary dialog " +
"svg path circle rect line ellipse polyline polygon text g defs use " +
"clippath lineargradient radialgradient stop pattern mask " +
"tspan textpath foreignobject";
var _VOID_STR = "area base br col embed hr img input link meta param source track wbr";
var _BOOL_STR = "disabled checked readonly required selected autofocus autoplay " +
"controls loop muted multiple hidden open novalidate";
function makeSet(str) {
var s = {}, parts = str.split(/\s+/);
for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true;
return s;
}
HTML_TAGS = makeSet(_HTML_TAGS_STR);
var VOID_ELEMENTS = makeSet(_VOID_STR);
var BOOLEAN_ATTRS = makeSet(_BOOL_STR);
// Access expandMacro via Sx._eval on a defmacro — we need to replicate macro expansion
// Actually, we need the internal expandMacro. Let's check if Sx exposes it.
// Sx._eval handles macro expansion internally, so we can call sxEval for macro forms.
var sxEval = _eval;
// _isRenderExpr — check if an expression is a render-only form
function _isRenderExpr(v) {
if (!Array.isArray(v) || !v.length) return false;
var h = v[0];
if (!isSym(h)) return false;
var n = h.name;
if (n === "<>" || n === "raw!" || n === "if" || n === "when" || n === "cond" ||
n === "case" || n === "let" || n === "let*" || n === "begin" || n === "do" ||
n === "map" || n === "map-indexed" || n === "filter" || n === "for-each") return true;
if (n.charAt(0) === "~") return true;
if (HTML_TAGS[n]) return true;
return false;
}
// --- String Renderer ---
function escapeText(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function renderStr(expr, env) {
if (isNil(expr) || expr === false || expr === true) return "";
if (isRaw(expr)) return expr.html;
if (typeof expr === "string") return escapeText(expr);
if (typeof expr === "number") return escapeText(String(expr));
if (isSym(expr)) return renderStr(sxEval(expr, env), env);
if (isKw(expr)) return escapeText(expr.name);
if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); }
if (expr && typeof expr === "object") return "";
return escapeText(String(expr));
}
function renderStrList(expr, env) {
var head = expr[0];
if (!isSym(head)) {
var parts = [];
for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env));
return parts.join("");
}
var name = head.name;
if (name === "raw!") {
var ps = [];
for (var ri = 1; ri < expr.length; ri++) {
var v = sxEval(expr[ri], env);
if (isRaw(v)) ps.push(v.html);
else if (typeof v === "string") ps.push(v);
else if (!isNil(v)) ps.push(String(v));
}
return ps.join("");
}
if (name === "<>") {
var fs = [];
for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env));
return fs.join("");
}
if (name === "if") {
return isSxTruthy(sxEval(expr[1], env))
? renderStr(expr[2], env)
: (expr.length > 3 ? renderStr(expr[3], env) : "");
}
if (name === "when") {
if (!isSxTruthy(sxEval(expr[1], env))) return "";
var ws = [];
for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env));
return ws.join("");
}
if (name === "let" || name === "let*") {
var bindings = expr[1], local = merge({}, env);
if (Array.isArray(bindings)) {
if (bindings.length && Array.isArray(bindings[0])) {
for (var li = 0; li < bindings.length; li++) {
local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local);
}
} else {
for (var lj = 0; lj < bindings.length; lj += 2) {
local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local);
}
}
}
var ls = [];
for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local));
return ls.join("");
}
if (name === "begin" || name === "do") {
var bs = [];
for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
return bs.join("");
}
if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; }
// Macro expansion in string renderer
if (name in env && isMacro(env[name])) {
var smExp = Sx._expandMacro(env[name], expr.slice(1), env);
return renderStr(smExp, env);
}
// Higher-order forms — render-aware
if (name === "map") {
var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env);
if (!Array.isArray(mapColl)) return "";
var mapParts = [];
for (var mi = 0; mi < mapColl.length; mi++) {
if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env));
else mapParts.push(renderStr(mapFn(mapColl[mi]), env));
}
return mapParts.join("");
}
if (name === "map-indexed") {
var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env);
if (!Array.isArray(mixColl)) return "";
var mixParts = [];
for (var mxi = 0; mxi < mixColl.length; mxi++) {
if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env));
else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env));
}
return mixParts.join("");
}
if (name === "filter") {
var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env);
if (!Array.isArray(filtColl)) return "";
var filtParts = [];
for (var fli = 0; fli < filtColl.length; fli++) {
var keep = isLambda(filtFn) ? Sx._callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]);
if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
}
return filtParts.join("");
}
if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env);
if (name.charAt(0) === "~") {
var comp = env[name];
if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
console.warn("sx.js: unknown component " + name);
return '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
return renderStr(sxEval(expr, env), env);
}
function renderStrElement(tag, args, env) {
var attrs = [], children = [];
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
var aname = args[i].name, aval = sxEval(args[i + 1], env);
i += 2;
if (isNil(aval) || aval === false) continue;
if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); }
else if (aval === true) attrs.push(" " + aname);
else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"');
} else {
children.push(args[i]);
i++;
}
}
var open = "<" + tag + attrs.join("") + ">";
if (VOID_ELEMENTS[tag]) return open;
var isRawText = (tag === "script" || tag === "style");
var inner = [];
for (var ci = 0; ci < children.length; ci++) {
var child = children[ci];
if (isRawText && typeof child === "string") inner.push(child);
else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env)));
else inner.push(renderStr(child, env));
}
return open + inner.join("") + "</" + tag + ">";
}
function renderLambdaStr(fn, args, env) {
var local = merge({}, fn.closure, env);
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
return renderStr(fn.body, local);
}
function renderStrComponent(comp, args, env) {
var kwargs = {}, children = [];
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
var v = args[i + 1];
if (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v)) {
kwargs[args[i].name] = v;
} else if (isSym(v)) {
kwargs[args[i].name] = sxEval(v, env);
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
if (_isRenderExpr(v)) {
kwargs[args[i].name] = new RawHTML(renderStr(v, env));
} else {
kwargs[args[i].name] = sxEval(v, env);
}
} else {
kwargs[args[i].name] = v;
}
i += 2;
} else { children.push(args[i]); i++; }
}
var local = merge({}, comp.closure, env);
for (var pi = 0; pi < comp.params.length; pi++) {
var p = comp.params[pi];
local[p] = (p in kwargs) ? kwargs[p] : NIL;
}
if (comp.hasChildren) {
var cs = [];
for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env));
local["children"] = new RawHTML(cs.join(""));
}
return renderStr(comp.body, local);
}
// --- Public API ---
Sx.renderToString = function (exprOrText, extraEnv) {
var expr = typeof exprOrText === "string" ? Sx.parse(exprOrText) : exprOrText;
var env = extraEnv ? merge({}, Sx.getEnv(), extraEnv) : Sx.getEnv();
return renderStr(expr, env);
};
Sx._renderStr = renderStr;
})(Sx);

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,28 @@ control flow (``if``, ``let``, ``map``, ``when``). The sync
collect-then-substitute resolver can't handle data dependencies between
I/O results and control flow, so handlers need inline async evaluation.
Evaluation modes
~~~~~~~~~~~~~~~~
The same component AST can be evaluated in different modes depending on
where the rendering boundary is drawn (server vs client). Five modes
exist across the codebase:
Function Expands components? Output Used for
-------------------- ------------------- -------------- ----------------------------
_eval (sync) Yes Python values register_components, Jinja sx()
_arender (async) Yes HTML render_to_html
_aser (async) No — serializes SX wire format render_to_sx
_aser_component Yes, one level SX wire format render_to_sx_with_env (layouts)
sx.js renderDOM Yes DOM nodes Client-side
_aser deliberately does NOT expand ~component calls — it serializes them
as SX wire format so the client can render them. But layout components
(used by render_to_sx_with_env) need server-side expansion because they
depend on Python context (auth state, fragments, etc.). That's what
_aser_component / async_eval_slot_to_sx provides: expand the top-level
component body server-side, then serialize its children for the client.
Usage::
from shared.sx.async_eval import async_render
@@ -19,25 +41,54 @@ Usage::
from __future__ import annotations
import inspect
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
from .evaluator import _expand_macro, EvalError
from .primitives import _PRIMITIVES
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
from .parser import SxExpr, serialize
from .html import (
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
escape_text, escape_attr, _RawHTML, css_class_collector,
escape_text, escape_attr, _RawHTML, css_class_collector, _svg_context,
)
# ---------------------------------------------------------------------------
# Async TCO — thunk + trampoline
# ---------------------------------------------------------------------------
class _AsyncThunk:
"""Deferred (expr, env, ctx) for tail-call optimization."""
__slots__ = ("expr", "env", "ctx")
def __init__(self, expr: Any, env: dict[str, Any], ctx: RequestContext) -> None:
self.expr = expr
self.env = env
self.ctx = ctx
async def _async_trampoline(val: Any) -> Any:
"""Iteratively resolve thunks from tail positions."""
while isinstance(val, _AsyncThunk):
val = await _async_eval(val.expr, val.env, val.ctx)
return val
# ---------------------------------------------------------------------------
# Async evaluate
# ---------------------------------------------------------------------------
async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr* in *env*, awaiting I/O primitives inline."""
"""Public entry — evaluates and trampolines thunks."""
result = await _async_eval(expr, env, ctx)
while isinstance(result, _AsyncThunk):
result = await _async_eval(result.expr, result.env, result.ctx)
return result
async def _async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Internal evaluator — may return _AsyncThunk for tail positions."""
# --- literals ---
if isinstance(expr, (int, float, str, bool)):
return expr
@@ -65,7 +116,7 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
# --- dict literal ---
if isinstance(expr, dict):
return {k: await async_eval(v, env, ctx) for k, v in expr.items()}
return {k: await _async_trampoline(await _async_eval(v, env, ctx)) for k, v in expr.items()}
# --- list ---
if not isinstance(expr, list):
@@ -76,7 +127,7 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
head = expr[0]
if not isinstance(head, (Symbol, Lambda, list)):
return [await async_eval(x, env, ctx) for x in expr]
return [await _async_trampoline(await _async_eval(x, env, ctx)) for x in expr]
if isinstance(head, Symbol):
name = head.name
@@ -95,12 +146,12 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
if ho is not None:
return await ho(expr, env, ctx)
# Macro expansion
# Macro expansion — tail position
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await async_eval(expanded, env, ctx)
return _AsyncThunk(expanded, env, ctx)
# Render forms in eval position — delegate to renderer and return
# as _RawHTML so it won't be double-escaped when used in render
@@ -110,11 +161,14 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
return _RawHTML(html)
# --- function / lambda call ---
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
fn = await _async_trampoline(await _async_eval(head, env, ctx))
args = [await _async_trampoline(await _async_eval(a, env, ctx)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component):
@@ -149,7 +203,7 @@ async def _async_call_lambda(
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return await async_eval(fn.body, local, ctx)
return _AsyncThunk(fn.body, local, ctx)
async def _async_call_component(
@@ -172,7 +226,7 @@ async def _async_call_component(
local[p] = kwargs.get(p, NIL)
if comp.has_children:
local["children"] = children
return await async_eval(comp.body, local, ctx)
return _AsyncThunk(comp.body, local, ctx)
# ---------------------------------------------------------------------------
@@ -180,28 +234,28 @@ async def _async_call_component(
# ---------------------------------------------------------------------------
async def _asf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
return await async_eval(expr[2], env, ctx)
return _AsyncThunk(expr[2], env, ctx)
if len(expr) > 3:
return await async_eval(expr[3], env, ctx)
return _AsyncThunk(expr[3], env, ctx)
return NIL
async def _asf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, env, ctx)
return result
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, env, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_and(expr, env, ctx):
result: Any = True
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if not result:
return result
return result
@@ -210,7 +264,7 @@ async def _asf_and(expr, env, ctx):
async def _asf_or(expr, env, ctx):
result: Any = False
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if result:
return result
return result
@@ -224,16 +278,17 @@ async def _asf_let(expr, env, ctx):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(binding[1], local, ctx)
local[vname] = await _async_trampoline(await _async_eval(binding[1], local, ctx))
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(bindings[i + 1], local, ctx)
result: Any = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, local, ctx)
return result
local[vname] = await _async_trampoline(await _async_eval(bindings[i + 1], local, ctx))
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, local, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], local, ctx)
return NIL
async def _asf_lambda(expr, env, ctx):
@@ -249,7 +304,7 @@ async def _asf_lambda(expr, env, ctx):
async def _asf_define(expr, env, ctx):
name_sym = expr[1]
value = await async_eval(expr[2], env, ctx)
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
@@ -261,6 +316,16 @@ async def _asf_defcomp(expr, env, ctx):
return _sf_defcomp(expr, env)
async def _asf_defstyle(expr, env, ctx):
from .evaluator import _sf_defstyle
return _sf_defstyle(expr, env)
async def _asf_defkeyframes(expr, env, ctx):
from .evaluator import _sf_defkeyframes
return _sf_defkeyframes(expr, env)
async def _asf_defmacro(expr, env, ctx):
from .evaluator import _sf_defmacro
return _sf_defmacro(expr, env)
@@ -272,10 +337,11 @@ async def _asf_defhandler(expr, env, ctx):
async def _asf_begin(expr, env, ctx):
result: Any = NIL
for sub in expr[1:]:
result = await async_eval(sub, env, ctx)
return result
for sub in expr[1:-1]:
await _async_trampoline(await _async_eval(sub, env, ctx))
if len(expr) > 1:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_quote(expr, env, ctx):
@@ -321,63 +387,65 @@ async def _asf_cond(expr, env, ctx):
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return await async_eval(clause[1], env, ctx)
return _AsyncThunk(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(clause[1], env, ctx)
return _AsyncThunk(clause[1], env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(clause[1], env, ctx)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_case(expr, env, ctx):
match_val = await async_eval(expr[1], env, ctx)
match_val = await _async_trampoline(await _async_eval(expr[1], env, ctx))
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if match_val == await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if match_val == await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_thread_first(expr, env, ctx):
result = await async_eval(expr[1], env, ctx)
result = await _async_trampoline(await _async_eval(expr[1], env, ctx))
for form in expr[2:]:
if isinstance(form, list):
fn = await async_eval(form[0], env, ctx)
args = [result] + [await async_eval(a, env, ctx) for a in form[1:]]
fn = await _async_trampoline(await _async_eval(form[0], env, ctx))
args = [result] + [await _async_trampoline(await _async_eval(a, env, ctx)) for a in form[1:]]
else:
fn = await async_eval(form, env, ctx)
fn = await _async_trampoline(await _async_eval(form, env, ctx))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
result = await result
elif isinstance(fn, Lambda):
result = await _async_call_lambda(fn, args, env, ctx)
result = await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
async def _asf_set_bang(expr, env, ctx):
value = await async_eval(expr[2], env, ctx)
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
env[expr[1].name] = value
return value
@@ -394,6 +462,8 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
"lambda": _asf_lambda,
"fn": _asf_lambda,
"define": _asf_define,
"defstyle": _asf_defstyle,
"defkeyframes": _asf_defkeyframes,
"defcomp": _asf_defcomp,
"defmacro": _asf_defmacro,
"defhandler": _asf_defhandler,
@@ -416,9 +486,10 @@ async def _aho_map(expr, env, ctx):
results = []
for item in coll:
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [item], env, ctx))
results.append(await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx)))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -430,9 +501,10 @@ async def _aho_map_indexed(expr, env, ctx):
results = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [i, item], env, ctx))
results.append(await _async_trampoline(await _async_call_lambda(fn, [i, item], env, ctx)))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -444,9 +516,11 @@ async def _aho_filter(expr, env, ctx):
results = []
for item in coll:
if isinstance(fn, Lambda):
val = await _async_call_lambda(fn, [item], env, ctx)
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
val = fn(item)
if inspect.iscoroutine(val):
val = await val
else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val:
@@ -459,7 +533,12 @@ async def _aho_reduce(expr, env, ctx):
acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx)
for item in coll:
acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item)
if isinstance(fn, Lambda):
acc = await _async_trampoline(await _async_call_lambda(fn, [acc, item], env, ctx))
else:
acc = fn(acc, item)
if inspect.iscoroutine(acc):
acc = await acc
return acc
@@ -467,7 +546,12 @@ async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)
if isinstance(fn, Lambda):
result = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
result = fn(item)
if inspect.iscoroutine(result):
result = await result
if result:
return result
return NIL
@@ -477,7 +561,13 @@ async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)):
if isinstance(fn, Lambda):
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
val = fn(item)
if inspect.iscoroutine(val):
val = await val
if not val:
return False
return True
@@ -487,9 +577,11 @@ async def _aho_for_each(expr, env, ctx):
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
await _async_call_lambda(fn, [item], env, ctx)
await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
fn(item)
r = fn(item)
if inspect.iscoroutine(r):
await r
return NIL
@@ -573,9 +665,19 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) ->
parts.append(await _arender(child, env, ctx))
return "".join(parts)
# html: prefix → force tag rendering
if name.startswith("html:"):
return await _arender_element(name[5:], expr[1:], env, ctx)
# Render-aware special forms
# If name is also an HTML tag and (keyword arg or SVG context) → tag call
arsf = _ASYNC_RENDER_FORMS.get(name)
if arsf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return await _arender_element(name, expr[1:], env, ctx)
return await arsf(expr, env, ctx)
# Macro expansion
@@ -595,6 +697,14 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) ->
if isinstance(val, Component):
return await _arender_component(val, expr[1:], env, ctx)
# Custom element (hyphenated name with keyword attrs) → tag
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return await _arender_element(name, expr[1:], env, ctx)
# SVG/MathML context → unknown names are child elements
if _svg_context.get(False):
return await _arender_element(name, expr[1:], env, ctx)
# Fallback — evaluate then render
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
@@ -626,6 +736,18 @@ async def _arender_element(
children.append(arg)
i += 1
# Handle :style StyleValue — convert to class and register CSS rule
style_val = attrs.get("style")
if isinstance(style_val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(style_val)
existing_class = attrs.get("class")
if existing_class and existing_class is not NIL and existing_class is not False:
attrs["class"] = f"{existing_class} {style_val.class_name}"
else:
attrs["class"] = style_val.class_name
del attrs["style"]
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
@@ -649,9 +771,19 @@ async def _arender_element(
if tag in VOID_ELEMENTS:
return opening
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
# SVG/MathML namespace auto-detection: set context for children
token = None
if tag in ("svg", "math"):
token = _svg_context.set(True)
try:
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
finally:
if token is not None:
_svg_context.reset(token)
return f"{opening}{''.join(child_parts)}</{tag}>"
@@ -782,7 +914,10 @@ async def _arsf_map(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -796,7 +931,10 @@ async def _arsf_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(i, item), env, ctx))
r = fn(i, item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -815,7 +953,10 @@ async def _arsf_for_each(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -830,6 +971,8 @@ _ASYNC_RENDER_FORMS: dict[str, Any] = {
"begin": _arsf_begin,
"do": _arsf_begin,
"define": _arsf_define,
"defstyle": _arsf_define,
"defkeyframes": _arsf_define,
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
@@ -867,10 +1010,92 @@ async def async_eval_to_sx(
ctx = RequestContext()
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
return result
if result is None or result is NIL:
return ""
return serialize(result)
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _maybe_expand_component_result(
result: Any,
env: dict[str, Any],
ctx: RequestContext,
) -> Any:
"""If *result* is a component call (SxExpr or string starting with
``(~``), re-parse and expand it server-side.
This ensures Python-only helpers (e.g. ``highlight``) inside the
component body are evaluated on the server rather than being
serialized for the client where they don't exist.
"""
raw = None
if isinstance(result, SxExpr):
raw = str(result).strip()
elif isinstance(result, str):
raw = result.strip()
if raw and raw.startswith("(~"):
from .parser import parse_all
parsed = parse_all(raw)
if parsed:
return await async_eval_slot_to_sx(parsed[0], env, ctx)
return result
async def async_eval_slot_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Like async_eval_to_sx but expands component calls.
Used by defpage slot evaluation where the content expression is
typically a component call like ``(~dashboard-content)``. Normal
``async_eval_to_sx`` serializes component calls without expanding;
this variant expands one level so IO primitives in the body execute,
then serializes the result as SX wire format.
"""
if ctx is None:
ctx = RequestContext()
# If expr is a component call, expand it through _aser
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
else:
import logging
logging.getLogger("sx.eval").error(
"async_eval_slot_to_sx: component %s not found in env "
"(will fall through to _aser and serialize unexpanded — "
"client will see 'Unknown component'). "
"Check that the .sx file is loaded and the service's sx/ "
"directory is bind-mounted in docker-compose.dev.yml.",
head.name,
)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
# If the result is a component call (from case/if/let branches or
# page helpers returning strings), re-parse and expand it server-side
# so that Python-only helpers like ``highlight`` in the component body
# get evaluated here, not on the client.
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
@@ -878,10 +1103,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
for everything else."""
if isinstance(expr, (int, float, bool)):
return expr
if isinstance(expr, str):
return expr
if isinstance(expr, SxExpr):
return expr
if isinstance(expr, str):
return expr
if expr is None or expr is NIL:
return NIL
@@ -930,14 +1155,28 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
if name == "raw!":
return await _aser_call("raw!", expr[1:], env, ctx)
# Component call — serialize (don't expand)
# html: prefix → force tag serialization
if name.startswith("html:"):
return await _aser_call(name[5:], expr[1:], env, ctx)
# Component call — expand macros, serialize regular components
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
return await _aser_call(name, expr[1:], env, ctx)
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
# because some names like "map" are both HTML tags and sx forms)
# because some names like "map" are both HTML tags and sx forms).
# If name is also an HTML tag and (keyword arg or SVG context) → tag call.
sf = _ASER_FORMS.get(name)
if sf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return await _aser_call(name, expr[1:], env, ctx)
return await sf(expr, env, ctx)
# HTML tag — serialize (don't render to HTML)
@@ -951,14 +1190,25 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
# Custom element (hyphenated name with keyword attrs) → serialize as tag
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return await _aser_call(name, expr[1:], env, ctx)
# SVG/MathML context → unknown names are child elements
if _svg_context.get(False):
return await _aser_call(name, expr[1:], env, ctx)
# Function / lambda call — evaluate (produces data, not rendering)
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
return await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
if isinstance(fn, Component):
# Component invoked as function — serialize the call
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
@@ -982,27 +1232,121 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
return SxExpr("(<> " + " ".join(parts) + ")")
async def _aser_component(
comp: Component, args: list, env: dict, ctx: RequestContext,
) -> Any:
"""Expand a component body through _aser — produces SX, not HTML."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
child_parts.append(serialize(await _aser(c, env, ctx)))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr:
"""Serialize ``(name :key val child ...)`` — evaluate args but keep
as sx source instead of rendering to HTML."""
parts = [name]
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
parts.append(f":{arg.name}")
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
parts.append(serialize(result))
i += 1
return SxExpr("(" + " ".join(parts) + ")")
# SVG/MathML namespace auto-detection for serializer
token = None
if name in ("svg", "math"):
token = _svg_context.set(True)
try:
parts = [name]
extra_class: str | None = None # from :style StyleValue conversion
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
# :style StyleValue → convert to :class and register CSS
if arg.name == "style" and isinstance(val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(val)
extra_class = val.class_name
else:
parts.append(f":{arg.name}")
# Plain list → serialize for the client.
# Rendered items (SxExpr) → wrap in (<> ...) fragment.
# Data items (dicts, strings, numbers) → (list ...)
# so the client gets an iterable array, not a
# DocumentFragment that breaks map/filter.
if isinstance(val, list):
live = [v for v in val
if v is not NIL and v is not None]
items = [serialize(v) for v in live]
if not items:
parts.append("nil")
elif any(isinstance(v, SxExpr) for v in live):
parts.append(
"(<> " + " ".join(items) + ")"
)
else:
parts.append(
"(list " + " ".join(items) + ")"
)
else:
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
# Flatten list results (e.g. from map) into individual
# children, matching _aser_fragment behaviour
if isinstance(result, list):
for item in result:
if item is not NIL and item is not None:
parts.append(serialize(item))
else:
parts.append(serialize(result))
i += 1
# If we converted a :style to a class, merge into existing :class or add it
if extra_class:
_merge_class_into_parts(parts, extra_class)
return SxExpr("(" + " ".join(parts) + ")")
finally:
if token is not None:
_svg_context.reset(token)
def _merge_class_into_parts(parts: list[str], class_name: str) -> None:
"""Merge an extra class name into the serialized parts list.
If :class already exists, append to it. Otherwise add :class.
"""
for i, p in enumerate(parts):
if p == ":class" and i + 1 < len(parts):
# Existing :class — append our class
existing = parts[i + 1]
if existing.startswith('"') and existing.endswith('"'):
# Quoted string — insert before closing quote
parts[i + 1] = existing[:-1] + " " + class_name + '"'
else:
# Expression — wrap in (str ...)
parts[i + 1] = f'(str {existing} " {class_name}")'
return
# No existing :class — add one
parts.insert(1, f'"{class_name}"')
parts.insert(1, ":class")
# ---------------------------------------------------------------------------
@@ -1151,7 +1495,8 @@ async def _asho_ser_map(expr, env, ctx):
local[p] = v
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -1169,7 +1514,8 @@ async def _asho_ser_map_indexed(expr, env, ctx):
local[fn.params[1]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -1191,7 +1537,8 @@ async def _asho_ser_for_each(expr, env, ctx):
local[fn.params[0]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
return results
@@ -1207,6 +1554,8 @@ _ASER_FORMS: dict[str, Any] = {
"lambda": _assf_lambda,
"fn": _assf_lambda,
"define": _assf_define,
"defstyle": _assf_define,
"defkeyframes": _assf_define,
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,

View File

@@ -147,6 +147,48 @@ def scan_classes_from_sx(source: str) -> set[str]:
return classes
def register_generated_rule(style_val: Any) -> None:
"""Register a generated StyleValue's CSS rules in the registry.
This allows generated class names (``sx-a3f2c1``) to flow through
the existing ``lookup_rules()`` → ``SX-Css`` delta pipeline.
"""
from .style_dict import CHILD_SELECTOR_ATOMS
cn = style_val.class_name
if cn in _REGISTRY:
return # already registered
parts: list[str] = []
# Base declarations
if style_val.declarations:
parts.append(f".{cn}{{{style_val.declarations}}}")
# Pseudo-class rules
for sel, decls in style_val.pseudo_rules:
if sel.startswith("::"):
parts.append(f".{cn}{sel}{{{decls}}}")
elif "&" in sel:
# group-hover pattern: ":is(.group:hover) &" → .group:hover .sx-abc
expanded = sel.replace("&", f".{cn}")
parts.append(f"{expanded}{{{decls}}}")
else:
parts.append(f".{cn}{sel}{{{decls}}}")
# Media-query rules
for query, decls in style_val.media_rules:
parts.append(f"@media {query}{{.{cn}{{{decls}}}}}")
# Keyframes
for _name, kf_rule in style_val.keyframes:
parts.append(kf_rule)
rule_text = "".join(parts)
order = len(_RULE_ORDER) + 10000 # after all tw.css rules
_REGISTRY[cn] = rule_text
_RULE_ORDER[cn] = order
def registry_loaded() -> bool:
"""True if the registry has been populated."""
return bool(_REGISTRY)
@@ -252,6 +294,8 @@ def _css_selector_to_class(selector: str) -> str:
i += 2
elif name[i] == ':':
break # pseudo-class — stop here
elif name[i] == '[':
break # attribute selector — stop here
else:
result.append(name[i])
i += 1

View File

@@ -42,6 +42,22 @@ class EvalError(Exception):
pass
class _Thunk:
"""Deferred evaluation — returned from tail positions for TCO."""
__slots__ = ("expr", "env")
def __init__(self, expr: Any, env: dict[str, Any]):
self.expr = expr
self.env = env
def _trampoline(val: Any) -> Any:
"""Unwrap thunks by re-entering the evaluator until we get an actual value."""
while isinstance(val, _Thunk):
val = _eval(val.expr, val.env)
return val
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -50,7 +66,10 @@ def evaluate(expr: Any, env: dict[str, Any] | None = None) -> Any:
"""Evaluate *expr* in *env* and return the result."""
if env is None:
env = {}
return _eval(expr, env)
result = _eval(expr, env)
while isinstance(result, _Thunk):
result = _eval(result.expr, result.env)
return result
def make_env(**kwargs: Any) -> dict[str, Any]:
@@ -90,7 +109,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# --- dict literal -----------------------------------------------------
if isinstance(expr, dict):
return {k: _eval(v, env) for k, v in expr.items()}
return {k: _trampoline(_eval(v, env)) for k, v in expr.items()}
# --- list = call or special form --------------------------------------
if not isinstance(expr, list):
@@ -103,7 +122,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# If head is not a symbol/lambda/list, treat entire list as data
if not isinstance(head, (Symbol, Lambda, list)):
return [_eval(x, env) for x in expr]
return [_trampoline(_eval(x, env)) for x in expr]
# --- special forms ----------------------------------------------------
if isinstance(head, Symbol):
@@ -122,11 +141,11 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _eval(expanded, env)
return _Thunk(expanded, env)
# --- function / lambda call -------------------------------------------
fn = _eval(head, env)
args = [_eval(a, env) for a in expr[1:]]
fn = _trampoline(_eval(head, env))
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
@@ -151,7 +170,7 @@ def _call_lambda(fn: Lambda, args: list[Any], caller_env: dict[str, Any]) -> Any
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return _eval(fn.body, local)
return _Thunk(fn.body, local)
def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -> Any:
@@ -166,10 +185,10 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
while i < len(raw_args):
arg = raw_args[i]
if isinstance(arg, Keyword) and i + 1 < len(raw_args):
kwargs[arg.name] = _eval(raw_args[i + 1], env)
kwargs[arg.name] = _trampoline(_eval(raw_args[i + 1], env))
i += 2
else:
children.append(_eval(arg, env))
children.append(_trampoline(_eval(arg, env)))
i += 1
local = dict(comp.closure)
@@ -181,7 +200,7 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
local[p] = NIL
if comp.has_children:
local["children"] = children
return _eval(comp.body, local)
return _Thunk(comp.body, local)
# ---------------------------------------------------------------------------
@@ -191,23 +210,22 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
def _sf_if(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("if requires condition and then-branch")
cond = _eval(expr[1], env)
cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL:
return _eval(expr[2], env)
return _Thunk(expr[2], env)
if len(expr) > 3:
return _eval(expr[3], env)
return _Thunk(expr[3], env)
return NIL
def _sf_when(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("when requires condition and body")
cond = _eval(expr[1], env)
cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = _eval(body_expr, env)
return result
for body_expr in expr[2:-1]:
_trampoline(_eval(body_expr, env))
return _Thunk(expr[-1], env)
return NIL
@@ -228,22 +246,22 @@ def _sf_cond(expr: list, env: dict) -> Any:
raise EvalError("cond clause must be (test result)")
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _eval(clause[1], env)
return _Thunk(clause[1], env)
if isinstance(test, Keyword) and test.name == "else":
return _eval(clause[1], env)
if _eval(test, env):
return _eval(clause[1], env)
return _Thunk(clause[1], env)
if _trampoline(_eval(test, env)):
return _Thunk(clause[1], env)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env)
return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if _eval(test, env):
return _eval(result, env)
return _Thunk(result, env)
if _trampoline(_eval(test, env)):
return _Thunk(result, env)
i += 2
return NIL
@@ -251,18 +269,18 @@ def _sf_cond(expr: list, env: dict) -> Any:
def _sf_case(expr: list, env: dict) -> Any:
if len(expr) < 2:
raise EvalError("case requires expression to match")
match_val = _eval(expr[1], env)
match_val = _trampoline(_eval(expr[1], env))
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env)
return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if match_val == _eval(test, env):
return _eval(result, env)
return _Thunk(result, env)
if match_val == _trampoline(_eval(test, env)):
return _Thunk(result, env)
i += 2
return NIL
@@ -270,7 +288,7 @@ def _sf_case(expr: list, env: dict) -> Any:
def _sf_and(expr: list, env: dict) -> Any:
result: Any = True
for arg in expr[1:]:
result = _eval(arg, env)
result = _trampoline(_eval(arg, env))
if not result:
return result
return result
@@ -279,7 +297,7 @@ def _sf_and(expr: list, env: dict) -> Any:
def _sf_or(expr: list, env: dict) -> Any:
result: Any = False
for arg in expr[1:]:
result = _eval(arg, env)
result = _trampoline(_eval(arg, env))
if result:
return result
return result
@@ -299,23 +317,23 @@ def _sf_let(expr: list, env: dict) -> Any:
raise EvalError("let binding must be (name value)")
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(binding[1], local)
local[vname] = _trampoline(_eval(binding[1], local))
elif len(bindings) % 2 == 0:
# Clojure-style: (name val name val ...)
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(bindings[i + 1], local)
local[vname] = _trampoline(_eval(bindings[i + 1], local))
else:
raise EvalError("let bindings must be (name val ...) pairs")
else:
raise EvalError("let bindings must be a list")
# Evaluate body expressions, return last
result: Any = NIL
for body_expr in expr[2:]:
result = _eval(body_expr, local)
return result
# Evaluate body expressions — all but last non-tail, last is tail
body = expr[2:]
for body_expr in body[:-1]:
_trampoline(_eval(body_expr, local))
return _Thunk(body[-1], local)
def _sf_lambda(expr: list, env: dict) -> Lambda:
@@ -341,13 +359,85 @@ def _sf_define(expr: list, env: dict) -> Any:
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"define name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env)
value = _trampoline(_eval(expr[2], env))
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
return value
def _sf_defstyle(expr: list, env: dict) -> Any:
"""``(defstyle card-base (css :rounded-xl :bg-white :shadow))``
Evaluates body → StyleValue, binds to name in env.
"""
if len(expr) < 3:
raise EvalError("defstyle requires name and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defstyle name must be symbol, got {type(name_sym).__name__}")
value = _trampoline(_eval(expr[2], env))
env[name_sym.name] = value
return value
def _sf_defkeyframes(expr: list, env: dict) -> Any:
"""``(defkeyframes fade-in (from (css :opacity-0)) (to (css :opacity-100)))``
Builds @keyframes rule from steps, registers it, and binds the animation.
"""
from .types import StyleValue
from .css_registry import register_generated_rule
from .style_dict import KEYFRAMES
if len(expr) < 3:
raise EvalError("defkeyframes requires name and at least one step")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defkeyframes name must be symbol, got {type(name_sym).__name__}")
kf_name = name_sym.name
# Build @keyframes rule from steps
steps: list[str] = []
for step_expr in expr[2:]:
if not isinstance(step_expr, list) or len(step_expr) < 2:
raise EvalError("defkeyframes step must be (selector (css ...))")
selector = step_expr[0]
if isinstance(selector, Symbol):
selector = selector.name
else:
selector = str(selector)
body = _trampoline(_eval(step_expr[1], env))
if isinstance(body, StyleValue):
decls = body.declarations
elif isinstance(body, str):
decls = body
else:
raise EvalError(f"defkeyframes step body must be css/string, got {type(body).__name__}")
steps.append(f"{selector}{{{decls}}}")
kf_rule = f"@keyframes {kf_name}{{{' '.join(steps)}}}"
# Register in KEYFRAMES so animate-{name} works
KEYFRAMES[kf_name] = kf_rule
# Clear resolver cache so new keyframes are picked up
from .style_resolver import _resolve_cached
_resolve_cached.cache_clear()
# Create a StyleValue for the animation property
import hashlib
h = hashlib.sha256(kf_rule.encode()).hexdigest()[:6]
sv = StyleValue(
class_name=f"sx-{h}",
declarations=f"animation-name:{kf_name}",
keyframes=((kf_name, kf_rule),),
)
register_generated_rule(sv)
env[kf_name] = sv
return sv
def _sf_defcomp(expr: list, env: dict) -> Component:
"""``(defcomp ~name (&key param1 param2 &rest children) body)``"""
if len(expr) < 4:
@@ -393,10 +483,11 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
def _sf_begin(expr: list, env: dict) -> Any:
result: Any = NIL
for sub in expr[1:]:
result = _eval(sub, env)
return result
if len(expr) < 2:
return NIL
for sub in expr[1:-1]:
_trampoline(_eval(sub, env))
return _Thunk(expr[-1], env)
def _sf_quote(expr: list, _env: dict) -> Any:
@@ -407,18 +498,18 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
"""``(-> val (f a) (g b))`` → ``(g (f val a) b)``"""
if len(expr) < 2:
raise EvalError("-> requires at least a value")
result = _eval(expr[1], env)
result = _trampoline(_eval(expr[1], env))
for form in expr[2:]:
if isinstance(form, list):
fn = _eval(form[0], env)
args = [result] + [_eval(a, env) for a in form[1:]]
fn = _trampoline(_eval(form[0], env))
args = [result] + [_trampoline(_eval(a, env)) for a in form[1:]]
else:
fn = _eval(form, env)
fn = _trampoline(_eval(form, env))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
elif isinstance(fn, Lambda):
result = _call_lambda(fn, args, env)
result = _trampoline(_call_lambda(fn, args, env))
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
@@ -482,14 +573,14 @@ def _qq_expand(template: Any, env: dict) -> Any:
if head.name == "unquote":
if len(template) < 2:
raise EvalError("unquote requires an expression")
return _eval(template[1], env)
return _trampoline(_eval(template[1], env))
if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list")
# Walk children, handling splice-unquote
result: list[Any] = []
for item in template:
if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote":
spliced = _eval(item[1], env)
spliced = _trampoline(_eval(item[1], env))
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
@@ -516,7 +607,7 @@ def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any:
rest_start = len(macro.params)
local[macro.rest_param] = list(raw_args[rest_start:])
return _eval(macro.body, local)
return _trampoline(_eval(macro.body, local))
def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
@@ -553,6 +644,75 @@ def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
return handler
def _parse_key_params(params_expr: list) -> list[str]:
"""Parse ``(&key param1 param2 ...)`` into a list of param name strings."""
params: list[str] = []
in_key = False
for p in params_expr:
if isinstance(p, Symbol):
if p.name == "&key":
in_key = True
continue
if in_key:
params.append(p.name)
elif isinstance(p, str):
params.append(p)
return params
def _sf_defquery(expr: list, env: dict):
"""``(defquery name (&key param...) "docstring" body)``"""
from .types import QueryDef
if len(expr) < 4:
raise EvalError("defquery requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defquery name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defquery params must be a list")
params = _parse_key_params(params_expr)
# Optional docstring before body
if len(expr) >= 5 and isinstance(expr[3], str):
doc = expr[3]
body = expr[4]
else:
doc = ""
body = expr[3]
qdef = QueryDef(
name=name_sym.name, params=params, doc=doc,
body=body, closure=dict(env),
)
env[f"query:{name_sym.name}"] = qdef
return qdef
def _sf_defaction(expr: list, env: dict):
"""``(defaction name (&key param...) "docstring" body)``"""
from .types import ActionDef
if len(expr) < 4:
raise EvalError("defaction requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defaction name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defaction params must be a list")
params = _parse_key_params(params_expr)
if len(expr) >= 5 and isinstance(expr[3], str):
doc = expr[3]
body = expr[4]
else:
doc = ""
body = expr[3]
adef = ActionDef(
name=name_sym.name, params=params, doc=doc,
body=body, closure=dict(env),
)
env[f"action:{name_sym.name}"] = adef
return adef
def _sf_set_bang(expr: list, env: dict) -> Any:
"""``(set! name value)`` — mutate existing binding."""
if len(expr) != 3:
@@ -560,7 +720,7 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"set! name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env)
value = _trampoline(_eval(expr[2], env))
# Walk up scope if using Env objects; for plain dicts just overwrite
env[name_sym.name] = value
return value
@@ -591,7 +751,7 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
if isinstance(val, Keyword):
kwargs[key.name] = val.name
else:
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val
kwargs[key.name] = _trampoline(_eval(val, env)) if not isinstance(val, str) else val
i += 2
else:
kwargs[key.name] = None
@@ -677,7 +837,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
elif isinstance(item, str):
auth.append(item)
else:
auth.append(_eval(item, env))
auth.append(_trampoline(_eval(item, env)))
else:
auth = str(auth_val) if auth_val else "public"
@@ -693,7 +853,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
cache_val = slots.get("cache")
cache = None
if cache_val is not None:
cache_result = _eval(cache_val, env)
cache_result = _trampoline(_eval(cache_val, env))
if isinstance(cache_result, dict):
cache = cache_result
@@ -726,6 +886,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"lambda": _sf_lambda,
"fn": _sf_lambda,
"define": _sf_define,
"defstyle": _sf_defstyle,
"defkeyframes": _sf_defkeyframes,
"defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
@@ -737,6 +899,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler,
"defpage": _sf_defpage,
"defquery": _sf_defquery,
"defaction": _sf_defaction,
}
@@ -747,57 +911,57 @@ _SPECIAL_FORMS: dict[str, Any] = {
def _ho_map(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("map requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
return [_call_lambda(fn, [item], env) for item in coll]
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
def _ho_map_indexed(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("map-indexed requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"map-indexed requires lambda, got {type(fn).__name__}")
if len(fn.params) < 2:
raise EvalError("map-indexed lambda needs (i item) params")
return [_call_lambda(fn, [i, item], env) for i, item in enumerate(coll)]
return [_trampoline(_call_lambda(fn, [i, item], env)) for i, item in enumerate(coll)]
def _ho_filter(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("filter requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"filter requires lambda, got {type(fn).__name__}")
return [item for item in coll if _call_lambda(fn, [item], env)]
return [item for item in coll if _trampoline(_call_lambda(fn, [item], env))]
def _ho_reduce(expr: list, env: dict) -> Any:
if len(expr) != 4:
raise EvalError("reduce requires fn, init, and collection")
fn = _eval(expr[1], env)
acc = _eval(expr[2], env)
coll = _eval(expr[3], env)
fn = _trampoline(_eval(expr[1], env))
acc = _trampoline(_eval(expr[2], env))
coll = _trampoline(_eval(expr[3], env))
if not isinstance(fn, Lambda):
raise EvalError(f"reduce requires lambda, got {type(fn).__name__}")
for item in coll:
acc = _call_lambda(fn, [acc, item], env)
acc = _trampoline(_call_lambda(fn, [acc, item], env))
return acc
def _ho_some(expr: list, env: dict) -> Any:
if len(expr) != 3:
raise EvalError("some requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"some requires lambda, got {type(fn).__name__}")
for item in coll:
result = _call_lambda(fn, [item], env)
result = _trampoline(_call_lambda(fn, [item], env))
if result:
return result
return NIL
@@ -806,12 +970,12 @@ def _ho_some(expr: list, env: dict) -> Any:
def _ho_every(expr: list, env: dict) -> bool:
if len(expr) != 3:
raise EvalError("every? requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"every? requires lambda, got {type(fn).__name__}")
for item in coll:
if not _call_lambda(fn, [item], env):
if not _trampoline(_call_lambda(fn, [item], env)):
return False
return True
@@ -819,12 +983,12 @@ def _ho_every(expr: list, env: dict) -> bool:
def _ho_for_each(expr: list, env: dict) -> Any:
if len(expr) != 3:
raise EvalError("for-each requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"for-each requires lambda, got {type(fn).__name__}")
for item in coll:
_call_lambda(fn, [item], env)
_trampoline(_call_lambda(fn, [item], env))
return NIL

View File

@@ -69,7 +69,8 @@ def clear_handlers(service: str | None = None) -> None:
def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]:
"""Parse an .sx file, evaluate it, and register any HandlerDef values."""
from .parser import parse_all
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
@@ -110,16 +111,19 @@ async def execute_handler(
service_name: str,
args: dict[str, str] | None = None,
) -> str:
"""Execute a declarative handler and return rendered sx/HTML string.
"""Execute a declarative handler and return SX wire format (``SxExpr``).
Uses the async evaluator+renderer so I/O primitives (``query``,
``service``, ``request-arg``, etc.) are awaited inline within
control flow — no collect-then-substitute limitations.
Uses the async evaluator so I/O primitives (``query``, ``service``,
``request-arg``, etc.) are awaited inline within control flow.
Returns ``SxExpr`` — pre-built sx source. Callers like
``fetch_fragment`` check ``content-type: text/sx`` and wrap the
response in ``SxExpr`` when consuming cross-service fragments.
1. Build env from component env + handler closure
2. Bind handler params from args (typically request.args)
3. Evaluate + render via async_render (handles I/O inline)
4. Return rendered string
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized)
4. Return ``SxExpr`` wire format
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
@@ -204,3 +208,42 @@ def create_handler_blueprint(service_name: str) -> Any:
bp._python_handlers = _python_handlers # type: ignore[attr-defined]
return bp
# ---------------------------------------------------------------------------
# Direct app mount — replaces per-service fragment blueprint boilerplate
# ---------------------------------------------------------------------------
def auto_mount_fragment_handlers(app: Any, service_name: str) -> Callable:
"""Mount ``/internal/fragments/<type>`` directly on the app.
Returns an ``add_handler(name, fn, content_type)`` function for
registering Python handler overrides (checked before SX handlers).
"""
from quart import Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
python_handlers: dict[str, Callable[[], Awaitable[str]]] = {}
html_types: set[str] = set()
@app.get("/internal/fragments/<fragment_type>")
async def _fragment_dispatch(fragment_type: str):
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
py = python_handlers.get(fragment_type)
if py is not None:
result = await py()
ct = "text/html" if fragment_type in html_types else "text/sx"
return Response(result, status=200, content_type=ct)
hdef = get_handler(service_name, fragment_type)
if hdef is not None:
result = await execute_handler(hdef, service_name, args=dict(request.args))
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
def add_handler(name: str, fn: Callable[[], Awaitable[str]], content_type: str = "text/sx") -> None:
python_handlers[name] = fn
if content_type == "text/html":
html_types.add(name)
return add_handler

View File

@@ -1,7 +1,7 @@
"""
Shared helper functions for s-expression page rendering.
These are used by per-service sx_components.py files to build common
These are used by per-service sxc/pages modules to build common
page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations
@@ -16,34 +16,6 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
# ---------------------------------------------------------------------------
# Pre-computed CSS classes for inline sx built by Python helpers
# ---------------------------------------------------------------------------
# These :class strings appear in post_header_sx / post_admin_header_sx etc.
# They're static — scan once at import time so they aren't re-scanned per request.
_HELPER_CLASS_SOURCES = [
':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"',
':class "relative nav-group"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"',
':class "!bg-stone-500 !text-white"',
':class "fa fa-cog"',
':class "fa fa-shield-halved"',
':class "text-white"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"',
]
def _scan_helper_classes() -> frozenset[str]:
"""Scan the static class strings from helper functions once."""
from .css_registry import scan_classes_from_sx
combined = " ".join(_HELPER_CLASS_SOURCES)
return frozenset(scan_classes_from_sx(combined))
HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes()
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
@@ -77,18 +49,18 @@ def _as_sx(val: Any) -> SxExpr | None:
if not val:
return None
if isinstance(val, SxExpr):
return val
return val if val.source else None
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sx call string."""
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as sx wire format."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return sx_call("header-row-sx",
return await _render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -102,19 +74,19 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def mobile_menu_sx(*sections: str) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
def mobile_root_nav_sx(ctx: dict) -> str:
async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return sx_call("mobile-root-nav",
return await _render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -124,29 +96,25 @@ def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
return SxExpr("")
parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
parts.append(await _render_to_sx("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2'
f' border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
parts.append(await _render_to_sx("container-nav-wrapper",
content=SxExpr(container_nav)))
# Admin cog
admin_nav = ctx.get("post_admin_nav")
@@ -157,22 +125,16 @@ def _post_nav_items_sx(ctx: dict) -> str:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
admin_nav = await _render_to_sx("admin-cog-button",
href=admin_href,
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> SxExpr:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
@@ -193,48 +155,36 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
# ---------------------------------------------------------------------------
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
async def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
nav = await _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
return await _render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
items=nav,
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await _render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -243,9 +193,9 @@ def search_mobile_sx(ctx: dict) -> str:
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await _render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -254,8 +204,8 @@ def search_desktop_sx(ctx: dict) -> str:
)
def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx call string."""
async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx wire format."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
@@ -263,68 +213,66 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_sx = _post_nav_items_sx(ctx) or None
label_sx = await _render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-header-child",
child=SxExpr(child) if child else None,
oob=oob, external=True,
)
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx call string."""
"""Post admin header row as sx wire format."""
# Label
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
if selected:
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
label_sx = await _render_to_sx("post-admin-label",
selected=str(escape(selected)) if selected else None)
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return sx_call("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return sx_call("oob-header-sx",
return await _render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
return await _render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
"""Build OOB response as sx wire format."""
return await _render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -333,7 +281,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
)
def full_page_sx(ctx: dict, *, header_rows: str,
async def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
@@ -344,8 +292,8 @@ def full_page_sx(ctx: dict, *, header_rows: str,
"""
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body",
menu = await mobile_root_nav_sx(ctx)
body_sx = await _render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -359,6 +307,104 @@ def full_page_sx(ctx: dict, *, header_rows: str,
return sx_page(ctx, body_sx, meta_html=meta_html)
def _build_component_ast(__name: str, **kwargs: Any) -> list:
"""Build an AST list for a component call from Python kwargs.
Returns e.g. [Symbol("~card"), Keyword("title"), "hello", Keyword("count"), 3]
No SX string generation — values stay as native Python objects.
"""
from .types import Symbol, Keyword, NIL
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for key, val in kwargs.items():
kebab = key.replace("_", "-")
ast.append(Keyword(kebab))
if val is None:
ast.append(NIL)
elif isinstance(val, SxExpr):
# SxExpr values need to be parsed into AST
from .parser import parse
if not val.source:
ast.append(NIL)
else:
ast.append(parse(val.source))
else:
ast.append(val)
return ast
async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
"""Like ``_render_to_sx`` but merges *extra_env* into the evaluation
environment before eval. Used by ``register_sx_layout`` so .sx
defcomps can read ctx values as free variables.
Uses ``async_eval_slot_to_sx`` (not ``async_eval_to_sx``) so the
top-level component body is expanded server-side — free variables
from *extra_env* are resolved during expansion rather than being
serialized as unresolved symbols for the client.
**Private** — service code should use ``sx_call()`` or defmacros instead.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_slot_to_sx
from .types import Symbol, Keyword, NIL as _NIL
# Build AST with extra_env entries as keyword args so _aser_component
# binds them as params (otherwise it defaults all params to NIL).
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for k, v in extra_env.items():
ast.append(Keyword(k))
ast.append(v if v is not None else _NIL)
for k, v in kwargs.items():
ast.append(Keyword(k.replace("_", "-")))
ast.append(v if v is not None else _NIL)
env = dict(get_component_env())
env.update(extra_env)
ctx = _get_request_context()
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
**Private** — service code should use ``sx_call()`` or defmacros instead.
Only infrastructure code (helpers.py, layouts.py) should call this.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return SxExpr(await async_eval_to_sx(ast, env, ctx))
# Backwards-compat alias — layout infrastructure still imports this.
# Will be removed once all layouts use register_sx_layout().
render_to_sx_with_env = _render_to_sx_with_env
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.
Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments).
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_render
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_render(ast, env, ctx)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
@@ -369,14 +415,23 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
Values are serialized: strings are quoted, None becomes nil,
bools become true/false, numbers stay as-is.
List values use ``(list ...)`` so the client gets an iterable array
rather than a rendered fragment.
"""
from .parser import serialize
from .parser import serialize, SxExpr
name = component_name if component_name.startswith("~") else f"~{component_name}"
parts = [name]
for key, val in kwargs.items():
parts.append(f":{key.replace('_', '-')}")
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
if isinstance(val, list):
items = [serialize(v) for v in val if v is not None]
if not items:
parts.append("nil")
else:
parts.append("(list " + " ".join(items) + ")")
else:
parts.append(serialize(val))
return SxExpr("(" + " ".join(parts) + ")")
@@ -428,27 +483,19 @@ def components_for_request() -> str:
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
"""Return an s-expression wire-format response.
Can be called with a raw sx string::
Takes a raw sx string::
return sx_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sx call)::
return sx_response("test-row", nodeid="foo", outcome="passed")
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
"""
from quart import request, Response
if kwargs:
source = sx_call(source_or_component, **kwargs)
else:
source = source_or_component
body = source
# Validate the sx source parses as a single expression
@@ -473,8 +520,6 @@ def sx_response(source_or_component: str, status: int = 200,
cumulative_classes: set[str] = set()
if registry_loaded():
new_classes = scan_classes_from_sx(source)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
new_classes.update(HELPER_CSS_CLASSES)
if comp_defs:
# Scan only the component definitions actually being sent
new_classes.update(scan_classes_from_sx(comp_defs))
@@ -558,6 +603,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v={sx_js_hash}"></script>
@@ -605,8 +651,6 @@ def sx_page(ctx: dict, page_sx: str, *,
for val in _COMPONENT_ENV.values():
if isinstance(val, Component) and val.css_classes:
classes.update(val.css_classes)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
classes.update(HELPER_CSS_CLASSES)
# Page sx is unique per request — scan it
classes.update(scan_classes_from_sx(page_sx))
# Always include body classes
@@ -628,6 +672,14 @@ def sx_page(ctx: dict, page_sx: str, *,
except Exception:
pass
# Style dictionary for client-side css primitive
styles_hash = _get_style_dict_hash()
client_styles_hash = _get_sx_styles_cookie()
if client_styles_hash and client_styles_hash == styles_hash:
styles_json = "" # Client has cached version
else:
styles_json = _build_style_dict_json()
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
@@ -635,6 +687,8 @@ def sx_page(ctx: dict, page_sx: str, *,
csrf=_html_escape(csrf),
component_hash=component_hash,
component_defs=component_defs,
styles_hash=styles_hash,
styles_json=styles_json,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
@@ -644,6 +698,58 @@ def sx_page(ctx: dict, page_sx: str, *,
_SCRIPT_HASH_CACHE: dict[str, str] = {}
_STYLE_DICT_JSON: str = ""
_STYLE_DICT_HASH: str = ""
def _build_style_dict_json() -> str:
"""Build compact JSON style dictionary for client-side css primitive."""
global _STYLE_DICT_JSON, _STYLE_DICT_HASH
if _STYLE_DICT_JSON:
return _STYLE_DICT_JSON
import json
from .style_dict import (
STYLE_ATOMS, PSEUDO_VARIANTS, RESPONSIVE_BREAKPOINTS,
KEYFRAMES, ARBITRARY_PATTERNS, CHILD_SELECTOR_ATOMS,
)
# Derive child selector prefixes from CHILD_SELECTOR_ATOMS
prefixes = set()
for atom in CHILD_SELECTOR_ATOMS:
# "space-y-4" → "space-y-", "divide-y" → "divide-"
for sep in ("space-x-", "space-y-", "divide-x", "divide-y"):
if atom.startswith(sep):
prefixes.add(sep)
break
data = {
"a": STYLE_ATOMS,
"v": PSEUDO_VARIANTS,
"b": RESPONSIVE_BREAKPOINTS,
"k": KEYFRAMES,
"p": ARBITRARY_PATTERNS,
"c": sorted(prefixes),
}
_STYLE_DICT_JSON = json.dumps(data, separators=(",", ":"))
_STYLE_DICT_HASH = hashlib.md5(_STYLE_DICT_JSON.encode()).hexdigest()[:8]
return _STYLE_DICT_JSON
def _get_style_dict_hash() -> str:
"""Get the hash of the style dictionary JSON."""
if not _STYLE_DICT_HASH:
_build_style_dict_json()
return _STYLE_DICT_HASH
def _get_sx_styles_cookie() -> str:
"""Read the sx-styles-hash cookie from the current request."""
try:
from quart import request
return request.cookies.get("sx-styles-hash", "")
except Exception:
return ""
def _script_hash(filename: str) -> str:

View File

@@ -27,8 +27,16 @@ from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _eval, _call_component, _expand_macro
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all html.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
def _call_component(comp, raw_args, env):
"""Call component and unwrap thunks — non-tail in html.py."""
return _trampoline(_raw_call_component(comp, raw_args, env))
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
@@ -36,6 +44,12 @@ css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.Conte
"css_class_collector", default=None
)
# ContextVar for SVG/MathML namespace auto-detection.
# When True, unknown tag names inside (svg ...) or (math ...) are treated as elements.
_svg_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_svg_context", default=False
)
class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped."""
@@ -86,6 +100,11 @@ HTML_TAGS = frozenset({
"g", "defs", "use", "text", "tspan", "clipPath", "mask",
"linearGradient", "radialGradient", "stop", "filter",
"feGaussianBlur", "feOffset", "feMerge", "feMergeNode",
"feTurbulence", "feColorMatrix", "feBlend",
"feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA",
"feDisplacementMap", "feComposite", "feFlood", "feImage",
"feMorphology", "feSpecularLighting", "feDiffuseLighting",
"fePointLight", "feSpotLight", "feDistantLight",
"animate", "animateTransform",
# Table
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
@@ -187,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 ""
@@ -417,10 +436,22 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
if name == "<>":
return "".join(_render(child, env) for child in expr[1:])
# --- html: prefix → force tag rendering --------------------------
if name.startswith("html:"):
return _render_element(name[5:], expr[1:], env)
# --- Render-aware special forms --------------------------------------
# Check BEFORE HTML_TAGS because some names overlap (e.g. `map`).
if name in _RENDER_FORMS:
return _RENDER_FORMS[name](expr, env)
# But if the name is ALSO an HTML tag and (a) first arg is a Keyword
# or (b) we're inside SVG/MathML context, it's a tag call.
rsf = _RENDER_FORMS.get(name)
if rsf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return _render_element(name, expr[1:], env)
return rsf(expr, env)
# --- Macro expansion → expand then render --------------------------
if name in env:
@@ -440,6 +471,14 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
return _render_component(val, expr[1:], env)
# Fall through to evaluation
# --- Custom element (hyphenated name with keyword attrs) → tag ----
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return _render_element(name, expr[1:], env)
# --- SVG/MathML context → unknown names are child elements --------
if _svg_context.get(False):
return _render_element(name, expr[1:], env)
# --- Other special forms / function calls → evaluate then render ---
result = _eval(expr, env)
return _render(result, env)
@@ -471,6 +510,19 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
children.append(arg)
i += 1
# Handle :style StyleValue — convert to class and register CSS rule
style_val = attrs.get("style")
if isinstance(style_val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(style_val)
# Merge into :class
existing_class = attrs.get("class")
if existing_class and existing_class is not NIL and existing_class is not False:
attrs["class"] = f"{existing_class} {style_val.class_name}"
else:
attrs["class"] = style_val.class_name
del attrs["style"]
# Collect CSS classes if collector is active
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
@@ -488,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(">")
@@ -498,7 +553,15 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
if tag in VOID_ELEMENTS:
return opening
# Render children
child_html = "".join(_render(child, env) for child in children)
# SVG/MathML namespace auto-detection: set context for children
token = None
if tag in ("svg", "math"):
token = _svg_context.set(True)
try:
child_html = "".join(_render(child, env) for child in children)
finally:
if token is not None:
_svg_context.reset(token)
return f"{opening}{child_html}</{tag}>"

View File

@@ -169,7 +169,8 @@ def register_components(sx_source: str) -> None:
(div :class "..." (div :class "..." title)))))
''')
"""
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .parser import parse_all
from .css_registry import scan_classes_from_sx

View File

@@ -2,8 +2,8 @@
Named layout presets for defpage.
Each layout generates header rows for full-page and OOB rendering.
Layouts wrap existing helper functions from ``shared.sx.helpers`` so
defpage can reference them by name (e.g. ``:layout :root``).
Built-in layouts delegate to .sx defcomps via ``register_sx_layout``.
Services register custom layouts via ``register_custom_layout``.
Layouts are registered in ``_LAYOUT_REGISTRY`` and looked up by
``get_layout()`` at request time.
@@ -13,12 +13,6 @@ from __future__ import annotations
from typing import Any, Callable, Awaitable
from .helpers import (
root_header_sx, post_header_sx, post_admin_header_sx,
oob_header_sx, header_child_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
)
# ---------------------------------------------------------------------------
@@ -83,71 +77,50 @@ def get_layout(name: str) -> Layout | None:
return _LAYOUT_REGISTRY.get(name)
# Built-in post/post-admin layouts are registered below via register_sx_layout,
# after that function is defined.
# ---------------------------------------------------------------------------
# Built-in layouts
# register_sx_layout — declarative layout from .sx defcomp names
# ---------------------------------------------------------------------------
# (defined below, used immediately after for built-in "root" layout)
# ---------------------------------------------------------------------------
def _root_full(ctx: dict, **kw: Any) -> str:
return root_header_sx(ctx)
def register_sx_layout(name: str, full_defcomp: str, oob_defcomp: str,
mobile_defcomp: str | None = None) -> None:
"""Register a layout that delegates entirely to .sx defcomps.
Layout defcomps use IO primitives (via auto-fetching macros) to
self-populate — no Python env injection needed. Any extra kwargs
from the caller are passed as kebab-case env entries::
register_sx_layout("account", "account-layout-full",
"account-layout-oob", "account-layout-mobile")
"""
from .helpers import _render_to_sx_with_env
async def full_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(full_defcomp, env)
async def oob_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(oob_defcomp, env)
mobile_fn = None
if mobile_defcomp:
async def mobile_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(mobile_defcomp, env)
register_layout(Layout(name, full_fn, oob_fn, mobile_fn))
def _root_oob(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
return oob_header_sx("root-header-child", "root-header-child", root_hdr)
def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = post_header_sx(ctx)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = post_header_sx(ctx, oob=True)
# Also replace #post-header-child (empty — clears any nested admin rows)
child_oob = oob_header_sx("post-header-child", "", "")
return "(<> " + post_hdr + " " + child_oob + ")"
def _post_admin_full(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = post_header_sx(ctx, child=admin_hdr)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
post_hdr = post_header_sx(ctx, oob=True)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
return "(<> " + post_hdr + " " + admin_oob + ")"
def _root_mobile(ctx: dict, **kw: Any) -> str:
return mobile_root_nav_sx(ctx)
def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
register_layout(Layout("root", _root_full, _root_oob, _root_mobile))
register_layout(Layout("post", _post_full, _post_oob, _post_mobile))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob, _post_admin_mobile))
# Register built-in layouts via .sx defcomps
register_sx_layout("root", "layout-root-full", "layout-root-oob", "layout-root-mobile")
register_sx_layout("post", "layout-post-full", "layout-post-oob", "layout-post-mobile")
register_sx_layout("post-admin", "layout-post-admin-full", "layout-post-admin-oob", "layout-post-admin-mobile")
# ---------------------------------------------------------------------------

View File

@@ -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:

View File

@@ -96,7 +96,8 @@ def get_page_helpers(service: str) -> dict[str, Any]:
def load_page_file(filepath: str, service_name: str) -> list[PageDef]:
"""Parse an .sx file, evaluate it, and register any PageDef values."""
from .parser import parse_all
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
@@ -132,31 +133,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
# Page execution
# ---------------------------------------------------------------------------
async def _eval_slot(expr: Any, env: dict, ctx: Any,
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
"""Evaluate a page slot expression and return an sx source string.
If the expression evaluates to a plain string (e.g. from a Python content
builder), use it directly as sx source. If it evaluates to an AST/list,
serialize it to sx wire format via async_eval_to_sx.
Expands component calls (so IO in the body executes) but serializes
the result as SX wire format, not HTML.
"""
from .html import _RawHTML
from .parser import SxExpr
# First try async_eval to get the raw value
result = await async_eval_fn(expr, env, ctx)
# If it's already an sx source string, use as-is
if isinstance(result, str):
return result
if isinstance(result, _RawHTML):
return result.html
if isinstance(result, SxExpr):
return result.source
if result is None:
return ""
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
# serialize to sx wire format
from .parser import serialize
return serialize(result)
from .async_eval import async_eval_slot_to_sx
return await async_eval_slot_to_sx(expr, env, ctx)
async def execute_page(
@@ -174,7 +158,7 @@ async def execute_page(
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval, async_eval_to_sx
from .async_eval import async_eval
from .page import get_template_context
from .helpers import full_page_sx, oob_page_sx, sx_response
from .layouts import get_layout
@@ -201,23 +185,25 @@ async def execute_page(
if page_def.data_expr is not None:
data_result = await async_eval(page_def.data_expr, env, ctx)
if isinstance(data_result, dict):
env.update(data_result)
# Merge with kebab-case keys so SX symbols can reference them
for k, v in data_result.items():
env[k.replace("_", "-")] = v
# Render content slot (required)
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
content_sx = await _eval_slot(page_def.content_expr, env, ctx)
# Render optional slots
filter_sx = ""
if page_def.filter_expr is not None:
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx)
aside_sx = ""
if page_def.aside_expr is not None:
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx)
menu_sx = ""
if page_def.menu_expr is not None:
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx)
# Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context()
@@ -268,7 +254,7 @@ async def execute_page(
is_htmx = is_htmx_request()
if is_htmx:
return sx_response(oob_page_sx(
return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "",
filter=filter_sx,
aside=aside_sx,
@@ -276,7 +262,7 @@ async def execute_page(
menu=menu_sx,
))
else:
return full_page_sx(
return await full_page_sx(
tctx,
header_rows=header_rows,
filter=filter_sx,
@@ -290,6 +276,18 @@ async def execute_page(
# Blueprint mounting
# ---------------------------------------------------------------------------
def auto_mount_pages(app: Any, service_name: str) -> None:
"""Auto-mount all registered defpages for a service directly on the app.
Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages().
"""
pages = get_all_pages(service_name)
for page_def in pages.values():
_mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint.

View File

@@ -25,31 +25,37 @@ from .types import Keyword, Symbol, NIL
# SxExpr — pre-built sx source marker
# ---------------------------------------------------------------------------
class SxExpr:
class SxExpr(str):
"""Pre-built sx source that serialize() outputs unquoted.
``SxExpr`` is a ``str`` subclass, so it works everywhere a plain
string does (join, startswith, f-strings, isinstance checks). The
only difference: ``serialize()`` emits it unquoted instead of
wrapping it in double-quotes.
Use this to nest sx call strings inside other sx_call() invocations
without them being quoted as strings::
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
sx_call("parent", child=sx_call("child", x=1))
# => (~parent :child (~child :x 1))
"""
__slots__ = ("source",)
def __init__(self, source: str):
self.source = source
def __new__(cls, source: str = "") -> "SxExpr":
return str.__new__(cls, source)
@property
def source(self) -> str:
"""The raw SX source string (backward compat)."""
return str.__str__(self)
def __repr__(self) -> str:
return f"SxExpr({self.source!r})"
def __str__(self) -> str:
return self.source
return f"SxExpr({str.__repr__(self)})"
def __add__(self, other: object) -> "SxExpr":
return SxExpr(self.source + str(other))
return SxExpr(str.__add__(self, str(other)))
def __radd__(self, other: object) -> "SxExpr":
return SxExpr(str(other) + self.source)
return SxExpr(str.__add__(str(other), self))
# ---------------------------------------------------------------------------
@@ -283,7 +289,26 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
# ---------------------------------------------------------------------------
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""Serialize a value back to s-expression text."""
"""Serialize a value back to s-expression text.
Type dispatch order (first match wins):
- ``SxExpr`` → emitted unquoted (pre-built sx source)
- ``list`` → ``(head ...)`` (s-expression list)
- ``Symbol`` → bare name
- ``Keyword`` → ``:name``
- ``str`` → ``"quoted"`` (with escapes)
- ``bool`` → ``true`` / ``false``
- ``int/float`` → numeric literal
- ``None/NIL`` → ``nil``
- ``dict`` → ``{:key val ...}``
List serialization conventions (for ``sx_call`` kwargs):
- ``(list ...)`` — data array: client gets iterable for map/filter
- ``(<> ...)`` — rendered content: client treats as DocumentFragment
- ``(head ...)`` — AST: head is called as function (never use for data)
"""
if isinstance(expr, SxExpr):
return expr.source
@@ -336,6 +361,22 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
# StyleValue — serialize as class name string
from .types import StyleValue
if isinstance(expr, StyleValue):
return f'"{expr.class_name}"'
# _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format
from .html import _RawHTML
if isinstance(expr, _RawHTML):
escaped = (
expr.html.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("</script", "<\\/script")
)
return f'(raw! "{escaped}")'
# Catch callables (Python functions leaked into sx data)
if callable(expr):
import logging

View File

@@ -196,6 +196,8 @@ def prim_is_empty(coll: Any) -> bool:
@register_primitive("contains?")
def prim_contains(coll: Any, key: Any) -> bool:
if isinstance(coll, str):
return str(key) in coll
if isinstance(coll, dict):
k = key.name if isinstance(key, Keyword) else key
return k in coll
@@ -257,6 +259,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]:
def prim_join(sep: str, coll: list) -> str:
return sep.join(str(x) for x in coll)
@register_primitive("replace")
def prim_replace(s: str, old: str, new: str) -> str:
return s.replace(old, new)
@register_primitive("strip-tags")
def prim_strip_tags(s: str) -> str:
"""Strip HTML tags from a string."""
import re
return re.sub(r"<[^>]+>", "", s)
@register_primitive("slice")
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
"""Slice a string or list: (slice coll start end?)."""
start = int(start)
if end is None or end is NIL:
return coll[start:]
return coll[start:int(end)]
@register_primitive("starts-with?")
def prim_starts_with(s, prefix: str) -> bool:
if not isinstance(s, str):
@@ -480,6 +500,15 @@ def prim_format_date(date_str: Any, fmt: str) -> str:
return str(date_str) if date_str else ""
@register_primitive("format-decimal")
def prim_format_decimal(val: Any, places: Any = 2) -> str:
"""``(format-decimal val places)`` → formatted decimal string."""
try:
return f"{float(val):.{int(places)}f}"
except (ValueError, TypeError):
return "0." + "0" * int(places)
@register_primitive("parse-int")
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
"""``(parse-int val default?)`` → int(val) with fallback."""
@@ -489,6 +518,23 @@ def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
return default
@register_primitive("parse-datetime")
def prim_parse_datetime(val: Any) -> Any:
"""``(parse-datetime "2024-01-15T10:00:00")`` → datetime object."""
from datetime import datetime
if not val or val is NIL:
return NIL
return datetime.fromisoformat(str(val))
@register_primitive("split-ids")
def prim_split_ids(val: Any) -> list[int]:
"""``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs."""
if not val or val is NIL:
return []
return [int(x.strip()) for x in str(val).split(",") if x.strip()]
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@@ -498,3 +544,72 @@ def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
if not condition:
raise RuntimeError(f"Assertion error: {message}")
return True
# ---------------------------------------------------------------------------
# Text helpers
# ---------------------------------------------------------------------------
@register_primitive("pluralize")
def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str:
"""``(pluralize count)`` → "s" if count != 1, else "".
``(pluralize count "item" "items")`` → "item" or "items"."""
try:
n = int(count)
except (ValueError, TypeError):
n = 0
if singular or plural != "s":
return singular if n == 1 else plural
return "" if n == 1 else "s"
@register_primitive("escape")
def prim_escape(s: Any) -> str:
"""``(escape val)`` → HTML-escaped string."""
from markupsafe import escape as _escape
return str(_escape(str(s) if s is not None and s is not NIL else ""))
@register_primitive("route-prefix")
def prim_route_prefix() -> str:
"""``(route-prefix)`` → service URL prefix for dev/prod routing."""
from shared.utils import route_prefix
return route_prefix()
# ---------------------------------------------------------------------------
# Style primitives
# ---------------------------------------------------------------------------
@register_primitive("css")
def prim_css(*args: Any) -> Any:
"""``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
Accepts keyword atoms (strings without colon prefix) and runtime
strings. Returns a StyleValue with a content-addressed class name
and all resolved CSS declarations.
"""
from .style_resolver import resolve_style
atoms = tuple(
(a.name if isinstance(a, Keyword) else str(a))
for a in args if a is not None and a is not NIL and a is not False
)
if not atoms:
return NIL
return resolve_style(atoms)
@register_primitive("merge-styles")
def prim_merge_styles(*styles: Any) -> Any:
"""``(merge-styles style1 style2)`` → merged StyleValue.
Merges multiple StyleValues; later declarations win.
"""
from .types import StyleValue
from .style_resolver import merge_styles
valid = [s for s in styles if isinstance(s, StyleValue)]
if not valid:
return NIL
if len(valid) == 1:
return valid[0]
return merge_styles(valid)

View File

@@ -41,6 +41,24 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"nav-tree",
"get-children",
"g",
"csrf-token",
"abort",
"url-for",
"route-prefix",
"root-header-ctx",
"post-header-ctx",
"select-colours",
"account-nav-ctx",
"app-rights",
"federation-actor-ctx",
"request-view-args",
"cart-page-ctx",
"events-calendar-ctx",
"events-day-ctx",
"events-entry-ctx",
"events-slot-ctx",
"events-ticket-type-ctx",
"market-header-ctx",
})
@@ -221,7 +239,10 @@ def _dto_to_dict(obj: Any) -> dict[str, Any]:
keys for any datetime-valued field so sx handlers can build URL paths
without parsing date strings.
"""
if hasattr(obj, "_asdict"):
if hasattr(obj, "__dataclass_fields__"):
from shared.contracts.dtos import dto_to_dict
return dto_to_dict(obj)
elif hasattr(obj, "_asdict"):
d = dict(obj._asdict())
elif hasattr(obj, "__dict__"):
d = {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
@@ -241,6 +262,8 @@ def _convert_result(result: Any) -> Any:
if result is None:
from .types import NIL
return NIL
if isinstance(result, dict):
return {k: _convert_result(v) for k, v in result.items()}
if isinstance(result, tuple):
# Tuple returns (e.g. (entries, has_more)) → list for sx access
return [_convert_result(item) for item in result]
@@ -314,6 +337,605 @@ async def _io_g(
return getattr(g, key, None)
async def _io_csrf_token(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(csrf-token)`` → current CSRF token string."""
from quart import current_app
csrf = current_app.jinja_env.globals.get("csrf_token")
if callable(csrf):
return csrf()
return ""
async def _io_abort(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(abort 403 "message")`` — raise HTTP error from SX.
Allows defpages to abort with HTTP error codes for auth/ownership
checks without needing a Python page helper.
"""
if not args:
raise ValueError("abort requires a status code")
from quart import abort
status = int(args[0])
message = str(args[1]) if len(args) > 1 else ""
abort(status, message)
async def _io_url_for(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs).
Generates a URL for the given endpoint. Keyword args become URL
parameters (kebab-case converted to snake_case).
"""
if not args:
raise ValueError("url-for requires an endpoint name")
from quart import url_for
endpoint = str(args[0])
clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()}
# Convert numeric values for int URL params
for k, v in clean.items():
if isinstance(v, str) and v.isdigit():
clean[k] = int(v)
return url_for(endpoint, **clean)
async def _io_route_prefix(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(route-prefix)`` → current route prefix string."""
from shared.utils import route_prefix
return route_prefix()
async def _io_root_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(root-header-ctx)`` → dict with all root header values.
Fetches cart-mini, auth-menu, nav-tree fragments and computes
settings-url / is-admin from rights. Result is cached on ``g``
per request so multiple calls (e.g. header + mobile) are free.
"""
from quart import g, current_app, request
cached = getattr(g, "_root_header_ctx", None)
if cached is not None:
return cached
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.urls import app_url
from shared.config import config
from .types import NIL
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params: dict[str, Any] = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
auth_params: dict[str, Any] = {}
if user and getattr(user, "email", None):
auth_params["email"] = user.email
nav_params = {"app_name": current_app.name, "path": request.path}
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params),
])
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
result = {
"cart-mini": cart_mini or NIL,
"blog-url": app_url("blog", ""),
"site-title": config()["title"],
"app-label": current_app.name,
"nav-tree": nav_tree or NIL,
"auth-menu": auth_menu or NIL,
"nav-panel": NIL,
"settings-url": app_url("blog", "/settings/") if is_admin else "",
"is-admin": is_admin,
}
g._root_header_ctx = result
return result
async def _io_select_colours(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(select-colours)`` → the shared select/hover CSS class string."""
from quart import current_app
return current_app.jinja_env.globals.get("select_colours", "")
async def _io_account_nav_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL.
Reads ``g.account_nav`` (set by account service's before_request hook),
wrapping HTML strings in ``~rich-text`` for SX rendering.
"""
from quart import g
from .types import NIL
from .parser import SxExpr
val = getattr(g, "account_nav", None)
if not val:
return NIL
if isinstance(val, SxExpr):
return val
# HTML string → wrap for SX rendering
escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
async def _io_app_rights(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(app-rights)`` → user rights dict from ``g.rights``."""
from quart import g
return getattr(g, "rights", None) or {}
async def _io_post_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(post-header-ctx)`` → dict with post-level header values.
Reads post data from ``g._defpage_ctx`` (set by per-service page
helpers), fetches container-nav and page cart count. Result is
cached on ``g`` per request.
Returns dict with keys: slug, title, feature-image, link-href,
container-nav, page-cart-count, cart-href, admin-href, is-admin,
is-admin-page, select-colours.
"""
from quart import g, request
cached = getattr(g, "_post_header_ctx", None)
if cached is not None:
return cached
from shared.infrastructure.urls import app_url
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
post = dctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
result: dict[str, Any] = {"slug": ""}
g._post_header_ctx = result
return result
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image") or NIL
# Container nav (pre-fetched by page helper into defpage ctx)
raw_nav = dctx.get("container_nav") or ""
container_nav: Any = NIL
nav_str = str(raw_nav).strip()
if nav_str and nav_str.replace("(<>", "").replace(")", "").strip():
if isinstance(raw_nav, SxExpr):
container_nav = raw_nav
else:
container_nav = SxExpr(nav_str)
page_cart_count = dctx.get("page_cart_count", 0) or 0
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
result = {
"slug": slug,
"title": title,
"feature-image": feature_image,
"link-href": app_url("blog", f"/{slug}/"),
"container-nav": container_nav,
"page-cart-count": page_cart_count,
"cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "",
"admin-href": app_url("blog", f"/{slug}/admin/"),
"is-admin": is_admin,
"is-admin-page": is_admin_page or NIL,
"select-colours": select_colours,
}
g._post_header_ctx = result
return result
async def _io_cart_page_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(cart-page-ctx)`` → dict with cart page header values.
Reads ``g.page_post`` (set by cart's before_request) and returns
slug, title, feature-image, and cart-url for the page cart header.
"""
from quart import g
from .types import NIL
from shared.infrastructure.urls import app_url
page_post = getattr(g, "page_post", None)
if not page_post:
return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"}
slug = getattr(page_post, "slug", "") or ""
title = (getattr(page_post, "title", "") or "")[:160]
feature_image = getattr(page_post, "feature_image", None) or NIL
return {
"slug": slug,
"title": title,
"feature-image": feature_image,
"page-cart-url": app_url("cart", f"/{slug}/"),
"cart-url": app_url("cart", "/"),
}
async def _io_federation_actor_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any] | None:
"""``(federation-actor-ctx)`` → serialized actor dict or None.
Reads ``g._social_actor`` (set by federation social blueprint's
before_request hook) and serializes to a dict for .sx components.
"""
from quart import g
actor = getattr(g, "_social_actor", None)
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"actor_url": getattr(actor, "actor_url", ""),
}
async def _io_request_view_args(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(request-view-args "key")`` → request.view_args[key]."""
if not args:
raise ValueError("request-view-args requires a key")
from quart import request
key = str(args[0])
return (request.view_args or {}).get(key)
async def _io_events_calendar_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-calendar-ctx)`` → dict with events calendar header values.
Reads ``g.calendar`` or ``g._defpage_ctx["calendar"]`` and returns
slug, name, description for the calendar header row.
"""
from quart import g
cal = getattr(g, "calendar", None)
if not cal:
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = dctx.get("calendar")
if not cal:
return {"slug": ""}
return {
"slug": getattr(cal, "slug", "") or "",
"name": getattr(cal, "name", "") or "",
"description": getattr(cal, "description", "") or "",
}
async def _io_events_day_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-day-ctx)`` → dict with events day header values.
Reads ``g.day_date``, ``g.calendar``, confirmed entries from
``g._defpage_ctx``. Pre-builds the confirmed entries nav as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
day_date = dctx.get("day_date") or getattr(g, "day_date", None)
if not cal or not day_date:
return {"date-str": ""}
cal_slug = getattr(cal, "slug", "") or ""
# Build confirmed entries nav
confirmed = dctx.get("confirmed_entries") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
nav_parts: list[str] = []
if confirmed:
entry_links = []
for entry in confirmed:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = (
f" \u2013 {entry.end_at.strftime('%H:%M')}"
if entry.end_at else ""
)
entry_links.append(sx_call(
"events-day-entry-link",
href=href, name=entry.name, time_str=f"{start}{end}",
))
inner = "".join(entry_links)
nav_parts.append(sx_call(
"events-day-entries-nav", inner=SxExpr(inner),
))
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin", calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
return {
"date-str": day_date.strftime("%A %d %B %Y"),
"year": day_date.year,
"month": day_date.month,
"day": day_date.day,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
}
async def _io_events_entry_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-entry-ctx)`` → dict with events entry header values.
Reads ``g.entry``, ``g.calendar``, and entry_posts from
``g._defpage_ctx``. Pre-builds entry nav (posts + admin link) as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
if not cal or not entry:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
# Times
start = entry.start_at
end = entry.end_at
time_str = ""
if start:
time_str = start.strftime("%H:%M")
if end:
time_str += f" \u2192 {end.strftime('%H:%M')}"
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
# Build nav: associated posts + admin link
entry_posts = dctx.get("entry_posts") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
from shared.infrastructure.urls import app_url
nav_parts: list[str] = []
if entry_posts:
post_links = ""
for ep in entry_posts:
ep_slug = getattr(ep, "slug", "")
ep_title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = app_url("blog", f"/{ep_slug}/")
if feat:
img_html = sx_call("events-post-img", src=feat, alt=ep_title)
else:
img_html = sx_call("events-post-img-placeholder")
post_links += sx_call(
"events-entry-nav-post-link",
href=href, img=SxExpr(img_html), title=ep_title,
)
nav_parts.append(
sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))
.replace(' :hx-swap-oob "true"', '')
)
if is_admin:
admin_url = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
)
nav_parts.append(sx_call("events-entry-admin-link", href=admin_url))
# Entry admin nav (ticket_types link)
admin_href = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
) if is_admin else ""
ticket_types_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day,
)
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
return {
"id": str(entry.id),
"name": entry.name or "",
"time-str": time_str,
"link-href": link_href,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
"admin-href": admin_href,
"ticket-types-href": ticket_types_href,
"is-admin": is_admin,
"select-colours": select_colours,
}
async def _io_events_slot_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-slot-ctx)`` → dict with events slot header values."""
from quart import g
dctx = getattr(g, "_defpage_ctx", None) or {}
slot = getattr(g, "slot", None) or dctx.get("slot")
if not slot:
return {"name": ""}
return {
"name": getattr(slot, "name", "") or "",
"description": getattr(slot, "description", "") or "",
}
async def _io_events_ticket_type_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-ticket-type-ctx)`` → dict with ticket type header values."""
from quart import g, url_for
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type")
if not cal or not entry or not ticket_type:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=ticket_type.id,
)
return {
"id": str(ticket_type.id),
"name": getattr(ticket_type, "name", "") or "",
"link-href": link_href,
}
async def _io_market_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(market-header-ctx)`` → dict with market header data.
Returns plain data (categories list, hrefs, flags) for the
~market-header-auto macro. Mobile nav is pre-built as SxExpr.
"""
from quart import g, url_for
from shared.config import config as get_config
from .parser import SxExpr
cfg = get_config()
market_title = cfg.get("market_title", "")
link_href = url_for("defpage_market_home")
# Get categories if market is loaded
market = getattr(g, "market", None)
categories = {}
if market:
from bp.browse.services.nav import get_nav
nav_data = await get_nav(g.s, market_id=market.id)
categories = nav_data.get("cats", {})
# Build minimal ctx for existing helper functions
select_colours = getattr(g, "select_colours", "")
if not select_colours:
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
rights = getattr(g, "rights", None) or {}
mini_ctx: dict[str, Any] = {
"market_title": market_title,
"top_slug": "",
"sub_slug": "",
"categories": categories,
"qs": "",
"hx_select_search": "#main-panel",
"select_colours": select_colours,
"rights": rights,
"category_label": "",
}
# Build header + mobile nav data via new data-driven helpers
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
header_data = _market_header_data(mini_ctx)
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
return {
"market-title": market_title,
"link-href": link_href,
"top-slug": "",
"sub-slug": "",
"categories": header_data.get("categories", []),
"hx-select": header_data.get("hx-select", "#main-panel"),
"select-colours": header_data.get("select-colours", ""),
"all-href": header_data.get("all-href", ""),
"all-active": header_data.get("all-active", False),
"admin-href": header_data.get("admin-href", ""),
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
}
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -326,4 +948,22 @@ _IO_HANDLERS: dict[str, Any] = {
"nav-tree": _io_nav_tree,
"get-children": _io_get_children,
"g": _io_g,
"csrf-token": _io_csrf_token,
"abort": _io_abort,
"url-for": _io_url_for,
"route-prefix": _io_route_prefix,
"root-header-ctx": _io_root_header_ctx,
"post-header-ctx": _io_post_header_ctx,
"select-colours": _io_select_colours,
"account-nav-ctx": _io_account_nav_ctx,
"app-rights": _io_app_rights,
"federation-actor-ctx": _io_federation_actor_ctx,
"request-view-args": _io_request_view_args,
"cart-page-ctx": _io_cart_page_ctx,
"events-calendar-ctx": _io_events_calendar_ctx,
"events-day-ctx": _io_events_day_ctx,
"events-entry-ctx": _io_events_entry_ctx,
"events-slot-ctx": _io_events_slot_ctx,
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
"market-header-ctx": _io_market_header_ctx,
}

View File

@@ -0,0 +1,70 @@
"""
Execute defquery / defaction definitions.
Unlike fragment handlers (which produce SX markup via ``async_eval_to_sx``),
query/action defs produce **data** (dicts, lists, scalars) that get
JSON-serialized by the calling blueprint. Uses ``async_eval()`` with
the I/O primitive pipeline so ``(service ...)`` calls are awaited inline.
"""
from __future__ import annotations
from typing import Any
from .types import QueryDef, ActionDef, NIL
async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
"""Execute a defquery and return a JSON-serializable result.
Parameters are bound from request query string args.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
env = dict(get_component_env())
env.update(query_def.closure)
# Bind params from request args (try kebab-case and snake_case)
for param in query_def.params:
snake = param.replace("-", "_")
val = params.get(param, params.get(snake, NIL))
# Coerce type=int for common patterns
if isinstance(val, str) and val.lstrip("-").isdigit():
val = int(val)
env[param] = val
ctx = _get_request_context()
result = await async_eval(query_def.body, env, ctx)
return _normalize(result)
async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
"""Execute a defaction and return a JSON-serializable result.
Parameters are bound from the JSON request body.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
env = dict(get_component_env())
env.update(action_def.closure)
# Bind params from JSON payload (try kebab-case and snake_case)
for param in action_def.params:
snake = param.replace("-", "_")
val = payload.get(param, payload.get(snake, NIL))
env[param] = val
ctx = _get_request_context()
result = await async_eval(action_def.body, env, ctx)
return _normalize(result)
def _normalize(value: Any) -> Any:
"""Ensure result is JSON-serializable (strip NIL, convert sets, etc)."""
if value is NIL or value is None:
return None
if isinstance(value, set):
return list(value)
return value

182
shared/sx/query_registry.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Registry for defquery / defaction definitions.
Mirrors the pattern in ``handlers.py`` but for inter-service data queries
and action endpoints. Each service loads its ``.sx`` files at startup,
and the registry makes them available for dispatch by the query blueprint.
Usage::
from shared.sx.query_registry import load_query_file, get_query
load_query_file("events/queries.sx", "events")
qdef = get_query("events", "pending-entries")
"""
from __future__ import annotations
import logging
import os
from typing import Any
from .types import QueryDef, ActionDef
logger = logging.getLogger("sx.query_registry")
# ---------------------------------------------------------------------------
# Registry — service → name → QueryDef / ActionDef
# ---------------------------------------------------------------------------
_QUERY_REGISTRY: dict[str, dict[str, QueryDef]] = {}
_ACTION_REGISTRY: dict[str, dict[str, ActionDef]] = {}
def register_query(service: str, qdef: QueryDef) -> None:
if service not in _QUERY_REGISTRY:
_QUERY_REGISTRY[service] = {}
_QUERY_REGISTRY[service][qdef.name] = qdef
logger.debug("Registered query %s:%s", service, qdef.name)
def register_action(service: str, adef: ActionDef) -> None:
if service not in _ACTION_REGISTRY:
_ACTION_REGISTRY[service] = {}
_ACTION_REGISTRY[service][adef.name] = adef
logger.debug("Registered action %s:%s", service, adef.name)
def get_query(service: str, name: str) -> QueryDef | None:
return _QUERY_REGISTRY.get(service, {}).get(name)
def get_action(service: str, name: str) -> ActionDef | None:
return _ACTION_REGISTRY.get(service, {}).get(name)
def get_all_queries(service: str) -> dict[str, QueryDef]:
return dict(_QUERY_REGISTRY.get(service, {}))
def get_all_actions(service: str) -> dict[str, ActionDef]:
return dict(_ACTION_REGISTRY.get(service, {}))
def clear(service: str | None = None) -> None:
if service is None:
_QUERY_REGISTRY.clear()
_ACTION_REGISTRY.clear()
else:
_QUERY_REGISTRY.pop(service, None)
_ACTION_REGISTRY.pop(service, None)
# ---------------------------------------------------------------------------
# Loading — parse .sx files and collect QueryDef / ActionDef instances
# ---------------------------------------------------------------------------
def load_query_file(filepath: str, service_name: str) -> list[QueryDef]:
"""Parse an .sx file and register any defquery definitions."""
from .parser import parse_all
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
source = f.read()
env = dict(get_component_env())
exprs = parse_all(source)
queries: list[QueryDef] = []
for expr in exprs:
_eval(expr, env)
for val in env.values():
if isinstance(val, QueryDef):
register_query(service_name, val)
queries.append(val)
return queries
def load_action_file(filepath: str, service_name: str) -> list[ActionDef]:
"""Parse an .sx file and register any defaction definitions."""
from .parser import parse_all
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
source = f.read()
env = dict(get_component_env())
exprs = parse_all(source)
actions: list[ActionDef] = []
for expr in exprs:
_eval(expr, env)
for val in env.values():
if isinstance(val, ActionDef):
register_action(service_name, val)
actions.append(val)
return actions
def load_query_dir(directory: str, service_name: str) -> list[QueryDef]:
"""Load all .sx files from a directory and register queries."""
import glob as glob_mod
queries: list[QueryDef] = []
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
queries.extend(load_query_file(filepath, service_name))
return queries
def load_action_dir(directory: str, service_name: str) -> list[ActionDef]:
"""Load all .sx files from a directory and register actions."""
import glob as glob_mod
actions: list[ActionDef] = []
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
actions.extend(load_action_file(filepath, service_name))
return actions
def load_service_protocols(service_name: str, base_dir: str) -> None:
"""Load queries.sx and actions.sx from a service's base directory."""
queries_path = os.path.join(base_dir, "queries.sx")
actions_path = os.path.join(base_dir, "actions.sx")
if os.path.exists(queries_path):
load_query_file(queries_path, service_name)
logger.info("Loaded queries for %s from %s", service_name, queries_path)
if os.path.exists(actions_path):
load_action_file(actions_path, service_name)
logger.info("Loaded actions for %s from %s", service_name, actions_path)
# ---------------------------------------------------------------------------
# Schema — introspection for /internal/schema
# ---------------------------------------------------------------------------
def schema_for_service(service: str) -> dict[str, Any]:
"""Return a JSON-serializable schema of all queries and actions."""
queries = []
for qdef in _QUERY_REGISTRY.get(service, {}).values():
queries.append({
"name": qdef.name,
"params": list(qdef.params),
"doc": qdef.doc,
})
actions = []
for adef in _ACTION_REGISTRY.get(service, {}).values():
actions.append({
"name": adef.name,
"params": list(adef.params),
"doc": adef.doc,
})
return {
"service": service,
"queries": sorted(queries, key=lambda q: q["name"]),
"actions": sorted(actions, key=lambda a: a["name"]),
}

View File

@@ -31,7 +31,11 @@ import asyncio
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all resolver.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
from .html import render as html_render, _RawHTML
from .primitives_io import (
IO_PRIMITIVES,

735
shared/sx/style_dict.py Normal file
View File

@@ -0,0 +1,735 @@
"""
Style dictionary — maps keyword atoms to CSS declarations.
Pure data. Each key is a Tailwind-compatible class name (used as an sx keyword
atom in ``(css :flex :gap-4 :p-2)``), and each value is the CSS declaration(s)
that class produces. Declarations are self-contained — no ``--tw-*`` custom
properties needed.
Generated from the codebase's tw.css via ``css_registry.py`` then simplified
to remove Tailwind v3 variable indirection.
Used by:
- ``style_resolver.py`` (server) — resolves ``(css ...)`` to StyleValue
- ``sx.js`` (client) — same resolution, cached in localStorage
"""
from __future__ import annotations
# ═══════════════════════════════════════════════════════════════════════════
# Base atoms — keyword → CSS declarations
# ═══════════════════════════════════════════════════════════════════════════
#
# ~466 atoms covering all utilities used across the codebase.
# Variants (hover:*, sm:*, focus:*, etc.) are NOT stored here — the
# resolver splits "hover:bg-sky-200" into variant="hover" + atom="bg-sky-200"
# and wraps the declaration in the appropriate pseudo/media rule.
STYLE_ATOMS: dict[str, str] = {
# ── Display ──────────────────────────────────────────────────────────
"block": "display:block",
"inline-block": "display:inline-block",
"inline": "display:inline",
"flex": "display:flex",
"inline-flex": "display:inline-flex",
"table": "display:table",
"grid": "display:grid",
"contents": "display:contents",
"hidden": "display:none",
# ── Position ─────────────────────────────────────────────────────────
"static": "position:static",
"fixed": "position:fixed",
"absolute": "position:absolute",
"relative": "position:relative",
"inset-0": "inset:0",
"top-0": "top:0",
"top-1/2": "top:50%",
"top-2": "top:.5rem",
"top-20": "top:5rem",
"top-[8px]": "top:8px",
"top-full": "top:100%",
"right-2": "right:.5rem",
"right-[8px]": "right:8px",
"bottom-full": "bottom:100%",
"left-1/2": "left:50%",
"left-2": "left:.5rem",
"-right-2": "right:-.5rem",
"-right-3": "right:-.75rem",
"-top-1.5": "top:-.375rem",
"-top-2": "top:-.5rem",
# ── Z-Index ──────────────────────────────────────────────────────────
"z-10": "z-index:10",
"z-40": "z-index:40",
"z-50": "z-index:50",
# ── Grid ─────────────────────────────────────────────────────────────
"grid-cols-1": "grid-template-columns:repeat(1,minmax(0,1fr))",
"grid-cols-2": "grid-template-columns:repeat(2,minmax(0,1fr))",
"grid-cols-3": "grid-template-columns:repeat(3,minmax(0,1fr))",
"grid-cols-4": "grid-template-columns:repeat(4,minmax(0,1fr))",
"grid-cols-5": "grid-template-columns:repeat(5,minmax(0,1fr))",
"grid-cols-6": "grid-template-columns:repeat(6,minmax(0,1fr))",
"grid-cols-7": "grid-template-columns:repeat(7,minmax(0,1fr))",
"grid-cols-12": "grid-template-columns:repeat(12,minmax(0,1fr))",
"col-span-2": "grid-column:span 2/span 2",
"col-span-3": "grid-column:span 3/span 3",
"col-span-4": "grid-column:span 4/span 4",
"col-span-5": "grid-column:span 5/span 5",
"col-span-12": "grid-column:span 12/span 12",
"col-span-full": "grid-column:1/-1",
# ── Flexbox ──────────────────────────────────────────────────────────
"flex-row": "flex-direction:row",
"flex-col": "flex-direction:column",
"flex-wrap": "flex-wrap:wrap",
"flex-1": "flex:1 1 0%",
"flex-shrink-0": "flex-shrink:0",
"shrink-0": "flex-shrink:0",
"flex-shrink": "flex-shrink:1",
# ── Alignment ────────────────────────────────────────────────────────
"items-start": "align-items:flex-start",
"items-end": "align-items:flex-end",
"items-center": "align-items:center",
"items-baseline": "align-items:baseline",
"justify-start": "justify-content:flex-start",
"justify-end": "justify-content:flex-end",
"justify-center": "justify-content:center",
"justify-between": "justify-content:space-between",
"self-start": "align-self:flex-start",
"self-center": "align-self:center",
"place-items-center": "place-items:center",
# ── Gap ───────────────────────────────────────────────────────────────
"gap-px": "gap:1px",
"gap-0.5": "gap:.125rem",
"gap-1": "gap:.25rem",
"gap-1.5": "gap:.375rem",
"gap-2": "gap:.5rem",
"gap-3": "gap:.75rem",
"gap-4": "gap:1rem",
"gap-5": "gap:1.25rem",
"gap-6": "gap:1.5rem",
"gap-8": "gap:2rem",
"gap-[4px]": "gap:4px",
"gap-[8px]": "gap:8px",
"gap-[16px]": "gap:16px",
"gap-x-3": "column-gap:.75rem",
"gap-y-1": "row-gap:.25rem",
# ── Margin ───────────────────────────────────────────────────────────
"m-0": "margin:0",
"m-2": "margin:.5rem",
"mx-1": "margin-left:.25rem;margin-right:.25rem",
"mx-2": "margin-left:.5rem;margin-right:.5rem",
"mx-4": "margin-left:1rem;margin-right:1rem",
"mx-auto": "margin-left:auto;margin-right:auto",
"my-3": "margin-top:.75rem;margin-bottom:.75rem",
"-mb-px": "margin-bottom:-1px",
"mb-1": "margin-bottom:.25rem",
"mb-2": "margin-bottom:.5rem",
"mb-3": "margin-bottom:.75rem",
"mb-4": "margin-bottom:1rem",
"mb-6": "margin-bottom:1.5rem",
"mb-8": "margin-bottom:2rem",
"mb-12": "margin-bottom:3rem",
"mb-[8px]": "margin-bottom:8px",
"mb-[24px]": "margin-bottom:24px",
"ml-1": "margin-left:.25rem",
"ml-2": "margin-left:.5rem",
"ml-4": "margin-left:1rem",
"ml-auto": "margin-left:auto",
"mr-1": "margin-right:.25rem",
"mr-2": "margin-right:.5rem",
"mr-3": "margin-right:.75rem",
"mt-0.5": "margin-top:.125rem",
"mt-1": "margin-top:.25rem",
"mt-2": "margin-top:.5rem",
"mt-3": "margin-top:.75rem",
"mt-4": "margin-top:1rem",
"mt-6": "margin-top:1.5rem",
"mt-8": "margin-top:2rem",
"mt-[8px]": "margin-top:8px",
"mt-[16px]": "margin-top:16px",
"mt-[32px]": "margin-top:32px",
# ── Padding ──────────────────────────────────────────────────────────
"p-0": "padding:0",
"p-1": "padding:.25rem",
"p-1.5": "padding:.375rem",
"p-2": "padding:.5rem",
"p-3": "padding:.75rem",
"p-4": "padding:1rem",
"p-5": "padding:1.25rem",
"p-6": "padding:1.5rem",
"p-8": "padding:2rem",
"px-1": "padding-left:.25rem;padding-right:.25rem",
"px-1.5": "padding-left:.375rem;padding-right:.375rem",
"px-2": "padding-left:.5rem;padding-right:.5rem",
"px-2.5": "padding-left:.625rem;padding-right:.625rem",
"px-3": "padding-left:.75rem;padding-right:.75rem",
"px-4": "padding-left:1rem;padding-right:1rem",
"px-6": "padding-left:1.5rem;padding-right:1.5rem",
"px-[8px]": "padding-left:8px;padding-right:8px",
"px-[12px]": "padding-left:12px;padding-right:12px",
"px-[16px]": "padding-left:16px;padding-right:16px",
"px-[20px]": "padding-left:20px;padding-right:20px",
"py-0.5": "padding-top:.125rem;padding-bottom:.125rem",
"py-1": "padding-top:.25rem;padding-bottom:.25rem",
"py-1.5": "padding-top:.375rem;padding-bottom:.375rem",
"py-2": "padding-top:.5rem;padding-bottom:.5rem",
"py-3": "padding-top:.75rem;padding-bottom:.75rem",
"py-4": "padding-top:1rem;padding-bottom:1rem",
"py-6": "padding-top:1.5rem;padding-bottom:1.5rem",
"py-8": "padding-top:2rem;padding-bottom:2rem",
"py-12": "padding-top:3rem;padding-bottom:3rem",
"py-16": "padding-top:4rem;padding-bottom:4rem",
"py-[6px]": "padding-top:6px;padding-bottom:6px",
"py-[12px]": "padding-top:12px;padding-bottom:12px",
"pb-1": "padding-bottom:.25rem",
"pb-2": "padding-bottom:.5rem",
"pb-3": "padding-bottom:.75rem",
"pb-4": "padding-bottom:1rem",
"pb-6": "padding-bottom:1.5rem",
"pb-8": "padding-bottom:2rem",
"pb-[48px]": "padding-bottom:48px",
"pl-2": "padding-left:.5rem",
"pl-5": "padding-left:1.25rem",
"pl-6": "padding-left:1.5rem",
"pr-1": "padding-right:.25rem",
"pr-2": "padding-right:.5rem",
"pr-4": "padding-right:1rem",
"pt-2": "padding-top:.5rem",
"pt-3": "padding-top:.75rem",
"pt-4": "padding-top:1rem",
"pt-[16px]": "padding-top:16px",
# ── Width ────────────────────────────────────────────────────────────
"w-1": "width:.25rem",
"w-2": "width:.5rem",
"w-4": "width:1rem",
"w-5": "width:1.25rem",
"w-6": "width:1.5rem",
"w-8": "width:2rem",
"w-10": "width:2.5rem",
"w-11": "width:2.75rem",
"w-12": "width:3rem",
"w-16": "width:4rem",
"w-20": "width:5rem",
"w-24": "width:6rem",
"w-28": "width:7rem",
"w-48": "width:12rem",
"w-1/2": "width:50%",
"w-1/3": "width:33.333333%",
"w-1/4": "width:25%",
"w-1/6": "width:16.666667%",
"w-2/6": "width:33.333333%",
"w-3/4": "width:75%",
"w-full": "width:100%",
"w-auto": "width:auto",
"w-[1em]": "width:1em",
"w-[32px]": "width:32px",
# ── Height ───────────────────────────────────────────────────────────
"h-2": "height:.5rem",
"h-4": "height:1rem",
"h-5": "height:1.25rem",
"h-6": "height:1.5rem",
"h-8": "height:2rem",
"h-10": "height:2.5rem",
"h-12": "height:3rem",
"h-14": "height:3.5rem",
"h-16": "height:4rem",
"h-24": "height:6rem",
"h-28": "height:7rem",
"h-48": "height:12rem",
"h-64": "height:16rem",
"h-full": "height:100%",
"h-[1em]": "height:1em",
"h-[30vh]": "height:30vh",
"h-[32px]": "height:32px",
"h-[60vh]": "height:60vh",
# ── Min/Max Dimensions ───────────────────────────────────────────────
"min-w-0": "min-width:0",
"min-w-full": "min-width:100%",
"min-w-[1.25rem]": "min-width:1.25rem",
"min-w-[180px]": "min-width:180px",
"min-h-0": "min-height:0",
"min-h-20": "min-height:5rem",
"min-h-[3rem]": "min-height:3rem",
"min-h-[50vh]": "min-height:50vh",
"max-w-xs": "max-width:20rem",
"max-w-md": "max-width:28rem",
"max-w-lg": "max-width:32rem",
"max-w-2xl": "max-width:42rem",
"max-w-3xl": "max-width:48rem",
"max-w-4xl": "max-width:56rem",
"max-w-full": "max-width:100%",
"max-w-none": "max-width:none",
"max-w-screen-2xl": "max-width:1536px",
"max-w-[360px]": "max-width:360px",
"max-w-[768px]": "max-width:768px",
"max-h-64": "max-height:16rem",
"max-h-96": "max-height:24rem",
"max-h-none": "max-height:none",
"max-h-[448px]": "max-height:448px",
"max-h-[50vh]": "max-height:50vh",
# ── Typography ───────────────────────────────────────────────────────
"text-xs": "font-size:.75rem;line-height:1rem",
"text-sm": "font-size:.875rem;line-height:1.25rem",
"text-base": "font-size:1rem;line-height:1.5rem",
"text-lg": "font-size:1.125rem;line-height:1.75rem",
"text-xl": "font-size:1.25rem;line-height:1.75rem",
"text-2xl": "font-size:1.5rem;line-height:2rem",
"text-3xl": "font-size:1.875rem;line-height:2.25rem",
"text-4xl": "font-size:2.25rem;line-height:2.5rem",
"text-5xl": "font-size:3rem;line-height:1",
"text-6xl": "font-size:3.75rem;line-height:1",
"text-8xl": "font-size:6rem;line-height:1",
"text-[8px]": "font-size:8px",
"text-[9px]": "font-size:9px",
"text-[10px]": "font-size:10px",
"text-[11px]": "font-size:11px",
"text-[13px]": "font-size:13px",
"text-[14px]": "font-size:14px",
"text-[16px]": "font-size:16px",
"text-[18px]": "font-size:18px",
"text-[36px]": "font-size:36px",
"text-[40px]": "font-size:40px",
"text-[0.6rem]": "font-size:.6rem",
"text-[0.65rem]": "font-size:.65rem",
"text-[0.7rem]": "font-size:.7rem",
"font-normal": "font-weight:400",
"font-medium": "font-weight:500",
"font-semibold": "font-weight:600",
"font-bold": "font-weight:700",
"font-mono": "font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace",
"italic": "font-style:italic",
"uppercase": "text-transform:uppercase",
"capitalize": "text-transform:capitalize",
"tabular-nums": "font-variant-numeric:tabular-nums",
"leading-none": "line-height:1",
"leading-tight": "line-height:1.25",
"leading-snug": "line-height:1.375",
"leading-relaxed": "line-height:1.625",
"tracking-tight": "letter-spacing:-.025em",
"tracking-wide": "letter-spacing:.025em",
"tracking-widest": "letter-spacing:.1em",
"text-left": "text-align:left",
"text-center": "text-align:center",
"text-right": "text-align:right",
"align-top": "vertical-align:top",
# ── Text Colors ──────────────────────────────────────────────────────
"text-white": "color:rgb(255 255 255)",
"text-white/80": "color:rgba(255,255,255,.8)",
"text-black": "color:rgb(0 0 0)",
"text-stone-300": "color:rgb(214 211 209)",
"text-stone-400": "color:rgb(168 162 158)",
"text-stone-500": "color:rgb(120 113 108)",
"text-stone-600": "color:rgb(87 83 78)",
"text-stone-700": "color:rgb(68 64 60)",
"text-stone-800": "color:rgb(41 37 36)",
"text-stone-900": "color:rgb(28 25 23)",
"text-slate-400": "color:rgb(148 163 184)",
"text-gray-500": "color:rgb(107 114 128)",
"text-gray-600": "color:rgb(75 85 99)",
"text-red-500": "color:rgb(239 68 68)",
"text-red-600": "color:rgb(220 38 38)",
"text-red-700": "color:rgb(185 28 28)",
"text-red-800": "color:rgb(153 27 27)",
"text-rose-500": "color:rgb(244 63 94)",
"text-rose-600": "color:rgb(225 29 72)",
"text-rose-700": "color:rgb(190 18 60)",
"text-rose-800/80": "color:rgba(159,18,57,.8)",
"text-rose-900": "color:rgb(136 19 55)",
"text-orange-600": "color:rgb(234 88 12)",
"text-amber-500": "color:rgb(245 158 11)",
"text-amber-600": "color:rgb(217 119 6)",
"text-amber-700": "color:rgb(180 83 9)",
"text-amber-800": "color:rgb(146 64 14)",
"text-yellow-700": "color:rgb(161 98 7)",
"text-green-600": "color:rgb(22 163 74)",
"text-green-800": "color:rgb(22 101 52)",
"text-emerald-500": "color:rgb(16 185 129)",
"text-emerald-600": "color:rgb(5 150 105)",
"text-emerald-700": "color:rgb(4 120 87)",
"text-emerald-800": "color:rgb(6 95 70)",
"text-emerald-900": "color:rgb(6 78 59)",
"text-sky-600": "color:rgb(2 132 199)",
"text-sky-700": "color:rgb(3 105 161)",
"text-sky-800": "color:rgb(7 89 133)",
"text-blue-500": "color:rgb(59 130 246)",
"text-blue-600": "color:rgb(37 99 235)",
"text-blue-700": "color:rgb(29 78 216)",
"text-blue-800": "color:rgb(30 64 175)",
"text-purple-600": "color:rgb(147 51 234)",
"text-violet-600": "color:rgb(124 58 237)",
"text-violet-700": "color:rgb(109 40 217)",
"text-violet-800": "color:rgb(91 33 182)",
# ── Background Colors ────────────────────────────────────────────────
"bg-transparent": "background-color:transparent",
"bg-white": "background-color:rgb(255 255 255)",
"bg-white/60": "background-color:rgba(255,255,255,.6)",
"bg-white/70": "background-color:rgba(255,255,255,.7)",
"bg-white/80": "background-color:rgba(255,255,255,.8)",
"bg-white/90": "background-color:rgba(255,255,255,.9)",
"bg-black": "background-color:rgb(0 0 0)",
"bg-black/50": "background-color:rgba(0,0,0,.5)",
"bg-stone-50": "background-color:rgb(250 250 249)",
"bg-stone-100": "background-color:rgb(245 245 244)",
"bg-stone-200": "background-color:rgb(231 229 228)",
"bg-stone-300": "background-color:rgb(214 211 209)",
"bg-stone-400": "background-color:rgb(168 162 158)",
"bg-stone-500": "background-color:rgb(120 113 108)",
"bg-stone-600": "background-color:rgb(87 83 78)",
"bg-stone-700": "background-color:rgb(68 64 60)",
"bg-stone-800": "background-color:rgb(41 37 36)",
"bg-stone-900": "background-color:rgb(28 25 23)",
"bg-slate-100": "background-color:rgb(241 245 249)",
"bg-slate-200": "background-color:rgb(226 232 240)",
"bg-gray-100": "background-color:rgb(243 244 246)",
"bg-red-50": "background-color:rgb(254 242 242)",
"bg-red-100": "background-color:rgb(254 226 226)",
"bg-red-200": "background-color:rgb(254 202 202)",
"bg-red-500": "background-color:rgb(239 68 68)",
"bg-red-600": "background-color:rgb(220 38 38)",
"bg-rose-50": "background-color:rgb(255 241 242)",
"bg-rose-50/80": "background-color:rgba(255,241,242,.8)",
"bg-orange-100": "background-color:rgb(255 237 213)",
"bg-amber-50": "background-color:rgb(255 251 235)",
"bg-amber-50/60": "background-color:rgba(255,251,235,.6)",
"bg-amber-100": "background-color:rgb(254 243 199)",
"bg-amber-500": "background-color:rgb(245 158 11)",
"bg-amber-600": "background-color:rgb(217 119 6)",
"bg-yellow-50": "background-color:rgb(254 252 232)",
"bg-yellow-100": "background-color:rgb(254 249 195)",
"bg-yellow-200": "background-color:rgb(254 240 138)",
"bg-yellow-300": "background-color:rgb(253 224 71)",
"bg-green-50": "background-color:rgb(240 253 244)",
"bg-green-100": "background-color:rgb(220 252 231)",
"bg-emerald-50": "background-color:rgb(236 253 245)",
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
"bg-emerald-100": "background-color:rgb(209 250 229)",
"bg-emerald-200": "background-color:rgb(167 243 208)",
"bg-emerald-500": "background-color:rgb(16 185 129)",
"bg-emerald-600": "background-color:rgb(5 150 105)",
"bg-sky-100": "background-color:rgb(224 242 254)",
"bg-sky-200": "background-color:rgb(186 230 253)",
"bg-sky-300": "background-color:rgb(125 211 252)",
"bg-sky-400": "background-color:rgb(56 189 248)",
"bg-sky-500": "background-color:rgb(14 165 233)",
"bg-blue-50": "background-color:rgb(239 246 255)",
"bg-blue-100": "background-color:rgb(219 234 254)",
"bg-blue-600": "background-color:rgb(37 99 235)",
"bg-purple-600": "background-color:rgb(147 51 234)",
"bg-violet-50": "background-color:rgb(245 243 255)",
"bg-violet-100": "background-color:rgb(237 233 254)",
"bg-violet-200": "background-color:rgb(221 214 254)",
"bg-violet-300": "background-color:rgb(196 181 253)",
"bg-violet-400": "background-color:rgb(167 139 250)",
"bg-violet-500": "background-color:rgb(139 92 246)",
"bg-violet-600": "background-color:rgb(124 58 237)",
# ── Border ───────────────────────────────────────────────────────────
"border": "border-width:1px",
"border-2": "border-width:2px",
"border-4": "border-width:4px",
"border-t": "border-top-width:1px",
"border-t-0": "border-top-width:0",
"border-b": "border-bottom-width:1px",
"border-b-2": "border-bottom-width:2px",
"border-r": "border-right-width:1px",
"border-l-4": "border-left-width:4px",
"border-dashed": "border-style:dashed",
"border-none": "border-style:none",
"border-transparent": "border-color:transparent",
"border-white": "border-color:rgb(255 255 255)",
"border-white/30": "border-color:rgba(255,255,255,.3)",
"border-stone-100": "border-color:rgb(245 245 244)",
"border-stone-200": "border-color:rgb(231 229 228)",
"border-stone-300": "border-color:rgb(214 211 209)",
"border-stone-700": "border-color:rgb(68 64 60)",
"border-red-200": "border-color:rgb(254 202 202)",
"border-red-300": "border-color:rgb(252 165 165)",
"border-rose-200": "border-color:rgb(254 205 211)",
"border-rose-300": "border-color:rgb(253 164 175)",
"border-amber-200": "border-color:rgb(253 230 138)",
"border-amber-300": "border-color:rgb(252 211 77)",
"border-yellow-200": "border-color:rgb(254 240 138)",
"border-green-300": "border-color:rgb(134 239 172)",
"border-emerald-100": "border-color:rgb(209 250 229)",
"border-emerald-200": "border-color:rgb(167 243 208)",
"border-emerald-300": "border-color:rgb(110 231 183)",
"border-emerald-600": "border-color:rgb(5 150 105)",
"border-blue-200": "border-color:rgb(191 219 254)",
"border-blue-300": "border-color:rgb(147 197 253)",
"border-violet-200": "border-color:rgb(221 214 254)",
"border-violet-300": "border-color:rgb(196 181 253)",
"border-violet-400": "border-color:rgb(167 139 250)",
"border-t-white": "border-top-color:rgb(255 255 255)",
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
"border-l-stone-400": "border-left-color:rgb(168 162 158)",
# ── Border Radius ────────────────────────────────────────────────────
"rounded": "border-radius:.25rem",
"rounded-md": "border-radius:.375rem",
"rounded-lg": "border-radius:.5rem",
"rounded-xl": "border-radius:.75rem",
"rounded-2xl": "border-radius:1rem",
"rounded-full": "border-radius:9999px",
"rounded-t": "border-top-left-radius:.25rem;border-top-right-radius:.25rem",
"rounded-b": "border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem",
"rounded-[4px]": "border-radius:4px",
"rounded-[8px]": "border-radius:8px",
# ── Shadow ───────────────────────────────────────────────────────────
"shadow-sm": "box-shadow:0 1px 2px 0 rgba(0,0,0,.05)",
"shadow": "box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1)",
"shadow-md": "box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)",
"shadow-lg": "box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1)",
"shadow-xl": "box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)",
# ── Opacity ──────────────────────────────────────────────────────────
"opacity-0": "opacity:0",
"opacity-40": "opacity:.4",
"opacity-50": "opacity:.5",
"opacity-100": "opacity:1",
# ── Ring / Outline ───────────────────────────────────────────────────
"outline-none": "outline:2px solid transparent;outline-offset:2px",
"ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))",
"ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))",
# ── Overflow ─────────────────────────────────────────────────────────
"overflow-hidden": "overflow:hidden",
"overflow-x-auto": "overflow-x:auto",
"overflow-y-auto": "overflow-y:auto",
"overscroll-contain": "overscroll-behavior:contain",
# ── Text Decoration ──────────────────────────────────────────────────
"underline": "text-decoration-line:underline",
"line-through": "text-decoration-line:line-through",
"no-underline": "text-decoration-line:none",
# ── Text Overflow ────────────────────────────────────────────────────
"truncate": "overflow:hidden;text-overflow:ellipsis;white-space:nowrap",
"line-clamp-2": "display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden",
"line-clamp-3": "display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden",
# ── Whitespace / Word Break ──────────────────────────────────────────
"whitespace-normal": "white-space:normal",
"whitespace-nowrap": "white-space:nowrap",
"whitespace-pre-line": "white-space:pre-line",
"whitespace-pre-wrap": "white-space:pre-wrap",
"break-words": "overflow-wrap:break-word",
"break-all": "word-break:break-all",
# ── Transform ────────────────────────────────────────────────────────
"rotate-180": "transform:rotate(180deg)",
"-translate-x-1/2": "transform:translateX(-50%)",
"-translate-y-1/2": "transform:translateY(-50%)",
# ── Transition ───────────────────────────────────────────────────────
"transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-all": "transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-colors": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-opacity": "transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-transform": "transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"duration-75": "transition-duration:75ms",
"duration-100": "transition-duration:100ms",
"duration-150": "transition-duration:150ms",
"duration-200": "transition-duration:200ms",
"duration-300": "transition-duration:300ms",
"duration-500": "transition-duration:500ms",
"duration-700": "transition-duration:700ms",
# ── Animation ────────────────────────────────────────────────────────
"animate-spin": "animation:spin 1s linear infinite",
"animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite",
"animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
"animate-bounce": "animation:bounce 1s infinite",
"animate-none": "animation:none",
# ── Aspect Ratio ─────────────────────────────────────────────────────
"aspect-square": "aspect-ratio:1/1",
"aspect-video": "aspect-ratio:16/9",
# ── Object Fit / Position ────────────────────────────────────────────
"object-contain": "object-fit:contain",
"object-cover": "object-fit:cover",
"object-center": "object-position:center",
"object-top": "object-position:top",
# ── Cursor ───────────────────────────────────────────────────────────
"cursor-pointer": "cursor:pointer",
"cursor-move": "cursor:move",
# ── User Select ──────────────────────────────────────────────────────
"select-none": "user-select:none",
"select-all": "user-select:all",
# ── Pointer Events ───────────────────────────────────────────────────
"pointer-events-none": "pointer-events:none",
# ── Resize ───────────────────────────────────────────────────────────
"resize": "resize:both",
"resize-none": "resize:none",
# ── Scroll Snap ──────────────────────────────────────────────────────
"snap-y": "scroll-snap-type:y mandatory",
"snap-start": "scroll-snap-align:start",
"snap-mandatory": "scroll-snap-type:y mandatory",
# ── List Style ───────────────────────────────────────────────────────
"list-disc": "list-style-type:disc",
"list-decimal": "list-style-type:decimal",
"list-inside": "list-style-position:inside",
# ── Table ────────────────────────────────────────────────────────────
"table-fixed": "table-layout:fixed",
# ── Backdrop ─────────────────────────────────────────────────────────
"backdrop-blur": "backdrop-filter:blur(8px)",
"backdrop-blur-sm": "backdrop-filter:blur(4px)",
"backdrop-blur-md": "backdrop-filter:blur(12px)",
# ── Filter ───────────────────────────────────────────────────────────
"saturate-0": "filter:saturate(0)",
# ── Space Between (child selector atoms) ─────────────────────────────
# These generate `.atom > :not(:first-child)` rules
"space-y-0": "margin-top:0",
"space-y-0.5": "margin-top:.125rem",
"space-y-1": "margin-top:.25rem",
"space-y-2": "margin-top:.5rem",
"space-y-3": "margin-top:.75rem",
"space-y-4": "margin-top:1rem",
"space-y-6": "margin-top:1.5rem",
"space-y-8": "margin-top:2rem",
"space-y-10": "margin-top:2.5rem",
"space-x-1": "margin-left:.25rem",
"space-x-2": "margin-left:.5rem",
# ── Divide (child selector atoms) ────────────────────────────────────
# These generate `.atom > :not(:first-child)` rules
"divide-y": "border-top-width:1px",
"divide-stone-100": "border-color:rgb(245 245 244)",
"divide-stone-200": "border-color:rgb(231 229 228)",
# ── Important modifiers ──────────────────────────────────────────────
"!bg-stone-500": "background-color:rgb(120 113 108)!important",
"!text-white": "color:rgb(255 255 255)!important",
}
# Atoms that need a child selector: `.atom > :not(:first-child)` instead of `.atom`
CHILD_SELECTOR_ATOMS: frozenset[str] = frozenset({
k for k in STYLE_ATOMS
if k.startswith(("space-x-", "space-y-", "divide-y", "divide-x"))
and not k.startswith("divide-stone")
})
# ═══════════════════════════════════════════════════════════════════════════
# Pseudo-class / pseudo-element variants
# ═══════════════════════════════════════════════════════════════════════════
PSEUDO_VARIANTS: dict[str, str] = {
"hover": ":hover",
"focus": ":focus",
"focus-within": ":focus-within",
"focus-visible": ":focus-visible",
"active": ":active",
"disabled": ":disabled",
"first": ":first-child",
"last": ":last-child",
"odd": ":nth-child(odd)",
"even": ":nth-child(even)",
"empty": ":empty",
"open": "[open]",
"placeholder": "::placeholder",
"file": "::file-selector-button",
"aria-selected": "[aria-selected=true]",
"group-hover": ":is(.group:hover) &",
"group-open": ":is(.group[open]) &",
}
# ═══════════════════════════════════════════════════════════════════════════
# Responsive breakpoints
# ═══════════════════════════════════════════════════════════════════════════
RESPONSIVE_BREAKPOINTS: dict[str, str] = {
"sm": "(min-width:640px)",
"md": "(min-width:768px)",
"lg": "(min-width:1024px)",
"xl": "(min-width:1280px)",
"2xl": "(min-width:1536px)",
}
# ═══════════════════════════════════════════════════════════════════════════
# Keyframes — built-in animation definitions
# ═══════════════════════════════════════════════════════════════════════════
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# ═══════════════════════════════════════════════════════════════════════════
# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS
# ═══════════════════════════════════════════════════════════════════════════
#
# Each tuple is (regex_pattern, css_template).
# The regex captures value groups; the template uses {0}, {1}, etc.
ARBITRARY_PATTERNS: list[tuple[str, str]] = [
# Width / Height
(r"w-\[(.+)\]", "width:{0}"),
(r"h-\[(.+)\]", "height:{0}"),
(r"min-w-\[(.+)\]", "min-width:{0}"),
(r"min-h-\[(.+)\]", "min-height:{0}"),
(r"max-w-\[(.+)\]", "max-width:{0}"),
(r"max-h-\[(.+)\]", "max-height:{0}"),
# Spacing
(r"p-\[(.+)\]", "padding:{0}"),
(r"px-\[(.+)\]", "padding-left:{0};padding-right:{0}"),
(r"py-\[(.+)\]", "padding-top:{0};padding-bottom:{0}"),
(r"pt-\[(.+)\]", "padding-top:{0}"),
(r"pb-\[(.+)\]", "padding-bottom:{0}"),
(r"pl-\[(.+)\]", "padding-left:{0}"),
(r"pr-\[(.+)\]", "padding-right:{0}"),
(r"m-\[(.+)\]", "margin:{0}"),
(r"mx-\[(.+)\]", "margin-left:{0};margin-right:{0}"),
(r"my-\[(.+)\]", "margin-top:{0};margin-bottom:{0}"),
(r"mt-\[(.+)\]", "margin-top:{0}"),
(r"mb-\[(.+)\]", "margin-bottom:{0}"),
(r"ml-\[(.+)\]", "margin-left:{0}"),
(r"mr-\[(.+)\]", "margin-right:{0}"),
# Gap
(r"gap-\[(.+)\]", "gap:{0}"),
(r"gap-x-\[(.+)\]", "column-gap:{0}"),
(r"gap-y-\[(.+)\]", "row-gap:{0}"),
# Position
(r"top-\[(.+)\]", "top:{0}"),
(r"right-\[(.+)\]", "right:{0}"),
(r"bottom-\[(.+)\]", "bottom:{0}"),
(r"left-\[(.+)\]", "left:{0}"),
# Border radius
(r"rounded-\[(.+)\]", "border-radius:{0}"),
# Background / Text color
(r"bg-\[(.+)\]", "background-color:{0}"),
(r"text-\[(.+)\]", "font-size:{0}"),
# Grid
(r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"),
(r"col-span-(\d+)", "grid-column:span {0}/span {0}"),
]

254
shared/sx/style_resolver.py Normal file
View File

@@ -0,0 +1,254 @@
"""
Style resolver — ``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
Resolves a tuple of atom strings into a ``StyleValue`` with:
- A content-addressed class name (``sx-{hash[:6]}``)
- Base CSS declarations
- Pseudo-class rules (hover, focus, etc.)
- Media-query rules (responsive breakpoints)
- Referenced @keyframes definitions
Resolution order per atom:
1. Dictionary lookup in ``STYLE_ATOMS``
2. Arbitrary value pattern match (``w-[347px]`` → ``width:347px``)
3. Ignored (unknown atoms are silently skipped)
Results are memoized by input tuple for zero-cost repeat calls.
"""
from __future__ import annotations
import hashlib
import re
from functools import lru_cache
from typing import Sequence
from .style_dict import (
ARBITRARY_PATTERNS,
CHILD_SELECTOR_ATOMS,
KEYFRAMES,
PSEUDO_VARIANTS,
RESPONSIVE_BREAKPOINTS,
STYLE_ATOMS,
)
from .types import StyleValue
# ---------------------------------------------------------------------------
# Compiled arbitrary-value patterns
# ---------------------------------------------------------------------------
_COMPILED_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(f"^{pat}$"), tmpl)
for pat, tmpl in ARBITRARY_PATTERNS
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def resolve_style(atoms: tuple[str, ...]) -> StyleValue:
"""Resolve a tuple of keyword atoms into a StyleValue.
Each atom is a Tailwind-compatible keyword (``flex``, ``gap-4``,
``hover:bg-sky-200``, ``sm:flex-row``, etc.). Both keywords
(without leading colon) and runtime strings are accepted.
"""
return _resolve_cached(atoms)
def merge_styles(styles: Sequence[StyleValue]) -> StyleValue:
"""Merge multiple StyleValues into one.
Later declarations win for the same CSS property. Class name is
recomputed from the merged declarations.
"""
if len(styles) == 1:
return styles[0]
all_decls: list[str] = []
all_media: list[tuple[str, str]] = []
all_pseudo: list[tuple[str, str]] = []
all_kf: list[tuple[str, str]] = []
for sv in styles:
if sv.declarations:
all_decls.append(sv.declarations)
all_media.extend(sv.media_rules)
all_pseudo.extend(sv.pseudo_rules)
all_kf.extend(sv.keyframes)
merged_decls = ";".join(all_decls)
return _build_style_value(
merged_decls,
tuple(all_media),
tuple(all_pseudo),
tuple(dict(all_kf).items()), # dedupe keyframes by name
)
# ---------------------------------------------------------------------------
# Internal resolution
# ---------------------------------------------------------------------------
@lru_cache(maxsize=4096)
def _resolve_cached(atoms: tuple[str, ...]) -> StyleValue:
"""Memoized resolver."""
base_decls: list[str] = []
media_rules: list[tuple[str, str]] = [] # (query, decls)
pseudo_rules: list[tuple[str, str]] = [] # (selector_suffix, decls)
keyframes_needed: list[tuple[str, str]] = []
for atom in atoms:
if not atom:
continue
# Strip leading colon if keyword form (":flex" → "flex")
a = atom.lstrip(":")
# Split variant prefix(es): "hover:bg-sky-200" → ["hover", "bg-sky-200"]
# "sm:hover:bg-sky-200" → ["sm", "hover", "bg-sky-200"]
variant, base = _split_variant(a)
# Resolve the base atom to CSS declarations
decls = _resolve_atom(base)
if not decls:
continue
# Check if this atom references a keyframe
_check_keyframes(base, keyframes_needed)
# Route to the appropriate bucket
if variant is None:
base_decls.append(decls)
elif variant in RESPONSIVE_BREAKPOINTS:
query = RESPONSIVE_BREAKPOINTS[variant]
media_rules.append((query, decls))
elif variant in PSEUDO_VARIANTS:
pseudo_sel = PSEUDO_VARIANTS[variant]
pseudo_rules.append((pseudo_sel, decls))
else:
# Compound variant: "sm:hover:..." → media + pseudo
parts = variant.split(":")
media_part = None
pseudo_part = None
for p in parts:
if p in RESPONSIVE_BREAKPOINTS:
media_part = RESPONSIVE_BREAKPOINTS[p]
elif p in PSEUDO_VARIANTS:
pseudo_part = PSEUDO_VARIANTS[p]
if media_part and pseudo_part:
# Both media and pseudo — store as pseudo within media
# For now, put in pseudo_rules with media annotation
pseudo_rules.append((pseudo_part, decls))
media_rules.append((media_part, decls))
elif media_part:
media_rules.append((media_part, decls))
elif pseudo_part:
pseudo_rules.append((pseudo_part, decls))
else:
# Unknown variant — treat as base
base_decls.append(decls)
return _build_style_value(
";".join(base_decls),
tuple(media_rules),
tuple(pseudo_rules),
tuple(keyframes_needed),
)
def _split_variant(atom: str) -> tuple[str | None, str]:
"""Split a potentially variant-prefixed atom.
Returns (variant, base) where variant is None for non-prefixed atoms.
Examples:
"flex" → (None, "flex")
"hover:bg-sky-200" → ("hover", "bg-sky-200")
"sm:flex-row" → ("sm", "flex-row")
"sm:hover:bg-sky-200" → ("sm:hover", "bg-sky-200")
"""
# Check for responsive prefix first (always outermost)
for bp in RESPONSIVE_BREAKPOINTS:
prefix = bp + ":"
if atom.startswith(prefix):
rest = atom[len(prefix):]
# Check for nested pseudo variant
for pv in PSEUDO_VARIANTS:
inner_prefix = pv + ":"
if rest.startswith(inner_prefix):
return (bp + ":" + pv, rest[len(inner_prefix):])
return (bp, rest)
# Check for pseudo variant
for pv in PSEUDO_VARIANTS:
prefix = pv + ":"
if atom.startswith(prefix):
return (pv, atom[len(prefix):])
return (None, atom)
def _resolve_atom(atom: str) -> str | None:
"""Look up CSS declarations for a single base atom.
Returns None if the atom is unknown.
"""
# 1. Dictionary lookup
decls = STYLE_ATOMS.get(atom)
if decls is not None:
return decls
# 2. Dynamic keyframes: animate-{name} → animation-name:{name}
if atom.startswith("animate-"):
name = atom[len("animate-"):]
if name in KEYFRAMES:
return f"animation-name:{name}"
# 3. Arbitrary value pattern match
for pattern, template in _COMPILED_PATTERNS:
m = pattern.match(atom)
if m:
groups = m.groups()
result = template
for i, g in enumerate(groups):
result = result.replace(f"{{{i}}}", g)
return result
# 4. Unknown atom — silently skip
return None
def _check_keyframes(atom: str, kf_list: list[tuple[str, str]]) -> None:
"""If the atom references a built-in animation, add its @keyframes."""
if atom.startswith("animate-"):
name = atom[len("animate-"):]
if name in KEYFRAMES:
kf_list.append((name, KEYFRAMES[name]))
def _build_style_value(
declarations: str,
media_rules: tuple,
pseudo_rules: tuple,
keyframes: tuple,
) -> StyleValue:
"""Build a StyleValue with a content-addressed class name."""
# Build hash from all rules for deterministic class name
hash_input = declarations
for query, decls in media_rules:
hash_input += f"@{query}{{{decls}}}"
for sel, decls in pseudo_rules:
hash_input += f"{sel}{{{decls}}}"
for name, rule in keyframes:
hash_input += rule
h = hashlib.sha256(hash_input.encode()).hexdigest()[:6]
class_name = f"sx-{h}"
return StyleValue(
class_name=class_name,
declarations=declarations,
media_rules=media_rules,
pseudo_rules=pseudo_rules,
keyframes=keyframes,
)

View File

@@ -1,5 +1,65 @@
;; Shared auth components — login flow, check email
;; Used by account and federation services.
;; Shared auth components — login flow, check email, header rows
;; Used by account, orders, cart, and federation services.
;; ---------------------------------------------------------------------------
;; Auth / orders header rows — DRY extraction from per-service Python
;; ---------------------------------------------------------------------------
;; Auth section nav items (newsletters link + account_nav slot)
(defcomp ~auth-nav-items (&key account-url select-colours account-nav)
(<>
(~nav-link :href (str (or account-url "") "/newsletters/")
:label "newsletters"
:select-colours (or select-colours ""))
(when account-nav account-nav)))
;; Auth header row — wraps ~menu-row-sx for account section
(defcomp ~auth-header-row (&key account-url select-colours account-nav oob)
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:nav (~auth-nav-items :account-url account-url
:select-colours select-colours
:account-nav account-nav)
:child-id "auth-header-child" :oob oob))
;; Auth header row without nav (for cart service)
(defcomp ~auth-header-row-simple (&key account-url oob)
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:child-id "auth-header-child" :oob oob))
;; Auto-fetching auth header — uses IO primitives, no free variables needed.
;; Expands inline (defmacro) so IO calls resolve in _aser mode.
(defmacro ~auth-header-row-auto (oob)
(quasiquote
(~auth-header-row :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx)
:oob (unquote oob))))
(defmacro ~auth-header-row-simple-auto (oob)
(quasiquote
(~auth-header-row-simple :account-url (app-url "account" "")
:oob (unquote oob))))
;; Auto-fetching auth nav items — for mobile menus
(defmacro ~auth-nav-items-auto ()
(quasiquote
(~auth-nav-items :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx))))
;; Orders header row
(defcomp ~orders-header-row (&key list-url)
(~menu-row-sx :id "orders-row" :level 2 :colour "sky"
:link-href list-url :link-label "Orders" :icon "fa fa-gbp"
:child-id "orders-header-child"))
;; ---------------------------------------------------------------------------
;; Auth forms — login flow, check email
;; ---------------------------------------------------------------------------
(defcomp ~auth-error-banner (&key error)
(when error

View File

@@ -83,6 +83,7 @@
(when auth-menu auth-menu))))
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100
; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
selected hx-select nav child-id child oob external)
(let* ((c (or colour "sky"))
@@ -145,6 +146,113 @@
(when auth-menu
(div :class "p-3 border-t border-stone-200" auth-menu))))
;; ---------------------------------------------------------------------------
;; Root header/mobile shorthand — pass-through to shared defcomps.
;; All values must be supplied as &key args (not free variables) because
;; nested component calls in _aser are serialized without expansion.
;; ---------------------------------------------------------------------------
(defcomp ~root-header (&key cart-mini blog-url site-title app-label
nav-tree auth-menu nav-panel settings-url is-admin oob)
(~header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
:oob oob))
(defcomp ~root-mobile (&key nav-tree auth-menu)
(~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
;; ---------------------------------------------------------------------------
;; Auto-fetching header/mobile macros — use IO primitives to self-populate.
;; These expand inline so IO calls resolve in _aser mode within layout bodies.
;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps.
;; ---------------------------------------------------------------------------
(defmacro ~root-header-auto (oob)
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~header-row-sx :cart-mini (get __rhctx "cart-mini")
:blog-url (get __rhctx "blog-url")
:site-title (get __rhctx "site-title")
:app-label (get __rhctx "app-label")
:nav-tree (get __rhctx "nav-tree")
:auth-menu (get __rhctx "auth-menu")
:nav-panel (get __rhctx "nav-panel")
:settings-url (get __rhctx "settings-url")
:is-admin (get __rhctx "is-admin")
:oob (unquote oob)))))
(defmacro ~root-mobile-auto ()
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~mobile-root-nav :nav-tree (get __rhctx "nav-tree")
:auth-menu (get __rhctx "auth-menu")))))
;; ---------------------------------------------------------------------------
;; Built-in layout defcomps — used by register_sx_layout("root", ...)
;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives).
;; ---------------------------------------------------------------------------
(defcomp ~layout-root-full ()
(~root-header-auto))
(defcomp ~layout-root-oob ()
(~oob-header-sx :parent-id "root-header-child"
:row (~root-header-auto true)))
(defcomp ~layout-root-mobile ()
(~root-mobile-auto))
;; Post layout — root + post header
(defcomp ~layout-post-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (~post-header-auto))))
(defcomp ~layout-post-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" :row "")))
(defcomp ~layout-post-mobile ()
(let ((__phctx (post-header-ctx))
(__rhctx (root-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
;; Post-admin layout — root + post header with nested admin row
(defcomp ~layout-post-admin-full (&key selected)
(let ((__admin-hdr (~post-admin-header-auto nil selected)))
(<> (~root-header-auto)
(~header-child-sx
:inner (~post-header-auto nil)))))
(defcomp ~layout-post-admin-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))))
(defcomp ~layout-post-admin-mobile (&key selected)
(let ((__phctx (post-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label "admin"
:href (get __phctx "admin-href")
:level 2
:items (~post-admin-nav-auto selected)))
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
(defcomp ~error-content (&key errnum message image)
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
@@ -153,6 +261,112 @@
(div :class "flex justify-center"
(img :src image :width "300" :height "300")))))
(defcomp ~clear-oob-div (&key id)
(div :id id :sx-swap-oob "outerHTML"))
;; ---------------------------------------------------------------------------
;; Post-level auto-fetching macros — use (post-header-ctx) IO primitive
;; ---------------------------------------------------------------------------
(defmacro ~post-nav-auto ()
"Post-level nav items: page cart badge + container nav + admin cog."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(<>
(when (> (get __phctx "page-cart-count") 0)
(~page-cart-badge :href (get __phctx "cart-href")
:count (str (get __phctx "page-cart-count"))))
(when (get __phctx "container-nav")
(~container-nav-wrapper :content (get __phctx "container-nav")))
(when (get __phctx "is-admin")
(~admin-cog-button :href (get __phctx "admin-href")
:is-admin-page (get __phctx "is-admin-page"))))))))
(defmacro ~post-header-auto (oob)
"Post-level header row. Reads post data via (post-header-ctx)."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-row" :level 1
:link-href (get __phctx "link-href")
:link-label-content (~post-label
:feature-image (get __phctx "feature-image")
:title (get __phctx "title"))
:nav (~post-nav-auto)
:child-id "post-header-child"
:oob (unquote oob) :external true)))))
(defmacro ~post-admin-nav-auto (selected)
"Post-admin nav items: calendars, markets, etc."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(let ((__slug (get __phctx "slug"))
(__sc (get __phctx "select-colours")))
(<>
(~nav-link :href (app-url "events" (str "/" __slug "/admin/"))
:label "calendars" :select-colours __sc
:is-selected (when (= (unquote selected) "calendars") "true"))
(~nav-link :href (app-url "market" (str "/" __slug "/admin/"))
:label "markets" :select-colours __sc
:is-selected (when (= (unquote selected) "markets") "true"))
(~nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
:label "payments" :select-colours __sc
:is-selected (when (= (unquote selected) "payments") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
:label "entries" :select-colours __sc
:is-selected (when (= (unquote selected) "entries") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
:label "data" :select-colours __sc
:is-selected (when (= (unquote selected) "data") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
:label "preview" :select-colours __sc
:is-selected (when (= (unquote selected) "preview") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
:label "edit" :select-colours __sc
:is-selected (when (= (unquote selected) "edit") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
:label "settings" :select-colours __sc
:is-selected (when (= (unquote selected) "settings") "true"))))))))
(defmacro ~post-admin-header-auto (oob selected)
"Post-admin header row. Uses (post-header-ctx) for slug + URLs."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-admin-row" :level 2
:link-href (get __phctx "admin-href")
:link-label-content (~post-admin-label
:selected (unquote selected))
:nav (~post-admin-nav-auto (unquote selected))
:child-id "post-admin-header-child"
:oob (unquote oob))))))
;; ---------------------------------------------------------------------------
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
;; ---------------------------------------------------------------------------
(defcomp ~container-nav-wrapper (&key content)
(div :id "entries-calendars-nav-wrapper"
:class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
content))
; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white
(defcomp ~admin-cog-button (&key href is-admin-page)
(div :class "relative nav-group"
(a :href href
:class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
(if is-admin-page "!bg-stone-500 !text-white" ""))
(i :class "fa fa-cog" :aria-hidden "true"))))
(defcomp ~post-admin-label (&key selected)
(<>
(i :class "fa fa-shield-halved" :aria-hidden "true")
" admin"
(when selected
(span :class "text-white" selected))))
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
(div :class "relative nav-group"
(a :href href

View File

@@ -124,6 +124,155 @@
;; Checkout error screens
;; ---------------------------------------------------------------------------
;; ---------------------------------------------------------------------------
;; Assembled order list content — replaces Python _orders_rows_sx / _orders_main_panel_sx
;; ---------------------------------------------------------------------------
;; Status pill class mapping
(defcomp ~order-status-pill-cls (&key status)
(let* ((sl (lower (or status ""))))
(cond
((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700")
((or (= sl "failed") (= sl "cancelled")) "border-rose-300 bg-rose-50 text-rose-700")
(true "border-stone-300 bg-stone-50 text-stone-700"))))
;; Single order row pair (desktop + mobile) — takes serialized order data dict
(defcomp ~order-row-pair (&key order detail-url-prefix)
(let* ((status (or (get order "status") "pending"))
(pill-base (~order-status-pill-cls :status status))
(oid (str "#" (get order "id")))
(created (or (get order "created_at_formatted") "\u2014"))
(desc (or (get order "description") ""))
(total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00")))
(url (str detail-url-prefix (get order "id") "/")))
(<>
(~order-row-desktop
:oid oid :created created :desc desc :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base)
:status status :url url)
(~order-row-mobile
:oid oid :created created :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base)
:status status :url url))))
;; Assembled orders list content
(defcomp ~orders-list-content (&key orders page total-pages rows-url detail-url-prefix)
(if (empty? orders)
(~order-empty-state)
(~order-table
:rows (<>
(map (lambda (order)
(~order-row-pair :order order :detail-url-prefix detail-url-prefix))
orders)
(if (< page total-pages)
(~infinite-scroll
:url (str rows-url "?page=" (inc page))
:page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))))
;; Assembled order detail content — replaces Python _order_main_sx
(defcomp ~order-detail-content (&key order calendar-entries)
(let* ((items (get order "items")))
(~order-detail-panel
:summary (~order-summary-card
:order-id (get order "id")
:created-at (get order "created_at_formatted")
:description (get order "description")
:status (get order "status")
:currency (get order "currency")
:total-amount (get order "total_formatted"))
:items (when (not (empty? (or items (list))))
(~order-items-panel
:items (map (lambda (item)
(~order-item-row
:href (get item "product_url")
:img (if (get item "product_image")
(~order-item-image :src (get item "product_image")
:alt (or (get item "product_title") "Product image"))
(~order-item-no-image))
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
:qty (str "Qty: " (get item "quantity"))
:price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00"))))
items)))
:calendar (when (not (empty? (or calendar-entries (list))))
(~order-calendar-section
:items (map (lambda (e)
(let* ((st (or (get e "state") ""))
(pill (cond
((= st "confirmed") "bg-emerald-100 text-emerald-800")
((= st "provisional") "bg-amber-100 text-amber-800")
((= st "ordered") "bg-blue-100 text-blue-800")
(true "bg-stone-100 text-stone-700"))))
(~order-calendar-entry
:name (get e "name")
:pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill)
:status (upper (slice st 0 1))
:date-str (get e "date_str")
:cost (str "\u00a3" (or (get e "cost_formatted") "0.00")))))
calendar-entries))))))
;; Assembled order detail filter — replaces Python _order_filter_sx
(defcomp ~order-detail-filter-content (&key order list-url recheck-url pay-url csrf)
(let* ((status (or (get order "status") "pending"))
(created (or (get order "created_at_formatted") "\u2014")))
(~order-detail-filter
:info (str "Placed " created " \u00b7 Status: " status)
:list-url list-url
:recheck-url recheck-url
:csrf csrf
:pay (when (!= status "paid")
(~order-pay-btn :url pay-url)))))
;; ---------------------------------------------------------------------------
;; Checkout return components
;; ---------------------------------------------------------------------------
(defcomp ~checkout-return-header (&key status)
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete")
(p :class "text-xs sm:text-sm text-stone-600"
(str "Your checkout session is " status "."))))
(defcomp ~checkout-return-missing ()
(div :class "max-w-full px-3 py-3 space-y-4"
(p :class "text-sm text-stone-600" "Order not found.")))
(defcomp ~checkout-return-ticket (&key name pill state type-name date-str code price)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div
(div :class "font-medium flex items-center gap-2"
name (span :class pill state))
(when type-name (div :class "text-xs text-stone-500" type-name))
(div :class "text-xs text-stone-500" date-str)
(when code (div :class "font-mono text-xs text-stone-400" code)))
(div :class "ml-4 font-medium" price)))
(defcomp ~checkout-return-tickets (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Tickets")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~checkout-return-failed (&key order-id)
(div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900"
(p :class "font-medium" "Payment failed")
(p "Please try again or contact support."
(when order-id (span " Order #" (str order-id))))))
(defcomp ~checkout-return-paid ()
(div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"
(p :class "font-medium" "Payment successful!")
(p "Your order has been confirmed.")))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-3 py-3 space-y-4"
status-message summary items calendar tickets))
;; ---------------------------------------------------------------------------
;; Checkout error screens
;; ---------------------------------------------------------------------------
(defcomp ~checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")

View File

@@ -0,0 +1,394 @@
"""Test SxEngine features in sx.js — trigger parsing, param filtering, etc.
Runs pure-logic SxEngine functions through Node.js (no DOM required).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
def _run_engine_js(js_code: str) -> str:
"""Run a JS snippet that has access to SxEngine internals.
We load sx.js with a minimal DOM stub so the IIFE doesn't crash,
then expose internal functions via a test harness.
"""
stub = """
// Minimal DOM stub for SxEngine initialisation
global.document = {
readyState: "complete",
head: { querySelector: function() { return null; } },
body: null,
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
getElementById: function() { return null; },
createElement: function(t) {
return {
tagName: t, attributes: [], childNodes: [],
setAttribute: function() {},
appendChild: function() {},
querySelectorAll: function() { return []; },
};
},
createTextNode: function(t) { return { nodeType: 3, nodeValue: t }; },
createDocumentFragment: function() { return { nodeType: 11, childNodes: [], appendChild: function() {} }; },
addEventListener: function() {},
title: "",
cookie: "",
};
global.window = global;
global.window.addEventListener = function() {};
global.window.matchMedia = function() { return { matches: false }; };
global.window.confirm = function() { return true; };
global.window.prompt = function() { return ""; };
global.window.scrollTo = function() {};
global.requestAnimationFrame = function(fn) { fn(); };
global.setTimeout = global.setTimeout || function(fn) { fn(); };
global.setInterval = global.setInterval || function() {};
global.clearTimeout = global.clearTimeout || function() {};
global.console = { log: function() {}, error: function() {}, warn: function() {} };
global.CSS = { escape: function(s) { return s; } };
global.location = { href: "http://localhost/", hostname: "localhost", origin: "http://localhost", assign: function() {}, reload: function() {} };
global.history = { pushState: function() {}, replaceState: function() {} };
global.fetch = function() { return Promise.resolve({ ok: true, headers: new Map(), text: function() { return Promise.resolve(""); } }); };
global.Headers = function(o) { this._h = o || {}; this.get = function(k) { return this._h[k] || null; }; };
global.URL = function(u, b) { var full = u.indexOf("://") >= 0 ? u : b + u; this.origin = "http://localhost"; this.hostname = "localhost"; };
global.CustomEvent = function(n, o) { this.type = n; this.detail = (o || {}).detail; };
global.AbortController = function() { this.signal = {}; this.abort = function() {}; };
global.URLSearchParams = function(init) {
this._data = [];
if (init) {
if (typeof init.forEach === "function") {
var self = this;
init.forEach(function(v, k) { self._data.push([k, v]); });
}
}
this.append = function(k, v) { this._data.push([k, v]); };
this.delete = function(k) { this._data = this._data.filter(function(p) { return p[0] !== k; }); };
this.getAll = function(k) { return this._data.filter(function(p) { return p[0] === k; }).map(function(p) { return p[1]; }); };
this.toString = function() { return this._data.map(function(p) { return p[0] + "=" + p[1]; }).join("&"); };
this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); };
};
global.FormData = function() { this._data = []; this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); }; };
global.MutationObserver = function() { this.observe = function() {}; this.disconnect = function() {}; };
global.EventSource = function(url) { this.url = url; this.addEventListener = function() {}; this.close = function() {}; };
global.IntersectionObserver = function() { this.observe = function() {}; };
"""
script = f"""
{stub}
{SX_JS.read_text()}
// --- test code ---
{js_code}
"""
result = subprocess.run(
["node", "-e", script],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout
# ---------------------------------------------------------------------------
# parseTrigger tests
# ---------------------------------------------------------------------------
class TestParseTrigger:
"""Test the parseTrigger function for various trigger specifications."""
def _parse(self, spec: str) -> list[dict]:
out = _run_engine_js(f"""
// Access parseTrigger via the IIFE's internal scope isn't possible directly,
// but we can test it indirectly. Actually, we need to extract it.
// Since SxEngine is built as an IIFE, we need to re-expose parseTrigger.
// Let's test via a workaround: add a test method.
// Actually, parseTrigger is captured in the closure. Let's hook into process.
// Better approach: just re-parse the function from sx.js source.
// Simplest: duplicate parseTrigger logic for testing (not ideal).
// Best: we patch SxEngine to expose it before the IIFE closes.
// Actually, the simplest approach: the _parseTime and parseTrigger functions
// are inside the SxEngine IIFE. We can test them by examining the behavior
// through the process() function, but that needs DOM.
//
// Instead, let's just eval the same code to test the logic:
var _parseTime = function(s) {{
if (!s) return 0;
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
return parseInt(s, 10);
}};
var parseTrigger = function(spec) {{
if (!spec) return null;
var triggers = [];
var parts = spec.split(",");
for (var i = 0; i < parts.length; i++) {{
var p = parts[i].trim();
if (!p) continue;
var tokens = p.split(/\\s+/);
if (tokens[0] === "every" && tokens.length >= 2) {{
triggers.push({{ event: "every", modifiers: {{ interval: _parseTime(tokens[1]) }} }});
continue;
}}
var trigger = {{ event: tokens[0], modifiers: {{}} }};
for (var j = 1; j < tokens.length; j++) {{
var tok = tokens[j];
if (tok === "once") trigger.modifiers.once = true;
else if (tok === "changed") trigger.modifiers.changed = true;
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = _parseTime(tok.substring(6));
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
}}
triggers.push(trigger);
}}
return triggers;
}};
process.stdout.write(JSON.stringify(parseTrigger({json.dumps(spec)})));
""")
return json.loads(out)
def test_click(self):
result = self._parse("click")
assert len(result) == 1
assert result[0]["event"] == "click"
def test_every_seconds(self):
result = self._parse("every 2s")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 2000
def test_every_milliseconds(self):
result = self._parse("every 500ms")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 500
def test_delay_modifier(self):
result = self._parse("input changed delay:300ms")
assert result[0]["event"] == "input"
assert result[0]["modifiers"]["changed"] is True
assert result[0]["modifiers"]["delay"] == 300
def test_multiple_triggers(self):
result = self._parse("click, every 5s")
assert len(result) == 2
assert result[0]["event"] == "click"
assert result[1]["event"] == "every"
assert result[1]["modifiers"]["interval"] == 5000
def test_once_modifier(self):
result = self._parse("click once")
assert result[0]["modifiers"]["once"] is True
def test_from_modifier(self):
result = self._parse("keyup from:#search")
assert result[0]["event"] == "keyup"
assert result[0]["modifiers"]["from"] == "#search"
def test_load_trigger(self):
result = self._parse("load")
assert result[0]["event"] == "load"
def test_intersect(self):
result = self._parse("intersect once")
assert result[0]["event"] == "intersect"
assert result[0]["modifiers"]["once"] is True
def test_delay_seconds(self):
result = self._parse("click delay:1s")
assert result[0]["modifiers"]["delay"] == 1000
# ---------------------------------------------------------------------------
# sx-params filtering tests
# ---------------------------------------------------------------------------
class TestParamsFiltering:
"""Test the sx-params parameter filtering logic."""
def _filter(self, params_spec: str, form_data: dict[str, str]) -> dict[str, str]:
fd_entries = json.dumps([[k, v] for k, v in form_data.items()])
out = _run_engine_js(f"""
var body = new URLSearchParams();
var entries = {fd_entries};
entries.forEach(function(p) {{ body.append(p[0], p[1]); }});
var paramsSpec = {json.dumps(params_spec)};
if (paramsSpec === "none") {{
body = new URLSearchParams();
}} else if (paramsSpec.indexOf("not ") === 0) {{
var excluded = paramsSpec.substring(4).split(",").map(function(s) {{ return s.trim(); }});
excluded.forEach(function(k) {{ body.delete(k); }});
}} else if (paramsSpec !== "*") {{
var allowed = paramsSpec.split(",").map(function(s) {{ return s.trim(); }});
var filtered = new URLSearchParams();
allowed.forEach(function(k) {{ body.getAll(k).forEach(function(v) {{ filtered.append(k, v); }}); }});
body = filtered;
}}
var result = {{}};
body.forEach(function(v, k) {{ result[k] = v; }});
process.stdout.write(JSON.stringify(result));
""")
return json.loads(out)
def test_all(self):
result = self._filter("*", {"a": "1", "b": "2"})
assert result == {"a": "1", "b": "2"}
def test_none(self):
result = self._filter("none", {"a": "1", "b": "2"})
assert result == {}
def test_include(self):
result = self._filter("name", {"name": "Alice", "secret": "123"})
assert result == {"name": "Alice"}
def test_include_multiple(self):
result = self._filter("name,email", {"name": "Alice", "email": "a@b.c", "secret": "123"})
assert "name" in result
assert "email" in result
assert "secret" not in result
def test_exclude(self):
result = self._filter("not secret", {"name": "Alice", "secret": "123", "email": "a@b.c"})
assert "name" in result
assert "email" in result
assert "secret" not in result
# ---------------------------------------------------------------------------
# _dispatchTriggerEvents parsing tests
# ---------------------------------------------------------------------------
class TestTriggerEventParsing:
"""Test SX-Trigger header value parsing."""
def _parse_trigger(self, header_val: str) -> list[dict]:
out = _run_engine_js(f"""
var events = [];
// Stub dispatch to capture events
function dispatch(el, name, detail) {{
events.push({{ name: name, detail: detail }});
return true;
}}
function _dispatchTriggerEvents(el, headerVal) {{
if (!headerVal) return;
try {{
var parsed = JSON.parse(headerVal);
if (typeof parsed === "object" && parsed !== null) {{
for (var evtName in parsed) dispatch(el, evtName, parsed[evtName]);
}} else {{
dispatch(el, String(parsed), {{}});
}}
}} catch (e) {{
headerVal.split(",").forEach(function(name) {{
var n = name.trim();
if (n) dispatch(el, n, {{}});
}});
}}
}}
_dispatchTriggerEvents(null, {json.dumps(header_val)});
process.stdout.write(JSON.stringify(events));
""")
return json.loads(out)
def test_plain_string(self):
events = self._parse_trigger("myEvent")
assert len(events) == 1
assert events[0]["name"] == "myEvent"
def test_comma_separated(self):
events = self._parse_trigger("eventA, eventB")
assert len(events) == 2
assert events[0]["name"] == "eventA"
assert events[1]["name"] == "eventB"
def test_json_object(self):
events = self._parse_trigger('{"myEvent": {"key": "val"}}')
assert len(events) == 1
assert events[0]["name"] == "myEvent"
assert events[0]["detail"]["key"] == "val"
def test_json_multiple(self):
events = self._parse_trigger('{"a": {}, "b": {"x": 1}}')
assert len(events) == 2
names = [e["name"] for e in events]
assert "a" in names
assert "b" in names
# ---------------------------------------------------------------------------
# _parseTime tests
# ---------------------------------------------------------------------------
class TestParseTime:
"""Test the time parsing utility."""
def _parse_time(self, s: str) -> int:
out = _run_engine_js(f"""
var _parseTime = function(s) {{
if (!s) return 0;
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
return parseInt(s, 10);
}};
process.stdout.write(String(_parseTime({json.dumps(s)})));
""")
return int(out)
def test_seconds(self):
assert self._parse_time("2s") == 2000
def test_milliseconds(self):
assert self._parse_time("500ms") == 500
def test_fractional_seconds(self):
assert self._parse_time("1.5s") == 1500
def test_plain_number(self):
assert self._parse_time("100") == 100
def test_empty(self):
assert self._parse_time("") == 0
# ---------------------------------------------------------------------------
# View Transition parsing tests
# ---------------------------------------------------------------------------
class TestSwapParsing:
"""Test sx-swap value parsing with transition modifier."""
def _parse_swap(self, raw_swap: str) -> dict:
out = _run_engine_js(f"""
var rawSwap = {json.dumps(raw_swap)};
var swapParts = rawSwap.split(/\\s+/);
var swapStyle = swapParts[0];
var useTransition = false;
for (var sp = 1; sp < swapParts.length; sp++) {{
if (swapParts[sp] === "transition:true") useTransition = true;
else if (swapParts[sp] === "transition:false") useTransition = false;
}}
process.stdout.write(JSON.stringify({{ style: swapStyle, transition: useTransition }}));
""")
return json.loads(out)
def test_plain_swap(self):
result = self._parse_swap("innerHTML")
assert result["style"] == "innerHTML"
assert result["transition"] is False
def test_transition_true(self):
result = self._parse_swap("innerHTML transition:true")
assert result["style"] == "innerHTML"
assert result["transition"] is True
def test_transition_false(self):
result = self._parse_swap("outerHTML transition:false")
assert result["style"] == "outerHTML"
assert result["transition"] is False

View File

@@ -15,14 +15,16 @@ from shared.sx.html import render as py_render
from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-test.js"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js in Node and return the renderToString result."""
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);

View File

@@ -240,9 +240,71 @@ class PageDef:
return f"<page:{self.name} path={self.path!r}>"
# ---------------------------------------------------------------------------
# QueryDef / ActionDef
# ---------------------------------------------------------------------------
@dataclass
class QueryDef:
"""A declarative data query defined in an .sx file.
Created by ``(defquery name (&key param...) "docstring" body)``.
The body is evaluated with async I/O primitives to produce JSON data.
"""
name: str
params: list[str] # keyword parameter names
doc: str # docstring
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<query:{self.name}({', '.join(self.params)})>"
@dataclass
class ActionDef:
"""A declarative action defined in an .sx file.
Created by ``(defaction name (&key param...) "docstring" body)``.
The body is evaluated with async I/O primitives to produce JSON data.
"""
name: str
params: list[str] # keyword parameter names
doc: str # docstring
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<action:{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# StyleValue
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class StyleValue:
"""A resolved CSS style produced by ``(css :flex :gap-4 :hover:bg-sky-200)``.
Generated by the style resolver. The renderer emits ``class_name`` as a
CSS class and registers the CSS rule for on-demand delivery.
"""
class_name: str # "sx-a3f2c1"
declarations: str # "display:flex;gap:1rem"
media_rules: tuple = () # ((query, decls), ...)
pseudo_rules: tuple = () # ((selector, decls), ...)
keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...)
def __repr__(self):
return f"<StyleValue {self.class_name}>"
def __str__(self):
return self.class_name
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None

View File

@@ -0,0 +1,477 @@
"""Integration tests for SX docs app — page rendering + interactive API endpoints.
Runs inside the test container, hitting the sx_docs service over the internal
network. Uses ``SX-Request: true`` header to bypass the silent-SSO OAuth
redirect on page requests.
Tested:
- All 27 example pages render with 200 and contain meaningful content
- All 23 attribute detail pages render and mention the attribute name
- All 35+ interactive API endpoints return 200 with expected content
"""
from __future__ import annotations
import os
import re
import httpx
import pytest
SX_BASE = os.environ.get("INTERNAL_URL_SX", "http://sx_docs:8000")
HEADERS = {"SX-Request": "true"}
TIMEOUT = 15.0
def _get(path: str, **kw) -> httpx.Response:
return httpx.get(
f"{SX_BASE}{path}",
headers=HEADERS,
timeout=TIMEOUT,
follow_redirects=True,
**kw,
)
def _post(path: str, **kw) -> httpx.Response:
return httpx.post(
f"{SX_BASE}{path}",
headers=HEADERS,
timeout=TIMEOUT,
follow_redirects=True,
**kw,
)
def _put(path: str, **kw) -> httpx.Response:
return httpx.put(
f"{SX_BASE}{path}",
headers=HEADERS,
timeout=TIMEOUT,
follow_redirects=True,
**kw,
)
def _patch(path: str, **kw) -> httpx.Response:
return httpx.patch(
f"{SX_BASE}{path}",
headers=HEADERS,
timeout=TIMEOUT,
follow_redirects=True,
**kw,
)
def _delete(path: str, **kw) -> httpx.Response:
return httpx.delete(
f"{SX_BASE}{path}",
headers=HEADERS,
timeout=TIMEOUT,
follow_redirects=True,
**kw,
)
# ── Check that the sx_docs service is reachable ──────────────────────────
def _sx_reachable() -> bool:
try:
r = httpx.get(f"{SX_BASE}/", timeout=5, follow_redirects=False)
return r.status_code in (200, 302)
except Exception:
return False
pytestmark = pytest.mark.skipif(
not _sx_reachable(),
reason=f"sx_docs service not reachable at {SX_BASE}",
)
# ═════════════════════════════════════════════════════════════════════════
# Example pages — rendering
# ═════════════════════════════════════════════════════════════════════════
EXAMPLES = [
"click-to-load",
"form-submission",
"polling",
"delete-row",
"inline-edit",
"oob-swaps",
"lazy-loading",
"infinite-scroll",
"progress-bar",
"active-search",
"inline-validation",
"value-select",
"reset-on-submit",
"edit-row",
"bulk-update",
"swap-positions",
"select-filter",
"tabs",
"animations",
"dialogs",
"keyboard-shortcuts",
"put-patch",
"json-encoding",
"vals-and-headers",
"loading-states",
"sync-replace",
"retry",
]
@pytest.mark.parametrize("slug", EXAMPLES)
def test_example_page_renders(slug: str):
"""Each example page must render successfully on two consecutive loads."""
for attempt in (1, 2):
r = _get(f"/examples/{slug}")
assert r.status_code == 200, (
f"/examples/{slug} returned {r.status_code} on attempt {attempt}"
)
assert len(r.text) > 500, (
f"/examples/{slug} response too short ({len(r.text)} bytes) on attempt {attempt}"
)
# Every example page should have a demo section
assert "demo" in r.text.lower() or "example" in r.text.lower(), (
f"/examples/{slug} missing demo/example content"
)
# ═════════════════════════════════════════════════════════════════════════
# Attribute detail pages — rendering
# ═════════════════════════════════════════════════════════════════════════
ATTRIBUTES = [
"sx-get",
"sx-post",
"sx-put",
"sx-delete",
"sx-patch",
"sx-trigger",
"sx-target",
"sx-swap",
"sx-swap-oob",
"sx-select",
"sx-confirm",
"sx-push-url",
"sx-sync",
"sx-encoding",
"sx-headers",
"sx-include",
"sx-vals",
"sx-media",
"sx-disable",
"sx-on", # URL slug for sx-on:*
"sx-boost",
"sx-preload",
"sx-preserve",
"sx-indicator",
"sx-validate",
"sx-ignore",
"sx-optimistic",
"sx-replace-url",
"sx-disabled-elt",
"sx-prompt",
"sx-params",
"sx-sse",
"sx-sse-swap",
"sx-retry",
"data-sx",
"data-sx-env",
]
@pytest.mark.parametrize("slug", ATTRIBUTES)
def test_attribute_page_renders(slug: str):
"""Each attribute page must render successfully on two consecutive loads."""
for attempt in (1, 2):
r = _get(f"/reference/attributes/{slug}")
assert r.status_code == 200, (
f"/reference/attributes/{slug} returned {r.status_code} on attempt {attempt}"
)
assert len(r.text) > 500, (
f"/reference/attributes/{slug} response too short on attempt {attempt}"
)
# The attribute name (or a prefix of it) should appear somewhere
check = slug.rstrip("*").rstrip(":")
assert check.lower() in r.text.lower(), (
f"/reference/attributes/{slug} does not mention '{check}'"
)
# ═════════════════════════════════════════════════════════════════════════
# Example API endpoints — interactive demos
# ═════════════════════════════════════════════════════════════════════════
class TestExampleAPIs:
"""Test the interactive demo API endpoints."""
def test_click_to_load(self):
r = _get("/examples/api/click")
assert r.status_code == 200
def test_form_submission(self):
r = _post("/examples/api/form", data={"name": "Alice"})
assert r.status_code == 200
assert "Alice" in r.text
def test_polling(self):
r = _get("/examples/api/poll")
assert r.status_code == 200
def test_delete_row(self):
r = _delete("/examples/api/delete/1")
assert r.status_code == 200
def test_inline_edit_get(self):
r = _get("/examples/api/edit")
assert r.status_code == 200
def test_inline_edit_post(self):
r = _post("/examples/api/edit", data={"name": "New Name"})
assert r.status_code == 200
def test_inline_edit_cancel(self):
r = _get("/examples/api/edit/cancel")
assert r.status_code == 200
def test_oob_swap(self):
r = _get("/examples/api/oob")
assert r.status_code == 200
def test_lazy_loading(self):
r = _get("/examples/api/lazy")
assert r.status_code == 200
def test_infinite_scroll(self):
r = _get("/examples/api/scroll", params={"page": "1"})
assert r.status_code == 200
def test_progress_start(self):
r = _post("/examples/api/progress/start")
assert r.status_code == 200
def test_progress_status(self):
r = _get("/examples/api/progress/status")
assert r.status_code == 200
def test_active_search(self):
r = _get("/examples/api/search", params={"q": "py"})
assert r.status_code == 200
assert "Python" in r.text
def test_inline_validation(self):
r = _get("/examples/api/validate", params={"email": "test@example.com"})
assert r.status_code == 200
def test_validation_submit(self):
r = _post("/examples/api/validate/submit", data={"email": "test@example.com"})
assert r.status_code == 200
def test_value_select(self):
r = _get("/examples/api/values", params={"category": "Languages"})
assert r.status_code == 200
def test_reset_on_submit(self):
r = _post("/examples/api/reset-submit", data={"message": "hello"})
assert r.status_code == 200
def test_edit_row_get(self):
r = _get("/examples/api/editrow/1")
assert r.status_code == 200
def test_edit_row_post(self):
r = _post("/examples/api/editrow/1", data={"name": "X", "price": "10", "stock": "5"})
assert r.status_code == 200
def test_edit_row_cancel(self):
r = _get("/examples/api/editrow/1/cancel")
assert r.status_code == 200
def test_bulk_update(self):
r = _post("/examples/api/bulk", data={"ids": ["1", "2"], "status": "active"})
assert r.status_code == 200
def test_swap_positions(self):
r = _post("/examples/api/swap-log")
assert r.status_code == 200
def test_dashboard_filter(self):
r = _get("/examples/api/dashboard", params={"region": "all"})
assert r.status_code == 200
def test_tabs(self):
r = _get("/examples/api/tabs/overview")
assert r.status_code == 200
def test_animate(self):
r = _get("/examples/api/animate")
assert r.status_code == 200
def test_dialog_open(self):
r = _get("/examples/api/dialog")
assert r.status_code == 200
def test_dialog_close(self):
r = _get("/examples/api/dialog/close")
assert r.status_code == 200
def test_keyboard(self):
r = _get("/examples/api/keyboard")
assert r.status_code == 200
def test_put_patch_edit(self):
r = _get("/examples/api/putpatch/edit-all")
assert r.status_code == 200
def test_put_request(self):
r = _put("/examples/api/putpatch", data={"name": "X", "email": "x@x.com", "role": "Dev"})
assert r.status_code == 200
def test_put_patch_cancel(self):
r = _get("/examples/api/putpatch/cancel")
assert r.status_code == 200
def test_json_encoding(self):
r = httpx.post(
f"{SX_BASE}/examples/api/json-echo",
content='{"key":"val"}',
headers={**HEADERS, "Content-Type": "application/json"},
timeout=TIMEOUT,
)
assert r.status_code == 200
def test_echo_vals(self):
r = _get("/examples/api/echo-vals", params={"source": "test"})
assert r.status_code == 200
def test_echo_headers(self):
r = httpx.get(
f"{SX_BASE}/examples/api/echo-headers",
headers={**HEADERS, "X-Custom": "hello"},
timeout=TIMEOUT,
)
assert r.status_code == 200
def test_slow_endpoint(self):
r = _get("/examples/api/slow")
assert r.status_code == 200
def test_slow_search(self):
r = _get("/examples/api/slow-search", params={"q": "test"})
assert r.status_code == 200
def test_flaky_endpoint(self):
# May fail 2/3 times — just check it returns *something*
r = _get("/examples/api/flaky")
assert r.status_code in (200, 503)
# ═════════════════════════════════════════════════════════════════════════
# Reference API endpoints — attribute demos
# ═════════════════════════════════════════════════════════════════════════
class TestReferenceAPIs:
"""Test the reference attribute demo API endpoints."""
def test_time(self):
r = _get("/reference/api/time")
assert r.status_code == 200
# Should contain a time string (HH:MM:SS pattern)
assert re.search(r"\d{2}:\d{2}:\d{2}", r.text), "No time found in response"
def test_greet(self):
r = _post("/reference/api/greet", data={"name": "Bob"})
assert r.status_code == 200
assert "Bob" in r.text
def test_status_put(self):
r = _put("/reference/api/status", data={"status": "published"})
assert r.status_code == 200
assert "published" in r.text.lower()
def test_theme_patch(self):
r = _patch("/reference/api/theme", data={"theme": "dark"})
assert r.status_code == 200
assert "dark" in r.text.lower()
def test_delete_item(self):
r = _delete("/reference/api/item/42")
assert r.status_code == 200
def test_trigger_search(self):
r = _get("/reference/api/trigger-search", params={"q": "hello"})
assert r.status_code == 200
assert "hello" in r.text.lower()
def test_swap_item(self):
r = _get("/reference/api/swap-item")
assert r.status_code == 200
def test_oob(self):
r = _get("/reference/api/oob")
assert r.status_code == 200
# OOB response should contain sx-swap-oob attribute
assert "oob" in r.text.lower()
def test_select_page(self):
r = _get("/reference/api/select-page")
assert r.status_code == 200
assert "the-content" in r.text
def test_slow_echo(self):
r = _get("/reference/api/slow-echo", params={"q": "sync"})
assert r.status_code == 200
assert "sync" in r.text.lower()
def test_upload_name(self):
r = _post(
"/reference/api/upload-name",
files={"file": ("test.txt", b"hello", "text/plain")},
)
assert r.status_code == 200
assert "test.txt" in r.text
def test_echo_headers(self):
r = httpx.get(
f"{SX_BASE}/reference/api/echo-headers",
headers={**HEADERS, "X-Custom-Token": "abc123"},
timeout=TIMEOUT,
)
assert r.status_code == 200
def test_echo_vals_get(self):
r = _get("/reference/api/echo-vals", params={"category": "books"})
assert r.status_code == 200
def test_echo_vals_post(self):
r = _post("/reference/api/echo-vals", data={"source": "demo", "page": "3"})
assert r.status_code == 200
def test_flaky(self):
r = _get("/reference/api/flaky")
assert r.status_code in (200, 503)
def test_prompt_echo(self):
r = httpx.get(
f"{SX_BASE}/reference/api/prompt-echo",
headers={**HEADERS, "SX-Prompt": "Alice"},
timeout=TIMEOUT,
)
assert r.status_code == 200
assert "Alice" in r.text
def test_sse_time(self):
"""SSE endpoint returns event-stream content type."""
with httpx.stream("GET", f"{SX_BASE}/reference/api/sse-time",
headers=HEADERS, timeout=TIMEOUT) as r:
assert r.status_code == 200
ct = r.headers.get("content-type", "")
assert "text/event-stream" in ct
# Read just the first chunk to verify format
for chunk in r.iter_text():
assert "event:" in chunk or "data:" in chunk
break # only need the first chunk