2 Commits

Author SHA1 Message Date
e4e43177a8 Fix code blocks + add violet bg classes to tw.css
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- Pass :code keyword to ~doc-code and ~example-source components
  (highlighted content was positional but components use &key code)
- Rebuild tw.css (v3.4.19) with sx/sxc and sx/content in content paths
  so highlight.py classes (text-violet-600, text-rose-600, etc.) are included
- Add bg-violet-{100-500} classes for the sx app's violet menu bar
- Add highlight.py custom syntax highlighter (sx, python, bash)

IMPORTANT: tw.css must contain bg-violet-{100-500} rules for the sx
app's menu bar. Do not rebuild tw.css without ensuring violet classes
are included (via safelist or content paths).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:13:01 +00:00
8445c36270 Remove last Jinja fragment templates, use sx_components directly
Events fragment routes now call render_fragment_container_cards(),
render_fragment_account_tickets(), and render_fragment_account_bookings()
from sx_components instead of render_template(). Account sx_components
handles both SxExpr (text/sx) and HTML (text/html) fragment responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:07:02 +00:00
9 changed files with 303 additions and 173 deletions

View File

@@ -294,26 +294,49 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
# Public API: Fragment pages
# ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
"""Full page: fragment-provided content.
*page_fragment* may be sx source (from text/sx fragments wrapped in
SxExpr) or HTML (from text/html fragments). Sx source is embedded
directly; HTML is wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
content = _fragment_content(page_fragment)
return full_page_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
content=content,
menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
"""OOB response for fragment pages."""
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
content = _fragment_content(page_fragment)
return oob_page_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
content=content,
menu=_auth_nav_mobile_sx(ctx))
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------

View File

@@ -3,16 +3,17 @@
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Most handlers are defined declaratively in .sx files under
All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
Jinja HTML handlers (container-cards, account-page) remain as Python
because they return ``text/html`` templates, not sx source.
container-cards and account-page remain as Python handlers because they
call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
@@ -24,8 +25,8 @@ def register():
_handlers: dict[str, object] = {}
# Fragment types that return HTML (Jinja templates)
_html_types = {"container-cards", "account-page"}
# Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards"}
@bp.before_request
async def _require_fragment_header():
@@ -34,7 +35,7 @@ def register():
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first (Jinja HTML types)
# 1. Check Python handlers first
handler = _handlers.get(fragment_type)
if handler is not None:
result = await handler()
@@ -51,9 +52,13 @@ def register():
return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards (Jinja HTML) --
# --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
@@ -66,16 +71,19 @@ def register():
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return await render_template(
"fragments/container_cards_entries.html",
batch=batch, post_ids=post_ids, slug_map=slug_map,
)
return render_fragment_container_cards(batch, post_ids, slug_map)
_handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel (Jinja HTML) ------
# --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
@@ -83,16 +91,10 @@ def register():
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_tickets.html",
tickets=tickets,
)
return render_fragment_account_tickets(tickets)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_bookings.html",
bookings=bookings,
)
return render_fragment_account_bookings(bookings)
return ""
_handlers["account-page"] = _account_page_handler

View File

@@ -1,23 +0,0 @@
{# Account nav items: tickets + bookings links for the account dashboard #}
<div class="relative nav-group">
<a href="{{ account_url('/tickets/') }}"
sx-get="{{ account_url('/tickets/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
tickets
</a>
</div>
<div class="relative nav-group">
<a href="{{ account_url('/bookings/') }}"
sx-get="{{ account_url('/bookings/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
bookings
</a>
</div>

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,33 +0,0 @@
{# Calendar entries for blog listing cards — served as fragment from events app.
Each post's entries are delimited by comment markers so the consumer can
extract per-post HTML via simple string splitting. #}
{% for post_id in post_ids %}
<!-- card-widget:{{ post_id }} -->
{% set widget_entries = batch.get(post_id, []) %}
{% if widget_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _post_slug = slug_map.get(post_id, '') %}
{% set _entry_path = '/' + _post_slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- /card-widget:{{ post_id }} -->
{% endfor %}

File diff suppressed because one or more lines are too long

249
sx/content/highlight.py Normal file
View File

@@ -0,0 +1,249 @@
"""Syntax highlighting using Tailwind classes.
Produces sx source with coloured spans — no external CSS dependencies.
Showcases the on-demand CSS system.
"""
from __future__ import annotations
import re
def highlight_sx(code: str) -> str:
"""Highlight s-expression source code as sx with Tailwind spans."""
tokens = _tokenize_sx(code)
parts = []
for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string":
parts.append(f'(span :class "text-emerald-700" "{escaped}")')
elif kind == "keyword":
parts.append(f'(span :class "text-violet-600" "{escaped}")')
elif kind == "component":
parts.append(f'(span :class "text-rose-600 font-semibold" "{escaped}")')
elif kind == "special":
parts.append(f'(span :class "text-sky-700 font-semibold" "{escaped}")')
elif kind == "paren":
parts.append(f'(span :class "text-stone-400" "{escaped}")')
elif kind == "number":
parts.append(f'(span :class "text-amber-700" "{escaped}")')
elif kind == "boolean":
parts.append(f'(span :class "text-orange-600" "{escaped}")')
else:
parts.append(f'(span "{escaped}")')
return "(<> " + " ".join(parts) + ")"
_SX_SPECIALS = {
"defcomp", "defrelation", "define",
"if", "when", "cond", "case", "and", "or", "not",
"let", "let*", "lambda", "fn",
"do", "begin", "quote",
"->", "map", "filter", "reduce", "some", "every?",
"map-indexed", "for-each",
"&key", "&rest",
}
_SX_TOKEN_RE = re.compile(
r'(;[^\n]*)' # comment
r'|("(?:[^"\\]|\\.)*")' # string
r'|(:[a-zA-Z][\w?!-]*)' # keyword
r'|(~[\w-]+)' # component
r'|([()[\]{}])' # parens/brackets
r'|(\d+\.?\d*)' # number
r'|(true|false|nil)\b' # boolean/nil
r'|([\w?!+\-*/<>=&.]+)' # symbol
r'|(\s+)' # whitespace
r'|(.)' # other
)
def _tokenize_sx(code: str) -> list[tuple[str, str]]:
tokens = []
for m in _SX_TOKEN_RE.finditer(code):
if m.group(1):
tokens.append(("comment", m.group(1)))
elif m.group(2):
tokens.append(("string", m.group(2)))
elif m.group(3):
tokens.append(("keyword", m.group(3)))
elif m.group(4):
tokens.append(("component", m.group(4)))
elif m.group(5):
tokens.append(("paren", m.group(5)))
elif m.group(6):
tokens.append(("number", m.group(6)))
elif m.group(7):
tokens.append(("boolean", m.group(7)))
elif m.group(8):
text = m.group(8)
if text in _SX_SPECIALS:
tokens.append(("special", text))
else:
tokens.append(("symbol", text))
elif m.group(9):
tokens.append(("ws", m.group(9)))
else:
tokens.append(("other", m.group(10)))
return tokens
def highlight_python(code: str) -> str:
"""Highlight Python source code as sx with Tailwind spans."""
tokens = _tokenize_python(code)
parts = []
for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string":
parts.append(f'(span :class "text-emerald-700" "{escaped}")')
elif kind == "keyword":
parts.append(f'(span :class "text-violet-600 font-semibold" "{escaped}")')
elif kind == "builtin":
parts.append(f'(span :class "text-sky-700" "{escaped}")')
elif kind == "decorator":
parts.append(f'(span :class "text-amber-600" "{escaped}")')
elif kind == "number":
parts.append(f'(span :class "text-amber-700" "{escaped}")')
else:
parts.append(f'(span "{escaped}")')
return "(<> " + " ".join(parts) + ")"
_PY_KEYWORDS = {
"False", "None", "True", "and", "as", "assert", "async", "await",
"break", "class", "continue", "def", "del", "elif", "else", "except",
"finally", "for", "from", "global", "if", "import", "in", "is",
"lambda", "nonlocal", "not", "or", "pass", "raise", "return",
"try", "while", "with", "yield",
}
_PY_BUILTINS = {
"print", "len", "range", "str", "int", "float", "list", "dict",
"set", "tuple", "type", "isinstance", "getattr", "setattr", "hasattr",
"super", "property", "staticmethod", "classmethod", "enumerate", "zip",
"map", "filter", "sorted", "reversed", "any", "all", "min", "max",
"abs", "sum", "open", "input", "format", "repr", "id", "hash",
}
_PY_TOKEN_RE = re.compile(
r'(#[^\n]*)' # comment
r'|("""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\')' # triple-quoted string
r'|("(?:[^"\\]|\\.)*")' # double-quoted string
r"|('(?:[^'\\]|\\.)*')" # single-quoted string
r'|(@\w+)' # decorator
r'|(\d+\.?\d*(?:e[+-]?\d+)?)' # number
r'|([a-zA-Z_]\w*)' # identifier
r'|(\s+)' # whitespace
r'|(.)' # other
)
def _tokenize_python(code: str) -> list[tuple[str, str]]:
tokens = []
for m in _PY_TOKEN_RE.finditer(code):
if m.group(1):
tokens.append(("comment", m.group(1)))
elif m.group(2):
tokens.append(("string", m.group(2)))
elif m.group(3):
tokens.append(("string", m.group(3)))
elif m.group(4):
tokens.append(("string", m.group(4)))
elif m.group(5):
tokens.append(("decorator", m.group(5)))
elif m.group(6):
tokens.append(("number", m.group(6)))
elif m.group(7):
text = m.group(7)
if text in _PY_KEYWORDS:
tokens.append(("keyword", text))
elif text in _PY_BUILTINS:
tokens.append(("builtin", text))
else:
tokens.append(("ident", text))
elif m.group(8):
tokens.append(("ws", m.group(8)))
else:
tokens.append(("other", m.group(9)))
return tokens
def highlight_bash(code: str) -> str:
"""Highlight bash source code as sx with Tailwind spans."""
tokens = _tokenize_bash(code)
parts = []
for kind, text in tokens:
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
if kind == "comment":
parts.append(f'(span :class "text-stone-400 italic" "{escaped}")')
elif kind == "string":
parts.append(f'(span :class "text-emerald-700" "{escaped}")')
elif kind == "variable":
parts.append(f'(span :class "text-violet-600" "{escaped}")')
elif kind == "keyword":
parts.append(f'(span :class "text-sky-700 font-semibold" "{escaped}")')
elif kind == "flag":
parts.append(f'(span :class "text-amber-700" "{escaped}")')
else:
parts.append(f'(span "{escaped}")')
return "(<> " + " ".join(parts) + ")"
_BASH_KEYWORDS = {
"if", "then", "else", "elif", "fi", "for", "while", "do", "done",
"case", "esac", "in", "function", "return", "exit", "export",
"source", "local", "readonly", "declare", "set", "unset",
}
_BASH_TOKEN_RE = re.compile(
r'(#[^\n]*)' # comment
r'|("(?:[^"\\]|\\.)*")' # double-quoted string
r"|('(?:[^'\\]|\\.)*')" # single-quoted string
r'|(\$\{?\w+\}?|\$\()' # variable
r'|(--?[\w-]+)' # flag
r'|([a-zA-Z_][\w-]*)' # word
r'|(\s+)' # whitespace
r'|(.)' # other
)
def _tokenize_bash(code: str) -> list[tuple[str, str]]:
tokens = []
for m in _BASH_TOKEN_RE.finditer(code):
if m.group(1):
tokens.append(("comment", m.group(1)))
elif m.group(2):
tokens.append(("string", m.group(2)))
elif m.group(3):
tokens.append(("string", m.group(3)))
elif m.group(4):
tokens.append(("variable", m.group(4)))
elif m.group(5):
tokens.append(("flag", m.group(5)))
elif m.group(6):
text = m.group(6)
if text in _BASH_KEYWORDS:
tokens.append(("keyword", text))
else:
tokens.append(("word", text))
elif m.group(7):
tokens.append(("ws", m.group(7)))
else:
tokens.append(("other", m.group(8)))
return tokens
def highlight(code: str, language: str = "lisp") -> str:
"""Highlight code in the given language. Returns sx source."""
if language in ("lisp", "sx", "sexp"):
return highlight_sx(code)
elif language in ("python", "py"):
return highlight_python(code)
elif language in ("bash", "sh", "shell"):
return highlight_bash(code)
# Fallback: no highlighting, just escaped text
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
return f'(span "{escaped}")'

View File

@@ -25,13 +25,13 @@ def _full_page(ctx: dict, **kwargs) -> str:
def _code(code: str, language: str = "lisp") -> str:
"""Build a ~doc-code component with highlighted content."""
highlighted = highlight(code, language)
return f'(~doc-code {highlighted})'
return f'(~doc-code :code {highlighted})'
def _example_code(code: str) -> str:
"""Build an ~example-source component with highlighted content."""
highlighted = highlight(code, "lisp")
return f'(~example-source {highlighted})'
return f'(~example-source :code {highlighted})'
# ---------------------------------------------------------------------------