Merge branch 'macros' into worktree-sx-meta-eval
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
31
shared/contracts/likes.py
Normal 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: ...
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
592
shared/infrastructure/ap_social_sx.py
Normal file
592
shared/infrastructure/ap_social_sx.py
Normal 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}))'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
58
shared/infrastructure/protocol_manifest.py
Normal file
58
shared/infrastructure/protocol_manifest.py
Normal 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))
|
||||
127
shared/infrastructure/query_blueprint.py
Normal file
127
shared/infrastructure/query_blueprint.py
Normal 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
|
||||
31
shared/infrastructure/schema_blueprint.py
Normal file
31
shared/infrastructure/schema_blueprint.py
Normal 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
|
||||
42
shared/services/account_impl.py
Normal file
42
shared/services/account_impl.py
Normal 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()
|
||||
]
|
||||
37
shared/services/cart_items_impl.py
Normal file
37
shared/services/cart_items_impl.py
Normal 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()
|
||||
]
|
||||
110
shared/services/likes_impl.py
Normal file
110
shared/services/likes_impl.py
Normal 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
|
||||
55
shared/services/market_data_impl.py
Normal file
55
shared/services/market_data_impl.py
Normal 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
|
||||
]
|
||||
137
shared/services/page_config_impl.py
Normal file
137
shared/services/page_config_impl.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
164
shared/services/relations_impl.py
Normal file
164
shared/services/relations_impl.py
Normal 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}
|
||||
@@ -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";
|
||||
|
||||
292
shared/static/scripts/sx-test.js
Normal file
292
shared/static/scripts/sx-test.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); }
|
||||
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
70
shared/sx/query_executor.py
Normal file
70
shared/sx/query_executor.py
Normal 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
182
shared/sx/query_registry.py
Normal 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"]),
|
||||
}
|
||||
@@ -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
735
shared/sx/style_dict.py
Normal 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
254
shared/sx/style_resolver.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
394
shared/sx/tests/test_sx_engine.py
Normal file
394
shared/sx/tests/test_sx_engine.py
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
477
shared/tests/test_sx_app_pages.py
Normal file
477
shared/tests/test_sx_app_pages.py
Normal 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
|
||||
Reference in New Issue
Block a user