Delete account/sx/sx_components.py — all rendering now in .sx
Phase 1 of zero-Python rendering: account service. - Auth pages (login, device, check-email) use _render_auth_page() helper calling render_to_sx() + full_page_sx() directly in routes - Newsletter toggle POST renders inline via render_to_sx() - Newsletter page helper returns data dict; defpage :data slot fetches, :content slot renders via ~account-newsletters-content defcomp - Fragment page uses (frag ...) IO primitive directly in .sx - Defpage _eval_slot now uses async_eval_slot_to_sx which expands component bodies server-side (executing IO) but serializes tags as SX - Fix pre-existing OOB ParseError: _eval_slot was producing HTML instead of s-expressions for component content slots - Fix market url_for endpoint: defpage_market_home (app-level, not blueprint) - Fix events calendar nav: wrap multiple SX parts in fragment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
# Setup defpage routes
|
# Load .sx component files and setup defpage routes
|
||||||
import sx.sx_components # noqa: F811 — ensure components loaded
|
from shared.sx.jinja_bridge import load_service_components
|
||||||
|
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
|
||||||
from sxc.pages import setup_account_pages
|
from sxc.pages import setup_account_pages
|
||||||
setup_account_pages()
|
setup_account_pages()
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
request,
|
|
||||||
g,
|
g,
|
||||||
)
|
)
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from shared.models import UserNewsletter
|
from shared.models import UserNewsletter
|
||||||
from shared.infrastructure.fragments import fetch_fragments
|
from shared.infrastructure.fragments import fetch_fragments
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix="/"):
|
def register(url_prefix="/"):
|
||||||
@@ -55,7 +54,26 @@ def register(url_prefix="/"):
|
|||||||
|
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
from sx.sx_components import render_newsletter_toggle
|
# Render toggle directly — no sx_components intermediary
|
||||||
return sx_response(await render_newsletter_toggle(un))
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from shared.infrastructure.urls import account_url
|
||||||
|
|
||||||
|
nid = un.newsletter_id
|
||||||
|
url_fn = getattr(g, "_account_url", None) or account_url
|
||||||
|
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
|
||||||
|
translate = "translate-x-6" if un.subscribed else "translate-x-1"
|
||||||
|
checked = "true" if un.subscribed else "false"
|
||||||
|
|
||||||
|
return sx_response(await render_to_sx(
|
||||||
|
"account-newsletter-toggle",
|
||||||
|
id=f"nl-{nid}", url=toggle_url,
|
||||||
|
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||||
|
target=f"#nl-{nid}",
|
||||||
|
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||||
|
checked=checked,
|
||||||
|
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||||
|
))
|
||||||
|
|
||||||
return account_bp
|
return account_bp
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ from .services import (
|
|||||||
SESSION_USER_KEY = "uid"
|
SESSION_USER_KEY = "uid"
|
||||||
ACCOUNT_SESSION_KEY = "account_sid"
|
ACCOUNT_SESSION_KEY = "account_sid"
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
|
||||||
|
"""Render an auth page with root layout — replaces sx_components helpers."""
|
||||||
|
from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
ctx = await get_template_context()
|
||||||
|
hdr = await root_header_sx(ctx)
|
||||||
|
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
|
||||||
|
return await full_page_sx(ctx, header_rows=hdr, content=content,
|
||||||
|
meta_html=f"<title>{title}</title>")
|
||||||
|
|
||||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
||||||
|
|
||||||
|
|
||||||
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
|
|||||||
redirect_url = pop_login_redirect_target()
|
redirect_url = pop_login_redirect_target()
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
|
||||||
from sx.sx_components import render_login_page
|
|
||||||
ctx = await get_template_context()
|
|
||||||
return await render_login_page(ctx)
|
|
||||||
|
|
||||||
@rate_limit(
|
@rate_limit(
|
||||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||||
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
|
|||||||
|
|
||||||
is_valid, email = validate_email(email_input)
|
is_valid, email = validate_email(email_input)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_login_page
|
"account-login-content", "Login \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
error="Please enter a valid email address.", email=email_input,
|
||||||
return await render_login_page(ctx), 400
|
), 400
|
||||||
|
|
||||||
# Per-email rate limit: 5 magic links per 15 minutes
|
# Per-email rate limit: 5 magic links per 15 minutes
|
||||||
from shared.infrastructure.rate_limit import _check_rate_limit
|
from shared.infrastructure.rate_limit import _check_rate_limit
|
||||||
try:
|
try:
|
||||||
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_check_email_page
|
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(email=email, email_error=None)
|
email=email,
|
||||||
return await render_check_email_page(ctx), 200
|
), 200
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Redis down — allow the request
|
pass # Redis down — allow the request
|
||||||
|
|
||||||
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
|
|||||||
"Please try again in a moment."
|
"Please try again in a moment."
|
||||||
)
|
)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_check_email_page
|
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(email=email, email_error=email_error)
|
email=email, email_error=email_error,
|
||||||
return await render_check_email_page(ctx)
|
)
|
||||||
|
|
||||||
@auth_bp.get("/magic/<token>/")
|
@auth_bp.get("/magic/<token>/")
|
||||||
async def magic(token: str):
|
async def magic(token: str):
|
||||||
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
|
|||||||
user, error = await validate_magic_link(s, token)
|
user, error = await validate_magic_link(s, token)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_login_page
|
"account-login-content", "Login \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error=error)
|
error=error,
|
||||||
return await render_login_page(ctx), 400
|
), 400
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_login_page
|
"account-login-content", "Login \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
error="Could not sign you in right now. Please try again.",
|
||||||
return await render_login_page(ctx), 502
|
), 502
|
||||||
|
|
||||||
assert user_id is not None
|
assert user_id is not None
|
||||||
|
|
||||||
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
|
|||||||
@auth_bp.get("/device/")
|
@auth_bp.get("/device/")
|
||||||
async def device_form():
|
async def device_form():
|
||||||
"""Browser form where user enters the code displayed in terminal."""
|
"""Browser form where user enters the code displayed in terminal."""
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_device_page
|
|
||||||
code = request.args.get("code", "")
|
code = request.args.get("code", "")
|
||||||
ctx = await get_template_context(code=code)
|
return await _render_auth_page(
|
||||||
return await render_device_page(ctx)
|
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
|
||||||
@auth_bp.post("/device")
|
@auth_bp.post("/device")
|
||||||
@auth_bp.post("/device/")
|
@auth_bp.post("/device/")
|
||||||
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
|
|||||||
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
||||||
|
|
||||||
if not user_code or len(user_code) != 8:
|
if not user_code or len(user_code) != 8:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_device_page
|
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
|
error="Please enter a valid 8-character code.", code=form.get("code", ""),
|
||||||
return await render_device_page(ctx), 400
|
), 400
|
||||||
|
|
||||||
from shared.infrastructure.auth_redis import get_auth_redis
|
from shared.infrastructure.auth_redis import get_auth_redis
|
||||||
|
|
||||||
r = await get_auth_redis()
|
r = await get_auth_redis()
|
||||||
device_code = await r.get(f"devflow_uc:{user_code}")
|
device_code = await r.get(f"devflow_uc:{user_code}")
|
||||||
if not device_code:
|
if not device_code:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_device_page
|
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
|
error="Code not found or expired. Please try again.", code=form.get("code", ""),
|
||||||
return await render_device_page(ctx), 400
|
), 400
|
||||||
|
|
||||||
if isinstance(device_code, bytes):
|
if isinstance(device_code, bytes):
|
||||||
device_code = device_code.decode()
|
device_code = device_code.decode()
|
||||||
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
|
|||||||
# Logged in — approve immediately
|
# Logged in — approve immediately
|
||||||
ok = await _approve_device(device_code, g.user)
|
ok = await _approve_device(device_code, g.user)
|
||||||
if not ok:
|
if not ok:
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_device_page
|
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||||
ctx = await get_template_context(error="Code expired or already used.")
|
error="Code expired or already used.",
|
||||||
return await render_device_page(ctx), 400
|
), 400
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
return await _render_auth_page(
|
||||||
from sx.sx_components import render_device_approved_page
|
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||||
ctx = await get_template_context()
|
)
|
||||||
return await render_device_approved_page(ctx)
|
|
||||||
|
|
||||||
@auth_bp.get("/device/complete")
|
@auth_bp.get("/device/complete")
|
||||||
@auth_bp.get("/device/complete/")
|
@auth_bp.get("/device/complete/")
|
||||||
async def device_complete():
|
async def device_complete():
|
||||||
"""Post-login redirect — completes approval after magic link auth."""
|
"""Post-login redirect — completes approval after magic link auth."""
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_device_page, render_device_approved_page
|
|
||||||
|
|
||||||
device_code = request.args.get("code", "")
|
device_code = request.args.get("code", "")
|
||||||
|
|
||||||
if not device_code:
|
if not device_code:
|
||||||
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
|
|||||||
|
|
||||||
ok = await _approve_device(device_code, g.user)
|
ok = await _approve_device(device_code, g.user)
|
||||||
if not ok:
|
if not ok:
|
||||||
ctx = await get_template_context(
|
return await _render_auth_page(
|
||||||
|
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||||
)
|
), 400
|
||||||
return await render_device_page(ctx), 400
|
|
||||||
|
|
||||||
ctx = await get_template_context()
|
return await _render_auth_page(
|
||||||
return await render_device_approved_page(ctx)
|
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||||
|
)
|
||||||
|
|
||||||
return auth_bp
|
return auth_bp
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
"""
|
|
||||||
Account service s-expression page components.
|
|
||||||
|
|
||||||
Renders login, device auth, and check-email pages. Dashboard and newsletters
|
|
||||||
are now fully handled by .sx defcomps called from defpage expressions.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from shared.sx.jinja_bridge import load_service_components
|
|
||||||
from shared.sx.helpers import (
|
|
||||||
render_to_sx,
|
|
||||||
root_header_sx, full_page_sx,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load account-specific .sx components + handlers at import time
|
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
|
||||||
service_name="account")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API: Auth pages (login, device, check_email)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def render_login_page(ctx: dict) -> str:
|
|
||||||
"""Full page: login form."""
|
|
||||||
error = ctx.get("error", "")
|
|
||||||
email = ctx.get("email", "")
|
|
||||||
hdr = await root_header_sx(ctx)
|
|
||||||
content = await render_to_sx("account-login-content",
|
|
||||||
error=error or None, email=email)
|
|
||||||
return await full_page_sx(ctx, header_rows=hdr,
|
|
||||||
content=content,
|
|
||||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
|
||||||
|
|
||||||
|
|
||||||
async def render_device_page(ctx: dict) -> str:
|
|
||||||
"""Full page: device authorization form."""
|
|
||||||
error = ctx.get("error", "")
|
|
||||||
code = ctx.get("code", "")
|
|
||||||
hdr = await root_header_sx(ctx)
|
|
||||||
content = await render_to_sx("account-device-content",
|
|
||||||
error=error or None, code=code)
|
|
||||||
return await full_page_sx(ctx, header_rows=hdr,
|
|
||||||
content=content,
|
|
||||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
|
||||||
|
|
||||||
|
|
||||||
async def render_device_approved_page(ctx: dict) -> str:
|
|
||||||
"""Full page: device approved."""
|
|
||||||
hdr = await root_header_sx(ctx)
|
|
||||||
content = await render_to_sx("account-device-approved")
|
|
||||||
return await full_page_sx(ctx, header_rows=hdr,
|
|
||||||
content=content,
|
|
||||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
|
||||||
|
|
||||||
|
|
||||||
async def render_check_email_page(ctx: dict) -> str:
|
|
||||||
"""Full page: check email after magic link sent."""
|
|
||||||
email = ctx.get("email", "")
|
|
||||||
email_error = ctx.get("email_error")
|
|
||||||
hdr = await root_header_sx(ctx)
|
|
||||||
content = await render_to_sx("account-check-email-content",
|
|
||||||
email=email, email_error=email_error)
|
|
||||||
return await full_page_sx(ctx, header_rows=hdr,
|
|
||||||
content=content,
|
|
||||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API: Fragment renderers for POST handlers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def render_newsletter_toggle(un) -> str:
|
|
||||||
"""Render a newsletter toggle switch for POST response."""
|
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
|
||||||
|
|
||||||
nid = un.newsletter_id
|
|
||||||
from quart import g
|
|
||||||
account_url_fn = getattr(g, "_account_url", None)
|
|
||||||
if account_url_fn is None:
|
|
||||||
from shared.infrastructure.urls import account_url
|
|
||||||
account_url_fn = account_url
|
|
||||||
|
|
||||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
|
||||||
csrf = generate_csrf_token()
|
|
||||||
|
|
||||||
if un.subscribed:
|
|
||||||
bg = "bg-emerald-500"
|
|
||||||
translate = "translate-x-6"
|
|
||||||
checked = "true"
|
|
||||||
else:
|
|
||||||
bg = "bg-stone-300"
|
|
||||||
translate = "translate-x-1"
|
|
||||||
checked = "false"
|
|
||||||
|
|
||||||
return await render_to_sx(
|
|
||||||
"account-newsletter-toggle",
|
|
||||||
id=f"nl-{nid}", url=toggle_url,
|
|
||||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
|
||||||
target=f"#nl-{nid}",
|
|
||||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
|
||||||
checked=checked,
|
|
||||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
|
||||||
)
|
|
||||||
@@ -54,6 +54,7 @@ async def _account_oob(ctx: dict, **kw: Any) -> str:
|
|||||||
async def _account_mobile(ctx: dict, **kw: Any) -> str:
|
async def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||||
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
|
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
|
||||||
from shared.sx.parser import SxExpr
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
ctx = _inject_account_nav(ctx)
|
ctx = _inject_account_nav(ctx)
|
||||||
nav_items = await render_to_sx("auth-nav-items",
|
nav_items = await render_to_sx("auth-nav-items",
|
||||||
account_url=_call_url(ctx, "account_url", ""),
|
account_url=_call_url(ctx, "account_url", ""),
|
||||||
@@ -97,18 +98,16 @@ def _register_account_helpers() -> None:
|
|||||||
from shared.sx.pages import register_page_helpers
|
from shared.sx.pages import register_page_helpers
|
||||||
|
|
||||||
register_page_helpers("account", {
|
register_page_helpers("account", {
|
||||||
"newsletters-content": _h_newsletters_content,
|
"newsletters-data": _h_newsletters_data,
|
||||||
"fragment-content": _h_fragment_content,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async def _h_newsletters_content(**kw):
|
async def _h_newsletters_data(**kw):
|
||||||
"""Fetch newsletter data, return assembled defcomp call."""
|
"""Fetch newsletter data — returns dict merged into defpage env."""
|
||||||
from quart import g
|
from quart import g
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from shared.models import UserNewsletter
|
from shared.models import UserNewsletter
|
||||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||||
from shared.sx.helpers import render_to_sx
|
|
||||||
|
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||||
@@ -135,31 +134,6 @@ async def _h_newsletters_content(**kw):
|
|||||||
if account_url is None:
|
if account_url is None:
|
||||||
from shared.infrastructure.urls import account_url as _account_url
|
from shared.infrastructure.urls import account_url as _account_url
|
||||||
account_url = _account_url
|
account_url = _account_url
|
||||||
# Call account_url to get the base URL string
|
|
||||||
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
|
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
|
||||||
|
|
||||||
return await render_to_sx("account-newsletters-content",
|
return {"newsletter-list": newsletter_list, "account-url": account_url_str}
|
||||||
newsletter_list=newsletter_list,
|
|
||||||
account_url=account_url_str)
|
|
||||||
|
|
||||||
|
|
||||||
async def _h_fragment_content(slug=None, **kw):
|
|
||||||
from quart import g, abort
|
|
||||||
from shared.infrastructure.fragments import fetch_fragment
|
|
||||||
|
|
||||||
if not slug or not g.get("user"):
|
|
||||||
return ""
|
|
||||||
fragment_html = await fetch_fragment(
|
|
||||||
"events", "account-page",
|
|
||||||
params={"slug": slug, "user_id": str(g.user.id)},
|
|
||||||
)
|
|
||||||
if not fragment_html:
|
|
||||||
abort(404)
|
|
||||||
from shared.sx.parser import SxExpr
|
|
||||||
if isinstance(fragment_html, SxExpr):
|
|
||||||
return fragment_html.source
|
|
||||||
s = str(fragment_html) if fragment_html else ""
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
from shared.sx.helpers import render_to_sx
|
|
||||||
return await render_to_sx("rich-text", html=s)
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@
|
|||||||
:path "/newsletters/"
|
:path "/newsletters/"
|
||||||
:auth :login
|
:auth :login
|
||||||
:layout :account
|
:layout :account
|
||||||
:content (newsletters-content))
|
:data (newsletters-data)
|
||||||
|
:content (~account-newsletters-content
|
||||||
|
:newsletter-list newsletter-list
|
||||||
|
:account-url account-url))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; Fragment pages (tickets, bookings, etc. from events service)
|
;; Fragment pages (tickets, bookings, etc. from events service)
|
||||||
@@ -28,4 +31,10 @@
|
|||||||
:path "/<slug>/"
|
:path "/<slug>/"
|
||||||
:auth :login
|
:auth :login
|
||||||
:layout :account
|
:layout :account
|
||||||
:content (fragment-content slug))
|
:content (let* ((user (current-user))
|
||||||
|
(result (frag "events" "account-page"
|
||||||
|
:slug slug
|
||||||
|
:user-id (str (get user "id")))))
|
||||||
|
(if (or (nil? result) (empty? result))
|
||||||
|
(abort 404)
|
||||||
|
result)))
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ async def _calendar_nav_sx(ctx: dict) -> str:
|
|||||||
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
|
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
|
||||||
parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog",
|
parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog",
|
||||||
select_colours=select_colours))
|
select_colours=select_colours))
|
||||||
return "".join(parts)
|
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
|||||||
sub_div=SxExpr(sub_div) if sub_div else None,
|
sub_div=SxExpr(sub_div) if sub_div else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
link_href = url_for("market.browse.defpage_market_home")
|
link_href = url_for("defpage_market_home")
|
||||||
|
|
||||||
# Build desktop nav from categories
|
# Build desktop nav from categories
|
||||||
categories = ctx.get("categories", {})
|
categories = ctx.get("categories", {})
|
||||||
|
|||||||
@@ -910,6 +910,42 @@ async def async_eval_to_sx(
|
|||||||
return serialize(result)
|
return serialize(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.source
|
||||||
|
if result is None or result is NIL:
|
||||||
|
return ""
|
||||||
|
return serialize(result)
|
||||||
|
# Fall back to normal async_eval_to_sx
|
||||||
|
result = await _aser(expr, env, ctx)
|
||||||
|
if isinstance(result, SxExpr):
|
||||||
|
return result.source
|
||||||
|
if result is None or result is NIL:
|
||||||
|
return ""
|
||||||
|
return serialize(result)
|
||||||
|
|
||||||
|
|
||||||
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||||
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
|
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
|
||||||
for everything else."""
|
for everything else."""
|
||||||
@@ -1022,6 +1058,33 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
|
|||||||
return SxExpr("(<> " + " ".join(parts) + ")")
|
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 async_eval(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(
|
async def _aser_call(
|
||||||
name: str, args: list, env: dict, ctx: RequestContext,
|
name: str, args: list, env: dict, ctx: RequestContext,
|
||||||
) -> SxExpr:
|
) -> SxExpr:
|
||||||
|
|||||||
@@ -132,31 +132,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
|
|||||||
# Page execution
|
# Page execution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _eval_slot(expr: Any, env: dict, ctx: Any,
|
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||||
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
|
|
||||||
"""Evaluate a page slot expression and return an sx source string.
|
"""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
|
Expands component calls (so IO in the body executes) but serializes
|
||||||
builder), use it directly as sx source. If it evaluates to an AST/list,
|
the result as SX wire format, not HTML.
|
||||||
serialize it to sx wire format via async_eval_to_sx.
|
|
||||||
"""
|
"""
|
||||||
from .html import _RawHTML
|
from .async_eval import async_eval_slot_to_sx
|
||||||
from .parser import SxExpr
|
return await async_eval_slot_to_sx(expr, env, ctx)
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_page(
|
async def execute_page(
|
||||||
@@ -174,7 +157,7 @@ async def execute_page(
|
|||||||
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
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 .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 .page import get_template_context
|
||||||
from .helpers import full_page_sx, oob_page_sx, sx_response
|
from .helpers import full_page_sx, oob_page_sx, sx_response
|
||||||
from .layouts import get_layout
|
from .layouts import get_layout
|
||||||
@@ -204,20 +187,20 @@ async def execute_page(
|
|||||||
env.update(data_result)
|
env.update(data_result)
|
||||||
|
|
||||||
# Render content slot (required)
|
# 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
|
# Render optional slots
|
||||||
filter_sx = ""
|
filter_sx = ""
|
||||||
if page_def.filter_expr is not None:
|
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 = ""
|
aside_sx = ""
|
||||||
if page_def.aside_expr is not None:
|
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 = ""
|
menu_sx = ""
|
||||||
if page_def.menu_expr is not None:
|
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
|
# Resolve layout → header rows + mobile menu fallback
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
|
|||||||
Reference in New Issue
Block a user