Compare commits
2 Commits
5578923242
...
e4e43177a8
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e43177a8 | |||
| 8445c36270 |
@@ -294,26 +294,49 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
|||||||
# Public API: Fragment pages
|
# Public API: Fragment pages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
|
||||||
"""Full page: fragment-provided content."""
|
"""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 = root_header_sx(ctx)
|
||||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||||
|
|
||||||
|
content = _fragment_content(page_fragment)
|
||||||
return full_page_sx(ctx, header_rows=header_rows,
|
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))
|
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."""
|
"""OOB response for fragment pages."""
|
||||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||||
|
|
||||||
|
content = _fragment_content(page_fragment)
|
||||||
return oob_page_sx(oobs=oobs,
|
return oob_page_sx(oobs=oobs,
|
||||||
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
|
content=content,
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
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)
|
# Public API: Auth pages (login, device)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,16 +3,17 @@
|
|||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
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.
|
``events/sx/handlers/`` and dispatched via the sx handler registry.
|
||||||
|
|
||||||
Jinja HTML handlers (container-cards, account-page) remain as Python
|
container-cards and account-page remain as Python handlers because they
|
||||||
because they return ``text/html`` templates, not sx source.
|
call domain service methods and return batched/conditional content, but
|
||||||
|
they use sx_call() for rendering (no Jinja templates).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
@@ -24,8 +25,8 @@ def register():
|
|||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
# Fragment types that return HTML (Jinja templates)
|
# Fragment types that return HTML (comment-delimited batch)
|
||||||
_html_types = {"container-cards", "account-page"}
|
_html_types = {"container-cards"}
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
@@ -34,7 +35,7 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
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)
|
handler = _handlers.get(fragment_type)
|
||||||
if handler is not None:
|
if handler is not None:
|
||||||
result = await handler()
|
result = await handler()
|
||||||
@@ -51,9 +52,13 @@ def register():
|
|||||||
|
|
||||||
return Response("", status=200, content_type="text/sx")
|
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():
|
async def _container_cards_handler():
|
||||||
|
from sx.sx_components import render_fragment_container_cards
|
||||||
|
|
||||||
post_ids_raw = request.args.get("post_ids", "")
|
post_ids_raw = request.args.get("post_ids", "")
|
||||||
post_slugs_raw = request.args.get("post_slugs", "")
|
post_slugs_raw = request.args.get("post_slugs", "")
|
||||||
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
|
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 ""
|
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
|
||||||
|
|
||||||
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
|
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
|
||||||
return await render_template(
|
return render_fragment_container_cards(batch, post_ids, slug_map)
|
||||||
"fragments/container_cards_entries.html",
|
|
||||||
batch=batch, post_ids=post_ids, slug_map=slug_map,
|
|
||||||
)
|
|
||||||
|
|
||||||
_handlers["container-cards"] = _container_cards_handler
|
_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():
|
async def _account_page_handler():
|
||||||
|
from sx.sx_components import (
|
||||||
|
render_fragment_account_tickets,
|
||||||
|
render_fragment_account_bookings,
|
||||||
|
)
|
||||||
|
|
||||||
slug = request.args.get("slug", "")
|
slug = request.args.get("slug", "")
|
||||||
user_id = request.args.get("user_id", type=int)
|
user_id = request.args.get("user_id", type=int)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -83,16 +91,10 @@ def register():
|
|||||||
|
|
||||||
if slug == "tickets":
|
if slug == "tickets":
|
||||||
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
|
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
|
||||||
return await render_template(
|
return render_fragment_account_tickets(tickets)
|
||||||
"fragments/account_page_tickets.html",
|
|
||||||
tickets=tickets,
|
|
||||||
)
|
|
||||||
elif slug == "bookings":
|
elif slug == "bookings":
|
||||||
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
|
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
|
||||||
return await render_template(
|
return render_fragment_account_bookings(bookings)
|
||||||
"fragments/account_page_bookings.html",
|
|
||||||
bookings=bookings,
|
|
||||||
)
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
_handlers["account-page"] = _account_page_handler
|
_handlers["account-page"] = _account_page_handler
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.calendar_name %}
|
|
||||||
<span>· {{ booking.calendar_name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.cost %}
|
|
||||||
<span>· £{{ 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>
|
|
||||||
@@ -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>· {{ ticket.calendar_name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if ticket.ticket_type_name %}
|
|
||||||
<span>· {{ 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>
|
|
||||||
@@ -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
249
sx/content/highlight.py
Normal 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}")'
|
||||||
@@ -25,13 +25,13 @@ def _full_page(ctx: dict, **kwargs) -> str:
|
|||||||
def _code(code: str, language: str = "lisp") -> str:
|
def _code(code: str, language: str = "lisp") -> str:
|
||||||
"""Build a ~doc-code component with highlighted content."""
|
"""Build a ~doc-code component with highlighted content."""
|
||||||
highlighted = highlight(code, language)
|
highlighted = highlight(code, language)
|
||||||
return f'(~doc-code {highlighted})'
|
return f'(~doc-code :code {highlighted})'
|
||||||
|
|
||||||
|
|
||||||
def _example_code(code: str) -> str:
|
def _example_code(code: str) -> str:
|
||||||
"""Build an ~example-source component with highlighted content."""
|
"""Build an ~example-source component with highlighted content."""
|
||||||
highlighted = highlight(code, "lisp")
|
highlighted = highlight(code, "lisp")
|
||||||
return f'(~example-source {highlighted})'
|
return f'(~example-source :code {highlighted})'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user