From 1d59023571848ded526ca7f3840201ff0979bf85 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 02:30:46 +0000 Subject: [PATCH 01/24] Move events composition from Python to .sx defcomps (Phase 9) Convert all 14 events page helpers from returning sx_call() strings to returning data dicts. Defpage expressions compose SX components with data bindings using map/fn/if/when. Complex sub-panels (entry tickets config, buy form, posts panel, options buttons, entry nav menu) returned as SxExpr from existing render functions which remain for HTMX handler use. Co-Authored-By: Claude Opus 4.6 --- events/sxc/pages/events.sx | 178 +++++++++- events/sxc/pages/helpers.py | 689 +++++++++++++++++++++++++++++++----- 2 files changed, 767 insertions(+), 100 deletions(-) diff --git a/events/sxc/pages/events.sx b/events/sxc/pages/events.sx index 6ad48b0..130e693 100644 --- a/events/sxc/pages/events.sx +++ b/events/sxc/pages/events.sx @@ -1,89 +1,235 @@ ;; Events pages — auto-mounted with absolute paths +;; All helpers return data dicts — markup composition in SX. ;; Calendar admin (defpage calendar-admin :path "///admin/" :auth :admin :layout :events-calendar-admin - :content (calendar-admin-content calendar-slug)) + :data (calendar-admin-data calendar-slug) + :content (~events-calendar-admin-panel + :description-content (~events-calendar-description-display + :description cal-description :edit-url desc-edit-url) + :csrf csrf :description cal-description)) ;; Day admin (defpage day-admin :path "///day////admin/" :auth :admin :layout :events-day-admin - :content (day-admin-content calendar-slug year month day)) + :data (day-admin-data calendar-slug year month day) + :content (~events-day-admin-panel)) ;; Slots listing (defpage slots-listing :path "///slots/" :auth :public :layout :events-slots - :content (slots-content calendar-slug)) + :data (slots-data calendar-slug) + :content (~events-slots-table + :list-container list-container + :rows (if has-slots + (<> (map (fn (s) + (~events-slots-row + :tr-cls tr-cls :slot-href (get s "slot-href") + :pill-cls pill-cls :hx-select hx-select + :slot-name (get s "name") :description (get s "description") + :flexible (get s "flexible") + :days (if (get s "has-days") + (~events-slot-days-pills :days-inner + (<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list")))) + (~events-slot-no-days)) + :time-str (get s "time-str") + :cost-str (get s "cost-str") :action-btn action-btn + :del-url (get s "del-url") + :csrf-hdr csrf-hdr)) + slots-list)) + (~events-slots-empty-row)) + :pre-action pre-action :add-url add-url)) ;; Slot detail (defpage slot-detail :path "///slots//" :auth :admin :layout :events-slot - :content (slot-content calendar-slug slot-id)) + :data (slot-data calendar-slug slot-id) + :content (~events-slot-panel + :slot-id slot-id-str + :list-container list-container + :days (if has-days + (~events-slot-days-pills :days-inner + (<> (map (fn (d) (~events-slot-day-pill :day d)) day-list))) + (~events-slot-no-days)) + :flexible flexible + :time-str time-str :cost-str cost-str + :pre-action pre-action :edit-url edit-url)) ;; Entry detail (defpage entry-detail :path "///day////entries//" :auth :admin :layout :events-entry - :content (entry-content calendar-slug entry-id) - :menu (entry-menu calendar-slug entry-id)) + :data (entry-data calendar-slug entry-id) + :content (~events-entry-panel + :entry-id entry-id-str :list-container list-container + :name (~events-entry-field :label "Name" + :content (~events-entry-name-field :name entry-name)) + :slot (~events-entry-field :label "Slot" + :content (if has-slot + (~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label) + (~events-entry-slot-none))) + :time (~events-entry-field :label "Time Period" + :content (~events-entry-time-field :time-str time-str)) + :state (~events-entry-field :label "State" + :content (~events-entry-state-field :entry-id entry-id-str + :badge (~badge :cls state-badge-cls :label state-badge-label))) + :cost (~events-entry-field :label "Cost" + :content (~events-entry-cost-field :cost cost-str)) + :tickets (~events-entry-field :label "Tickets" + :content (~events-entry-tickets-field :entry-id entry-id-str + :tickets-config tickets-config)) + :buy buy-form + :date (~events-entry-field :label "Date" + :content (~events-entry-date-field :date-str date-str)) + :posts (~events-entry-field :label "Associated Posts" + :content (~events-entry-posts-field :entry-id entry-id-str + :posts-panel posts-panel)) + :options options-html + :pre-action pre-action :edit-url edit-url) + :menu entry-menu) ;; Entry admin (defpage entry-admin :path "///day////entries//admin/" :auth :admin :layout :events-entry-admin - :content (entry-admin-content calendar-slug entry-id) - :menu (admin-menu)) + :data (entry-admin-data calendar-slug entry-id year month day) + :content (~nav-link :href ticket-types-href :label "ticket_types" + :select-colours select-colours :aclass nav-btn :is-selected false) + :menu (~events-admin-placeholder-nav)) ;; Ticket types listing (defpage ticket-types-listing :path "///day////entries//ticket-types/" :auth :public :layout :events-ticket-types - :content (ticket-types-content calendar-slug entry-id year month day) - :menu (admin-menu)) + :data (ticket-types-data calendar-slug entry-id year month day) + :content (~events-ticket-types-table + :list-container list-container + :rows (if has-types + (<> (map (fn (tt) + (~events-ticket-types-row + :tr-cls tr-cls :tt-href (get tt "tt-href") + :pill-cls pill-cls :hx-select hx-select + :tt-name (get tt "tt-name") :cost-str (get tt "cost-str") + :count (get tt "count") :action-btn action-btn + :del-url (get tt "del-url") + :csrf-hdr csrf-hdr)) + types-list)) + (~events-ticket-types-empty-row)) + :action-btn action-btn :add-url add-url) + :menu (~events-admin-placeholder-nav)) ;; Ticket type detail (defpage ticket-type-detail :path "///day////entries//ticket-types//" :auth :admin :layout :events-ticket-type - :content (ticket-type-content calendar-slug entry-id ticket-type-id year month day) - :menu (admin-menu)) + :data (ticket-type-data calendar-slug entry-id ticket-type-id year month day) + :content (~events-ticket-type-panel + :ticket-id ticket-id :list-container list-container + :c1 (~events-ticket-type-col :label "Name" :value tt-name) + :c2 (~events-ticket-type-col :label "Cost" :value cost-str) + :c3 (~events-ticket-type-col :label "Count" :value count-str) + :pre-action pre-action :edit-url edit-url) + :menu (~events-admin-placeholder-nav)) ;; My tickets (defpage my-tickets :path "/tickets/" :auth :public :layout :root - :content (tickets-content)) + :data (tickets-data) + :content (~events-tickets-panel + :list-container list-container + :has-tickets has-tickets + :cards (when has-tickets + (<> (map (fn (t) + (~events-ticket-card + :href (get t "href") :entry-name (get t "entry-name") + :type-name (get t "type-name") :time-str (get t "time-str") + :cal-name (get t "cal-name") + :badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) + :code-prefix (get t "code-prefix"))) + tickets-list))))) ;; Ticket detail (defpage ticket-detail :path "/tickets//" :auth :public :layout :root - :content (ticket-detail-content code)) + :data (ticket-detail-data code) + :content (~events-ticket-detail + :list-container list-container :back-href back-href + :header-bg header-bg :entry-name entry-name + :badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls) + badge-label) + :type-name type-name :code ticket-code + :time-date time-date :time-range time-range + :cal-name cal-name :type-desc type-desc :checkin-str checkin-str + :qr-script qr-script)) ;; Ticket admin dashboard (defpage ticket-admin :path "/admin/tickets/" :auth :admin :layout :root - :content (ticket-admin-content)) + :data (ticket-admin-data) + :content (~events-ticket-admin-panel + :list-container list-container + :stats (<> (map (fn (s) + (~events-ticket-admin-stat + :border (get s "border") :bg (get s "bg") + :text-cls (get s "text-cls") :label-cls (get s "label-cls") + :value (get s "value") :label (get s "label"))) + admin-stats)) + :lookup-url lookup-url :has-tickets has-tickets + :rows (when has-tickets + (<> (map (fn (t) + (~events-ticket-admin-row + :code (get t "code") :code-short (get t "code-short") + :entry-name (get t "entry-name") + :date (when (get t "date-str") + (~events-ticket-admin-date :date-str (get t "date-str"))) + :type-name (get t "type-name") + :badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) + :action (if (get t "can-checkin") + (~events-ticket-admin-checkin-form + :checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf) + (when (get t "is-checked-in") + (~events-ticket-admin-checked-in :time-str (get t "checkin-time")))))) + admin-tickets))))) ;; Markets (defpage events-markets :path "//markets/" :auth :public :layout :events-markets - :content (markets-content)) + :data (markets-data) + :content (~crud-panel + :list-id "markets-list" + :form (when can-create + (~crud-create-form :create-url create-url :csrf csrf + :errors-id "market-create-errors" :list-id "markets-list" + :placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market")) + :list (if markets-list + (<> (map (fn (m) + (~crud-item :href (get m "href") :name (get m "name") + :slug (get m "slug") :del-url (get m "del-url") + :csrf-hdr (get m "csrf-hdr") + :list-id "markets-list" + :confirm-title "Delete market?" + :confirm-text "Products will be hidden (soft delete)")) + markets-list)) + (~empty-state :message "No markets yet. Create one above." + :cls "text-gray-500 mt-4")))) diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index 2925ca8..be5137d 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -1,26 +1,13 @@ -"""Layout registrations, page helpers, and shared hydration helpers.""" +"""Layout registrations, page helpers, and shared hydration helpers. + +All helpers return data dicts — no sx_call(). +Markup composition lives entirely in .sx defpage and .sx defcomp files. +""" from __future__ import annotations from typing import Any -from shared.sx.helpers import sx_call - -from .calendar import ( - _calendar_admin_main_panel_html, - _day_admin_main_panel_html, - _markets_main_panel_html, -) -from .entries import ( - _entry_main_panel_html, - _entry_nav_html, - _entry_admin_main_panel_html, -) -from .tickets import ( - _tickets_main_panel_html, _ticket_detail_panel_html, - _ticket_admin_main_panel_html, - render_ticket_type_main_panel, render_ticket_types_table, -) -from .slots import render_slot_main_panel, render_slots_table +from shared.sx.parser import SxExpr # --------------------------------------------------------------------------- @@ -261,6 +248,60 @@ def _register_events_layouts() -> None: "events-markets-layout-full", "events-markets-layout-oob") +# --------------------------------------------------------------------------- +# Badge data helpers +# --------------------------------------------------------------------------- + +_ENTRY_STATE_CLASSES = { + "confirmed": "bg-emerald-100 text-emerald-800", + "provisional": "bg-amber-100 text-amber-800", + "ordered": "bg-sky-100 text-sky-800", + "pending": "bg-stone-100 text-stone-700", + "declined": "bg-red-100 text-red-800", +} + +_TICKET_STATE_CLASSES = { + "confirmed": "bg-emerald-100 text-emerald-800", + "checked_in": "bg-blue-100 text-blue-800", + "reserved": "bg-amber-100 text-amber-800", + "cancelled": "bg-red-100 text-red-800", +} + + +def _entry_badge_data(state: str) -> dict: + cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700") + label = state.replace("_", " ").capitalize() + return {"cls": cls, "label": label} + + +def _ticket_badge_data(state: str) -> dict: + cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700") + label = (state or "").replace("_", " ").capitalize() + return {"cls": cls, "label": label} + + +# --------------------------------------------------------------------------- +# Styles helper +# --------------------------------------------------------------------------- + +def _styles_data() -> dict: + """Extract common style classes from g.styles.""" + from quart import g + styles = getattr(g, "styles", None) or {} + + def _gs(attr): + return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "") + + return { + "list-container": _gs("list_container"), + "pre-action": _gs("pre_action_button"), + "action-btn": _gs("action_button"), + "tr-cls": _gs("tr"), + "pill-cls": _gs("pill"), + "nav-btn": _gs("nav_button"), + } + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -269,141 +310,468 @@ def _register_events_helpers() -> None: from shared.sx.pages import register_page_helpers register_page_helpers("events", { - "calendar-admin-content": _h_calendar_admin_content, - "day-admin-content": _h_day_admin_content, - "slots-content": _h_slots_content, - "slot-content": _h_slot_content, - "entry-content": _h_entry_content, - "entry-menu": _h_entry_menu, - "entry-admin-content": _h_entry_admin_content, - "admin-menu": _h_admin_menu, - "ticket-types-content": _h_ticket_types_content, - "ticket-type-content": _h_ticket_type_content, - "tickets-content": _h_tickets_content, - "ticket-detail-content": _h_ticket_detail_content, - "ticket-admin-content": _h_ticket_admin_content, - "markets-content": _h_markets_content, + "calendar-admin-data": _h_calendar_admin_data, + "day-admin-data": _h_day_admin_data, + "slots-data": _h_slots_data, + "slot-data": _h_slot_data, + "entry-data": _h_entry_data, + "entry-admin-data": _h_entry_admin_data, + "ticket-types-data": _h_ticket_types_data, + "ticket-type-data": _h_ticket_type_data, + "tickets-data": _h_tickets_data, + "ticket-detail-data": _h_ticket_detail_data, + "ticket-admin-data": _h_ticket_admin_data, + "markets-data": _h_markets_data, }) -async def _h_calendar_admin_content(calendar_slug=None, **kw): +# --------------------------------------------------------------------------- +# Calendar admin +# --------------------------------------------------------------------------- + +async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict: + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _calendar_admin_main_panel_html(ctx) + + from quart import g + calendar = getattr(g, "calendar", None) + if not calendar: + return {} + + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + desc = getattr(calendar, "description", "") or "" + desc_edit_url = url_for("calendar.admin.calendar_description_edit", + calendar_slug=cal_slug) + + return { + "cal-description": desc, + "csrf": csrf, + "desc-edit-url": desc_edit_url, + } -async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw): +# --------------------------------------------------------------------------- +# Day admin +# --------------------------------------------------------------------------- + +async def _h_day_admin_data(calendar_slug=None, year=None, month=None, + day=None, **kw) -> dict: await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() if year is not None: await _ensure_day_data(int(year), int(month), int(day)) - return _day_admin_main_panel_html({}) + return {} -async def _h_slots_content(calendar_slug=None, **kw): - from quart import g +# --------------------------------------------------------------------------- +# Slots listing +# --------------------------------------------------------------------------- + +async def _h_slots_data(calendar_slug=None, **kw) -> dict: + from quart import g, url_for + from shared.browser.app.csrf import generate_csrf_token + from bp.slots.services.slots import list_slots as svc_list_slots + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() + calendar = getattr(g, "calendar", None) - from bp.slots.services.slots import list_slots as svc_list_slots slots = await svc_list_slots(g.s, calendar.id) if calendar else [] _add_to_defpage_ctx(slots=slots) - return render_slots_table(slots, calendar) + + styles = _styles_data() + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug) + + slots_list = [] + for s in slots: + slot_href = url_for("defpage_slot_detail", + calendar_slug=cal_slug, slot_id=s.id) + del_url = url_for("calendar.slots.slot.slot_delete", + calendar_slug=cal_slug, slot_id=s.id) + desc = getattr(s, "description", "") or "" + days_display = getattr(s, "days_display", "\u2014") + day_list = days_display.split(", ") + has_days = bool(day_list and day_list[0] != "\u2014") + time_start = s.time_start.strftime("%H:%M") if s.time_start else "" + time_end = s.time_end.strftime("%H:%M") if s.time_end else "" + cost = getattr(s, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + + slots_list.append({ + "name": s.name, + "description": desc, + "day-list": day_list if has_days else [], + "has-days": has_days, + "flexible": "yes" if s.flexible else "no", + "time-str": f"{time_start} - {time_end}", + "cost-str": cost_str, + "slot-href": slot_href, + "del-url": del_url, + }) + + return { + "has-slots": bool(slots), + "slots-list": slots_list, + "add-url": add_url, + "csrf-hdr": csrf_hdr, + "hx-select": hx_select, + **styles, + } -async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Slot detail +# --------------------------------------------------------------------------- + +async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict: + from quart import g, abort, url_for + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() + from bp.slot.services.slot import get_slot as svc_get_slot slot = await svc_get_slot(g.s, slot_id) if slot_id else None if not slot: abort(404) g.slot = slot _add_to_defpage_ctx(slot=slot) + calendar = getattr(g, "calendar", None) - return render_slot_main_panel(slot, calendar) + styles = _styles_data() + cal_slug = getattr(calendar, "slug", "") + + days_display = getattr(slot, "days_display", "\u2014") + day_list = days_display.split(", ") + has_days = bool(day_list and day_list[0] != "\u2014") + time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" + time_end = slot.time_end.strftime("%H:%M") if slot.time_end else "" + cost = getattr(slot, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + edit_url = url_for("calendar.slots.slot.get_edit", + slot_id=slot.id, calendar_slug=cal_slug) + + return { + "slot-id-str": str(slot.id), + "day-list": day_list if has_days else [], + "has-days": has_days, + "flexible": "yes" if getattr(slot, "flexible", False) else "no", + "time-str": f"{time_start} \u2014 {time_end}", + "cost-str": cost_str, + "edit-url": edit_url, + **styles, + } -async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): +# --------------------------------------------------------------------------- +# Entry detail (complex — sub-panels returned as SxExpr) +# --------------------------------------------------------------------------- + +async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict: + from quart import url_for, g + from .entries import ( + _entry_nav_html, + _entry_options_html, + render_entry_tickets_config, + render_entry_posts_panel, + ) + from .tickets import render_buy_form + await _ensure_calendar(calendar_slug) await _ensure_entry_context(entry_id) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _entry_main_panel_html(ctx) + + entry = ctx.get("entry") + if not entry: + return {} + + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") if calendar else "" + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + + styles = _styles_data() + eid = entry.id + state = getattr(entry, "state", "pending") or "pending" + + # Simple field data + slot = getattr(entry, "slot", None) + has_slot = slot is not None + slot_name = slot.name if slot else "" + flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)" + start_str = entry.start_at.strftime("%H:%M") if entry.start_at else "" + end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended" + cost = getattr(entry, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else "" + badge = _entry_badge_data(state) + + edit_url = url_for( + "calendar.day.calendar_entries.calendar_entry.get_edit", + entry_id=eid, calendar_slug=cal_slug, + day=day, month=month, year=year, + ) + + # Complex sub-panels (pre-composed as SxExpr) + ticket_remaining = ctx.get("ticket_remaining") + ticket_sold_count = ctx.get("ticket_sold_count", 0) + user_ticket_count = ctx.get("user_ticket_count", 0) + user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {} + entry_posts = ctx.get("entry_posts") or [] + + tickets_config = render_entry_tickets_config(entry, calendar, day, month, year) + buy_form = render_buy_form( + entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, + ) + posts_panel = render_entry_posts_panel( + entry_posts, entry, calendar, day, month, year, + ) + options_html = _entry_options_html(entry, calendar, day, month, year) + + # Entry menu (pre-composed for :menu slot) + entry_menu = _entry_nav_html(ctx) + + return { + "entry-id-str": str(eid), + "entry-name": entry.name, + "has-slot": has_slot, + "slot-name": slot_name, + "flex-label": flex_label, + "time-str": start_str + end_str, + "state-badge-cls": badge["cls"], + "state-badge-label": badge["label"], + "cost-str": cost_str, + "date-str": date_str, + "edit-url": edit_url, + "tickets-config": SxExpr(tickets_config), + "buy-form": SxExpr(buy_form) if buy_form else None, + "posts-panel": SxExpr(posts_panel), + "options-html": SxExpr(options_html), + "entry-menu": SxExpr(entry_menu) if entry_menu else None, + **styles, + } -async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): - await _ensure_calendar(calendar_slug) - await _ensure_entry_context(entry_id) - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _entry_nav_html(ctx) +# --------------------------------------------------------------------------- +# Entry admin +# --------------------------------------------------------------------------- +async def _h_entry_admin_data(calendar_slug=None, entry_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import url_for, g -async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() await _ensure_entry_context(entry_id) + + calendar = getattr(g, "calendar", None) + entry = getattr(g, "entry", None) + if not calendar or not entry: + return {} + + cal_slug = getattr(calendar, "slug", "") + styles = _styles_data() + from shared.sx.page import get_template_context ctx = await get_template_context() - return _entry_admin_main_panel_html(ctx) + select_colours = ctx.get("select_colours", "") + + 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, + ) + + return { + "ticket-types-href": ticket_types_href, + "select-colours": select_colours, + **styles, + } -def _h_admin_menu(): - return sx_call("events-admin-placeholder-nav") +# --------------------------------------------------------------------------- +# Ticket types listing +# --------------------------------------------------------------------------- +async def _h_ticket_types_data(calendar_slug=None, entry_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import g, url_for + from shared.browser.app.csrf import generate_csrf_token -async def _h_ticket_types_content(calendar_slug=None, entry_id=None, - year=None, month=None, day=None, **kw): - from quart import g await _ensure_calendar(calendar_slug) await _ensure_entry(entry_id) + entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) + from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] _add_to_defpage_ctx(ticket_types=ticket_types) - return render_ticket_types_table(ticket_types, entry, calendar, day, month, year) + + styles = _styles_data() + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + eid = entry.id if entry else 0 + csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + + types_list = [] + for tt in (ticket_types or []): + tt_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=eid, ticket_type_id=tt.id, + ) + del_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=eid, ticket_type_id=tt.id, + ) + cost = getattr(tt, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + + types_list.append({ + "tt-href": tt_href, + "tt-name": tt.name, + "cost-str": cost_str, + "count": str(tt.count), + "del-url": del_url, + }) + + add_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.add_form", + calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day, + ) + + return { + "has-types": bool(ticket_types), + "types-list": types_list, + "add-url": add_url, + "csrf-hdr": csrf_hdr, + "hx-select": hx_select, + **styles, + } -async def _h_ticket_type_content(calendar_slug=None, entry_id=None, - ticket_type_id=None, year=None, month=None, day=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Ticket type detail +# --------------------------------------------------------------------------- + +async def _h_ticket_type_data(calendar_slug=None, entry_id=None, + ticket_type_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import g, abort, url_for + await _ensure_calendar(calendar_slug) await _ensure_entry(entry_id) + from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None if not ticket_type: abort(404) g.ticket_type = ticket_type _add_to_defpage_ctx(ticket_type=ticket_type) + entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) - return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year) + styles = _styles_data() + cal_slug = getattr(calendar, "slug", "") + cost = getattr(ticket_type, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + count = getattr(ticket_type, "count", 0) + + edit_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit", + ticket_type_id=ticket_type.id, calendar_slug=cal_slug, + year=year, month=month, day=day, + entry_id=entry.id if entry else 0, + ) + + return { + "ticket-id": str(ticket_type.id), + "tt-name": ticket_type.name, + "cost-str": cost_str, + "count-str": str(count), + "edit-url": edit_url, + **styles, + } -async def _h_tickets_content(**kw): - from quart import g +# --------------------------------------------------------------------------- +# My tickets +# --------------------------------------------------------------------------- + +async def _h_tickets_data(**kw) -> dict: + from quart import g, url_for from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_user_tickets + ident = current_cart_identity() tickets = await get_user_tickets( g.s, user_id=ident["user_id"], session_id=ident["session_id"], ) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _tickets_main_panel_html(ctx, tickets) + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + tickets_list = [] + for ticket in (tickets or []): + href = url_for("defpage_ticket_detail", code=ticket.code) + entry = getattr(ticket, "entry", None) + entry_name = entry.name if entry else "Unknown event" + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + cal = getattr(entry, "calendar", None) if entry else None + + time_str = "" + if entry and entry.start_at: + time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M") + if entry.end_at: + time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}" + + badge = _ticket_badge_data(state) + tickets_list.append({ + "href": href, + "entry-name": entry_name, + "type-name": tt.name if tt else None, + "time-str": time_str or None, + "cal-name": cal.name if cal else None, + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "code-prefix": ticket.code[:8], + }) + + return { + "has-tickets": bool(tickets), + "tickets-list": tickets_list, + "list-container": list_container, + } -async def _h_ticket_detail_content(code=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Ticket detail +# --------------------------------------------------------------------------- + +async def _h_ticket_detail_data(code=None, **kw) -> dict: + from quart import g, abort, url_for from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_ticket_by_code + ticket = await get_ticket_by_code(g.s, code) if code else None if not ticket: abort(404) @@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw): abort(404) else: abort(404) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _ticket_detail_panel_html(ctx, ticket) + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + ticket_code = ticket.code + cal = getattr(entry, "calendar", None) if entry else None + checked_in_at = getattr(ticket, "checked_in_at", None) + + bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", + "reserved": "bg-amber-50"} + header_bg = bg_map.get(state, "bg-stone-50") + entry_name = entry.name if entry else "Ticket" + back_href = url_for("defpage_my_tickets") + + badge = _ticket_badge_data(state) + + time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None + time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None + if time_range and entry.end_at: + time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}" + + tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None + checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None + + qr_script = ( + f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');" + "if(c&&typeof QRCode!=='undefined'){" + "var cv=document.createElement('canvas');" + f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});" + "}})()" + ) + + return { + "list-container": list_container, + "back-href": back_href, + "header-bg": header_bg, + "entry-name": entry_name, + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "type-name": tt.name if tt else None, + "ticket-code": ticket_code, + "time-date": time_date, + "time-range": time_range, + "cal-name": cal.name if cal else None, + "type-desc": tt_desc, + "checkin-str": checkin_str, + "qr-script": qr_script, + } -async def _h_ticket_admin_content(**kw): - from quart import g +# --------------------------------------------------------------------------- +# Ticket admin dashboard +# --------------------------------------------------------------------------- + +async def _h_ticket_admin_data(**kw) -> dict: + from quart import g, url_for from sqlalchemy import select, func from sqlalchemy.orm import selectinload from models.calendars import CalendarEntry, Ticket + from shared.browser.app.csrf import generate_csrf_token result = await g.s.execute( select(Ticket) @@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw): reserved = await g.s.scalar( select(func.count(Ticket.id)).where(Ticket.state == "reserved") ) - stats = { - "total": total or 0, - "confirmed": confirmed or 0, - "checked_in": checked_in or 0, - "reserved": reserved or 0, + + csrf = generate_csrf_token() + lookup_url = url_for("ticket_admin.lookup") + + from shared.sx.page import get_template_context + ctx = await get_template_context() + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + # Stats cards data + admin_stats = [] + for label, key, border, bg, text_cls in [ + ("Total", "total", "border-stone-200", "", "text-stone-900"), + ("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"), + ("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"), + ("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"), + ]: + val_map = {"total": total, "confirmed": confirmed, + "checked_in": checked_in, "reserved": reserved} + val = val_map.get(key, 0) or 0 + lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500" + admin_stats.append({ + "border": border, "bg": bg, "text-cls": text_cls, + "label-cls": lbl_cls, "value": str(val), "label": label, + }) + + # Ticket rows data + admin_tickets = [] + for ticket in tickets: + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + tcode = ticket.code + checked_in_at = getattr(ticket, "checked_in_at", None) + + date_str = None + if entry and entry.start_at: + date_str = entry.start_at.strftime("%d %b %Y, %H:%M") + + badge = _ticket_badge_data(state) + can_checkin = state in ("confirmed", "reserved") + is_checked_in = state == "checked_in" + checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None + checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else "" + + admin_tickets.append({ + "code": tcode, + "code-short": tcode[:12] + "...", + "entry-name": entry.name if entry else "\u2014", + "date-str": date_str, + "type-name": tt.name if tt else "\u2014", + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "can-checkin": can_checkin, + "is-checked-in": is_checked_in, + "checkin-url": checkin_url, + "checkin-time": checkin_time, + }) + + return { + "admin-stats": admin_stats, + "admin-tickets": admin_tickets, + "list-container": list_container, + "lookup-url": lookup_url, + "csrf": csrf, + "has-tickets": bool(tickets), } - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _ticket_admin_main_panel_html(ctx, tickets, stats) +# --------------------------------------------------------------------------- +# Markets +# --------------------------------------------------------------------------- + +async def _h_markets_data(**kw) -> dict: + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + from shared.sx.helpers import call_url -async def _h_markets_content(**kw): _ensure_post_defpage_ctx() + from shared.sx.page import get_template_context ctx = await get_template_context() - return _markets_main_panel_html(ctx) + + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + has_access = ctx.get("has_access") + can_create = has_access("markets.create_market") if callable(has_access) else is_admin + csrf = generate_csrf_token() + markets_raw = ctx.get("markets") or [] + + post = ctx.get("post") or {} + slug = post.get("slug", "") + csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + + markets_list = [] + for m in markets_raw: + m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") + m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") + market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") + del_url = url_for("markets.delete_market", market_slug=m_slug) + + markets_list.append({ + "href": market_href, + "name": m_name, + "slug": m_slug, + "del-url": del_url, + "csrf-hdr": csrf_hdr, + }) + + return { + "can-create": can_create, + "create-url": url_for("markets.create_market") if can_create else None, + "csrf": csrf, + "markets-list": markets_list, + } From 2a04aaad5ef80b329c87b2ed48d60e60dbc3768d Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 08:50:48 +0000 Subject: [PATCH 02/24] Fix market header ImportError and sx docs menu bar 3 OOB insertion - market/sx/layouts.sx: Update ~market-header-auto macro to build nav from data fields via ~market-desktop-nav-from-data instead of expecting pre-built "desktop-nav" SxExpr (removed in Phase 9) - shared/sx/primitives_io.py: Import _market_header_data instead of deleted _desktop_category_nav_sx, return individual data fields - sx/sx/layouts.sx: Fix ~sx-section-layout-oob to use ~oob-header-sx for inserting sub-row into always-existing container div Co-Authored-By: Claude Opus 4.6 --- market/sx/layouts.sx | 8 +++++++- shared/sx/primitives_io.py | 19 ++++++++++++------- sx/sx/layouts.sx | 5 +++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/market/sx/layouts.sx b/market/sx/layouts.sx index a55828c..e9676d8 100644 --- a/market/sx/layouts.sx +++ b/market/sx/layouts.sx @@ -15,7 +15,13 @@ :title (get __mctx "market-title") :top-slug (get __mctx "top-slug") :sub-div (get __mctx "sub-slug")) - :nav (get __mctx "desktop-nav") + :nav (~market-desktop-nav-from-data + :categories (get __mctx "categories") + :hx-select (get __mctx "hx-select") + :select-colours (get __mctx "select-colours") + :all-href (get __mctx "all-href") + :all-active (get __mctx "all-active") + :admin-href (get __mctx "admin-href")) :child-id "market-header-child" :oob (unquote oob))))) diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 63fe8e3..09b758d 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -876,10 +876,10 @@ async def _io_events_ticket_type_ctx( 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 values. + """``(market-header-ctx)`` → dict with market header data. - Pre-builds desktop-nav and mobile-nav as SxExpr strings using - the existing Python helper functions in sxc.pages.layouts. + 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 @@ -916,9 +916,9 @@ async def _io_market_header_ctx( "category_label": "", } - # Pre-build nav using existing helper functions (lazy import from market service) - from sxc.pages.layouts import _desktop_category_nav_sx, _mobile_nav_panel_sx - desktop_nav = _desktop_category_nav_sx(mini_ctx, categories, "", "#main-panel") + # 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 { @@ -926,7 +926,12 @@ async def _io_market_header_ctx( "link-href": link_href, "top-slug": "", "sub-slug": "", - "desktop-nav": SxExpr(desktop_nav) if desktop_nav else "", + "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 "", } diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 2f8a4a1..26395e4 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -73,8 +73,9 @@ :sub-nav sub-nav :selected selected)))) (defcomp ~sx-section-layout-oob (&key section sub-label sub-href sub-nav selected) - (<> (~sx-sub-row :sub-label sub-label :sub-href sub-href - :sub-nav sub-nav :selected selected :oob true) + (<> (~oob-header-sx :parent-id "sx-header-child" + :row (~sx-sub-row :sub-label sub-label :sub-href sub-href + :sub-nav sub-nav :selected selected)) (~sx-header-row :nav (~sx-main-nav :section section) :oob true) From 64aa417d63510a967bca1ffd2c60525935852d60 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 09:25:28 +0000 Subject: [PATCH 03/24] Replace JSON sx-headers with SX dict expressions, fix blog like component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sx-headers attributes now use native SX dict format {:key val} instead of JSON strings. Eliminates manual JSON string construction in both .sx files and Python callers. - sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback, add _serializeDict for dict→attribute serialization, fix verbInfo scope in _doFetch error handler - html.py: serialize dict attribute values via SX serialize() not str() - All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}") - All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON - Blog like: extract ~blog-like-toggle, fix POST returning wrong component, fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers keyword mismatch, wrap sx_content in SxExpr for evaluation Co-Authored-By: Claude Opus 4.6 --- account/bp/account/routes.py | 2 +- account/sx/newsletters.sx | 2 +- blog/bp/post/admin/routes.py | 2 +- blog/bp/post/routes.py | 12 ++++-------- blog/services/blog_page.py | 9 ++++++++- blog/sx/admin.sx | 6 +++--- blog/sx/cards.sx | 5 ++--- blog/sx/detail.sx | 11 +++++++---- blog/sx/settings.sx | 4 ++-- events/sxc/pages/calendar.py | 4 ++-- events/sxc/pages/entries.py | 2 +- events/sxc/pages/helpers.py | 6 +++--- events/sxc/pages/slots.py | 4 ++-- events/sxc/pages/tickets.py | 4 ++-- market/sxc/pages/helpers.py | 2 +- market/sxc/pages/renders.py | 2 +- shared/static/scripts/sx.js | 28 ++++++++++++++++++++-------- shared/sx/html.py | 5 ++++- shared/sx/page.py | 4 ++-- sx/sx/examples-content.sx | 2 +- sx/sxc/examples.sx | 2 +- sx/sxc/reference.sx | 2 +- 22 files changed, 70 insertions(+), 50 deletions(-) diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 9129c3c..f300d9e 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -69,7 +69,7 @@ def register(url_prefix="/"): return sx_response(sx_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf}"}}', + hdrs={"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, diff --git a/account/sx/newsletters.sx b/account/sx/newsletters.sx index 4b0626b..b051d2a 100644 --- a/account/sx/newsletters.sx +++ b/account/sx/newsletters.sx @@ -54,7 +54,7 @@ :toggle (~account-newsletter-toggle :id (str "nl-" nid) :url toggle-url - :hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}") + :hdrs {:X-CSRFToken csrf} :target (str "#nl-" nid) :cls (str "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 diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index d06157e..4cbaf70 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -138,7 +138,7 @@ def _render_calendar_view( e_id = getattr(e, "id", None) e_name = esc(getattr(e, "name", "")) t_url = toggle_url_fn(e_id) - hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}' + hx_hdrs = '{:X-CSRFToken "' + csrf + '"}' if e_id in associated_entry_ids: entry_btns.append( diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index c5808af..70cf5c4 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -156,14 +156,10 @@ def register(): csrf = generate_csrf_token() def _like_btn(liked): - if liked: - colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post" - else: - colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post" - return sx_call("market-like-toggle-button", - colour=colour, action=like_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', - label=label, icon_cls=icon) + return sx_call("blog-like-toggle", + like_url=like_url, + hx_headers={"X-CSRFToken": csrf}, + heart="\u2764\ufe0f" if liked else "\U0001f90d") if not g.user: return sx_response(_like_btn(False), status=403) diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py index 7595a17..0a42fe5 100644 --- a/blog/services/blog_page.py +++ b/blog/services/blog_page.py @@ -1,6 +1,13 @@ """Blog page data service — provides serialized dicts for .sx defpages.""" from __future__ import annotations +from shared.sx.parser import SxExpr + + +def _sx_content_expr(raw: str) -> SxExpr | None: + """Wrap non-empty sx_content as SxExpr so it serializes unquoted.""" + return SxExpr(raw) if raw else None + class BlogPageService: """Service for blog page data, callable via (service "blog-page" ...).""" @@ -424,7 +431,7 @@ class BlogPageService: "authors": authors, "feature_image": post.get("feature_image"), "html_content": post.get("html", ""), - "sx_content": post.get("sx_content", ""), + "sx_content": _sx_content_expr(post.get("sx_content", "")), } async def preview_data(self, session, *, slug=None, **kw): diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index c7b2cd9..d227407 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -206,7 +206,7 @@ (when is-admin (~blog-snippet-visibility-select :patch-url (get s "patch_url") - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") + :hx-headers {:X-CSRFToken csrf} :options (<> (~blog-snippet-option :value "private" :selected (= vis "private") :label "private") (~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared") @@ -217,7 +217,7 @@ :trigger-target "#snippets-list" :title "Delete snippet?" :text (str "Delete \u201c" name "\u201d?") - :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") + :sx-headers {:X-CSRFToken csrf} :cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))) (or snippets (list))))))) @@ -240,7 +240,7 @@ :edit-url (get mi "edit_url") :delete-url (get mi "delete_url") :confirm-text (str "Remove " (get mi "label") " from the menu?") - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))) + :hx-headers {:X-CSRFToken csrf})) (or menu-items (list))))))) ;; Tag Groups — receives serialized tag group data from service diff --git a/blog/sx/cards.sx b/blog/sx/cards.sx index cf4f8fa..2fde9ee 100644 --- a/blog/sx/cards.sx +++ b/blog/sx/cards.sx @@ -2,8 +2,7 @@ (defcomp ~blog-like-button (&key like-url hx-headers heart) (div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl" - (button :sx-post like-url :sx-swap "outerHTML" - :sx-headers hx-headers :class "cursor-pointer" heart))) + (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (defcomp ~blog-draft-status (&key publish-requested timestamp) (<> (div :class "flex justify-center gap-2 mt-1" @@ -56,7 +55,7 @@ (when has-like (~blog-like-button :like-url like-url - :sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}") + :hx-headers {:X-CSRFToken csrf-token} :heart (if liked "❤️" "🤍"))) (a :href href :sx-get href :sx-target "#main-panel" :sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true" diff --git a/blog/sx/detail.sx b/blog/sx/detail.sx index c7ab534..c00ac6e 100644 --- a/blog/sx/detail.sx +++ b/blog/sx/detail.sx @@ -12,10 +12,13 @@ (when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested")) edit)) +(defcomp ~blog-like-toggle (&key like-url hx-headers heart) + (button :sx-post like-url :sx-swap "outerHTML" + :sx-headers hx-headers :class "cursor-pointer" heart)) + (defcomp ~blog-detail-like (&key like-url hx-headers heart) (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" - (button :sx-post like-url :sx-swap "outerHTML" - :sx-headers hx-headers :class "cursor-pointer" heart))) + (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (defcomp ~blog-detail-excerpt (&key excerpt) (div :class "w-full text-center italic text-3xl p-2" excerpt)) @@ -55,8 +58,8 @@ :like (when has-user (~blog-detail-like :like-url like-url - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") - :heart (if liked "\u2764\ufe0f" "\U0001f90d"))) + :hx-headers {:X-CSRFToken csrf} + :heart (if liked "❤️" "🤍"))) :excerpt (when (not (= custom-excerpt "")) (~blog-detail-excerpt :excerpt custom-excerpt)) :at-bar (~blog-at-bar :tags tags :authors authors))))) diff --git a/blog/sx/settings.sx b/blog/sx/settings.sx index c72f04a..b9a8978 100644 --- a/blog/sx/settings.sx +++ b/blog/sx/settings.sx @@ -2,7 +2,7 @@ (defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger) (form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML" - :sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3" + :sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3" (label :class "flex items-center gap-3 cursor-pointer" (input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500" @@ -140,7 +140,7 @@ (~blog-associated-entry :confirm-text (get e "confirm_text") :toggle-url (get e "toggle_url") - :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") + :hx-headers {:X-CSRFToken csrf} :img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title")) :name (get e "name") :date-str (get e "date_str"))) diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py index 01470e1..24471e3 100644 --- a/events/sxc/pages/calendar.py +++ b/events/sxc/pages/calendar.py @@ -323,7 +323,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str: cal_name = getattr(cal, "name", "") href = prefix + url_for("calendar.get", calendar_slug=cal_slug) del_url = url_for("calendar.delete", calendar_slug=cal_slug) - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} parts.append(sx_call("crud-item", href=href, name=cal_name, slug=cal_slug, del_url=del_url, csrf_hdr=csrf_hdr, @@ -656,7 +656,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str: m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") del_url = url_for("markets.delete_market", market_slug=m_slug) - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} parts.append(sx_call("crud-item", href=market_href, name=m_name, slug=m_slug, del_url=del_url, diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py index 1a1acd0..506483e 100644 --- a/events/sxc/pages/entries.py +++ b/events/sxc/pages/entries.py @@ -552,7 +552,7 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> items += sx_call("events-entry-post-item", img=img_html, title=ep_title, del_url=del_url, entry_id=eid_s, - csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + csrf_hdr={"X-CSRFToken": csrf}) posts_html = sx_call("events-entry-posts-list", items=SxExpr(items)) else: posts_html = sx_call("events-entry-posts-none") diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index be5137d..b005fbb 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -387,7 +387,7 @@ async def _h_slots_data(calendar_slug=None, **kw) -> dict: csrf = generate_csrf_token() cal_slug = getattr(calendar, "slug", "") hx_select = getattr(g, "hx_select_search", "#main-panel") - csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug) slots_list = [] @@ -624,7 +624,7 @@ async def _h_ticket_types_data(calendar_slug=None, entry_id=None, cal_slug = getattr(calendar, "slug", "") hx_select = getattr(g, "hx_select_search", "#main-panel") eid = entry.id if entry else 0 - csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} types_list = [] for tt in (ticket_types or []): @@ -964,7 +964,7 @@ async def _h_markets_data(**kw) -> dict: post = ctx.get("post") or {} slug = post.get("slug", "") - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} markets_list = [] for m in markets_raw: diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py index a0b2ded..bd95562 100644 --- a/events/sxc/pages/slots.py +++ b/events/sxc/pages/slots.py @@ -263,7 +263,7 @@ def render_slots_table(slots, calendar) -> str: time_str=f"{time_start} - {time_end}", cost_str=cost_str, action_btn=action_btn, del_url=del_url, - csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + csrf_hdr={"X-CSRFToken": csrf}) else: rows_html = sx_call("events-slots-empty-row") @@ -343,7 +343,7 @@ def render_slot_add_form(calendar) -> str: post_url = url_for("calendar.slots.post", calendar_slug=cal_slug) cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug) - csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} # Days checkboxes (all unchecked for add) day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"), diff --git a/events/sxc/pages/tickets.py b/events/sxc/pages/tickets.py index 669468c..4588bf7 100644 --- a/events/sxc/pages/tickets.py +++ b/events/sxc/pages/tickets.py @@ -419,7 +419,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) - tt_name=tt.name, cost_str=cost_str, count=str(tt.count), action_btn=action_btn, del_url=del_url, - csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + csrf_hdr={"X-CSRFToken": csrf}) else: rows_html = sx_call("events-ticket-types-empty-row") @@ -699,7 +699,7 @@ def render_ticket_type_add_form(entry, calendar, day, month, year) -> str: cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button", calendar_slug=cal_slug, entry_id=entry.id, year=year, month=month, day=day) - csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} return sx_call("events-ticket-type-add-form", post_url=post_url, csrf=csrf_hdr, diff --git a/market/sxc/pages/helpers.py b/market/sxc/pages/helpers.py index 24c1f3e..81b71ad 100644 --- a/market/sxc/pages/helpers.py +++ b/market/sxc/pages/helpers.py @@ -129,7 +129,7 @@ async def _h_page_admin_data(slug=None, **kw) -> dict: m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "") href = prefix + f"/{post_slug}/{m_slug}/" del_url = url_for("page_admin.delete_market", market_slug=m_slug) - csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + csrf_hdr = {"X-CSRFToken": csrf} markets.append({ "href": href, "name": m_name, "slug": m_slug, "del-url": del_url, "csrf-hdr": csrf_hdr, diff --git a/market/sxc/pages/renders.py b/market/sxc/pages/renders.py index 2a4d1cf..a316d0d 100644 --- a/market/sxc/pages/renders.py +++ b/market/sxc/pages/renders.py @@ -189,7 +189,7 @@ def render_like_toggle_button(slug: str, liked: bool, *, return sx_call( "market-like-toggle-button", colour=colour, action=like_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + hx_headers={"X-CSRFToken": csrf}, label=label, icon_cls=icon, ) diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 8ce9710..070f0bc 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -251,6 +251,18 @@ return results; } + /** Serialize a JS object as SX dict {:key "val" ...} for attribute values. */ + function _serializeDict(obj) { + var parts = []; + for (var k in obj) { + if (!obj.hasOwnProperty(k)) continue; + var v = obj[k]; + var vs = typeof v === "string" ? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' : String(v); + parts.push(":" + k + " " + vs); + } + return "{" + parts.join(" ") + "}"; + } + // --- Primitives --- var PRIMITIVES = {}; @@ -1420,7 +1432,7 @@ } else if (attrVal === true) { el.setAttribute(attrName, ""); } else { - el.setAttribute(attrName, String(attrVal)); + el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal)); } } else { // Child @@ -1851,7 +1863,7 @@ cancelButtonText: "Cancel" }).then(function (result) { if (!result.isConfirmed) return; - return _doFetch(el, method, url, extraParams); + return _doFetch(el, verbInfo, method, url, extraParams); }); } if (!window.confirm(confirmMsg)) return Promise.resolve(); @@ -1866,10 +1878,10 @@ extraParams.promptValue = promptVal; } - return _doFetch(el, method, url, extraParams); + return _doFetch(el, verbInfo, method, url, extraParams); } - function _doFetch(el, method, url, extraParams) { + function _doFetch(el, verbInfo, method, url, extraParams) { // sx-sync: abort previous var sync = el.getAttribute("sx-sync"); if (sync && sync.indexOf("replace") >= 0) abortPrevious(el); @@ -1895,12 +1907,12 @@ var cssHeader = _getSxCssHeader(); if (cssHeader) headers["SX-Css"] = cssHeader; - // Extra headers from sx-headers + // Extra headers from sx-headers (SX dict {:key "val"} or JSON) var extraH = el.getAttribute("sx-headers"); if (extraH) { try { - var parsed = JSON.parse(extraH); - for (var k in parsed) headers[k] = parsed[k]; + var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH); + for (var k in parsed) headers[k] = String(parsed[k]); } catch (e) { /* ignore */ } } @@ -1974,7 +1986,7 @@ var valsAttr = el.getAttribute("sx-vals"); if (valsAttr) { try { - var vals = JSON.parse(valsAttr); + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); if (method === "GET") { for (var vk in vals) { url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); diff --git a/shared/sx/html.py b/shared/sx/html.py index 50f5092..8123ada 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -206,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 "" @@ -540,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(">") diff --git a/shared/sx/page.py b/shared/sx/page.py index 6ca1c27..29309c8 100644 --- a/shared/sx/page.py +++ b/shared/sx/page.py @@ -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: diff --git a/sx/sx/examples-content.sx b/sx/sx/examples-content.sx index a497927..4b38a10 100644 --- a/sx/sx/examples-content.sx +++ b/sx/sx/examples-content.sx @@ -277,7 +277,7 @@ :description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received." :demo-description "Click each button to see what the server receives." :demo (~vals-headers-demo) - :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers \"{\\\"X-Custom-Token\\\": \\\"abc123\\\"}\"\n \"Send with headers\")" + :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")" :handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')" :comp-placeholder-id "vals-comp" :wire-placeholder-id "vals-wire")) diff --git a/sx/sxc/examples.sx b/sx/sxc/examples.sx index 16cb855..d1765a5 100644 --- a/sx/sxc/examples.sx +++ b/sx/sxc/examples.sx @@ -705,7 +705,7 @@ :sx-get "/examples/api/echo-headers" :sx-target "#headers-result" :sx-swap "innerHTML" - :sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}" + :sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"} :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Send with headers") (div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" diff --git a/sx/sxc/reference.sx b/sx/sxc/reference.sx index 74374a1..1fa16e8 100644 --- a/sx/sxc/reference.sx +++ b/sx/sxc/reference.sx @@ -272,7 +272,7 @@ (defcomp ~ref-headers-demo () (div :class "space-y-3" (button :sx-get "/reference/api/echo-headers" - :sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}" + :sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"} :sx-target "#ref-headers-result" :sx-swap "innerHTML" :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" From 235428628aed38e15b71ee93964b52437abdee97 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 09:31:40 +0000 Subject: [PATCH 04/24] Add reference SX evaluator written in s-expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta-circular evaluator: the SX language specifying its own semantics. A thin bootstrap compiler per target (JS, Python, Rust) reads these .sx files and emits a native evaluator. Files: - eval.sx: Core evaluator — type dispatch, special forms, TCO trampoline, lambda/component/macro invocation, higher-order forms - primitives.sx: Declarative specification of ~80 built-in pure functions - render.sx: Three rendering modes (DOM, HTML string, SX wire format) - parser.sx: Tokenizer, parser, and serializer specification Platform-specific concerns (DOM ops, async I/O, HTML emission) are declared as interfaces that each target implements. Co-Authored-By: Claude Opus 4.6 --- shared/sx/ref/eval.sx | 731 ++++++++++++++++++++++++++++++++++++ shared/sx/ref/parser.sx | 319 ++++++++++++++++ shared/sx/ref/primitives.sx | 428 +++++++++++++++++++++ shared/sx/ref/render.sx | 333 ++++++++++++++++ 4 files changed, 1811 insertions(+) create mode 100644 shared/sx/ref/eval.sx create mode 100644 shared/sx/ref/parser.sx create mode 100644 shared/sx/ref/primitives.sx create mode 100644 shared/sx/ref/render.sx diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx new file mode 100644 index 0000000..018a621 --- /dev/null +++ b/shared/sx/ref/eval.sx @@ -0,0 +1,731 @@ +;; ========================================================================== +;; eval.sx — Reference SX evaluator written in SX +;; +;; This is the canonical specification of SX evaluation semantics. +;; A thin bootstrap compiler per target reads this file and emits +;; a native evaluator (JavaScript, Python, Rust, etc.). +;; +;; The evaluator is written in a restricted subset of SX: +;; - defcomp, define, defmacro, lambda/fn +;; - if, when, cond, case, let, do, and, or +;; - map, filter, reduce, some, every? +;; - Primitives: list ops, string ops, arithmetic, predicates +;; - quote, quasiquote/unquote/splice-unquote +;; - Pattern matching via (case (type-of expr) ...) +;; +;; Platform-specific concerns (DOM rendering, async I/O, HTML emission) +;; are declared as interfaces — each target provides its own adapter. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 1. Types +;; -------------------------------------------------------------------------- +;; +;; The evaluator operates on these value types: +;; +;; number — integer or float +;; string — double-quoted text +;; boolean — true / false +;; nil — singleton null +;; symbol — unquoted identifier (e.g. div, ~card, map) +;; keyword — colon-prefixed key (e.g. :class, :id) +;; list — ordered sequence (also used as code) +;; dict — string-keyed hash map +;; lambda — closure: {params, body, closure-env, name?} +;; macro — AST transformer: {params, rest-param, body, closure-env} +;; component — UI component: {name, params, has-children, body, closure-env} +;; thunk — deferred eval for TCO: {expr, env} +;; +;; Each target must provide: +;; (type-of x) → one of the strings above +;; (make-lambda ...) → platform Lambda value +;; (make-component ..) → platform Component value +;; (make-macro ...) → platform Macro value +;; (make-thunk ...) → platform Thunk value +;; +;; These are declared in platform.sx and implemented per target. +;; -------------------------------------------------------------------------- + + +;; -------------------------------------------------------------------------- +;; 2. Trampoline — tail-call optimization +;; -------------------------------------------------------------------------- + +(define trampoline + (fn (val) + ;; Iteratively resolve thunks until we get an actual value. + ;; Each target implements thunk? and thunk-expr/thunk-env. + (let ((result val)) + (do + ;; Loop while result is a thunk + ;; Note: this is pseudo-iteration — bootstrap compilers convert + ;; this tail-recursive form to a while loop. + (if (thunk? result) + (trampoline (eval-expr (thunk-expr result) (thunk-env result))) + result))))) + + +;; -------------------------------------------------------------------------- +;; 3. Core evaluator +;; -------------------------------------------------------------------------- + +(define eval-expr + (fn (expr env) + (case (type-of expr) + + ;; --- literals pass through --- + "number" expr + "string" expr + "boolean" expr + "nil" nil + + ;; --- symbol lookup --- + "symbol" + (let ((name (symbol-name expr))) + (cond + (env-has? env name) (env-get env name) + (primitive? name) (get-primitive name) + (= name "true") true + (= name "false") false + (= name "nil") nil + :else (error (str "Undefined symbol: " name)))) + + ;; --- keyword → its string name --- + "keyword" (keyword-name expr) + + ;; --- dict literal --- + "dict" + (map-dict (fn (k v) (list k (trampoline (eval-expr v env)))) expr) + + ;; --- list = call or special form --- + "list" + (if (empty? expr) + (list) + (eval-list expr env)) + + ;; --- anything else passes through --- + :else expr))) + + +;; -------------------------------------------------------------------------- +;; 4. List evaluation — dispatch on head +;; -------------------------------------------------------------------------- + +(define eval-list + (fn (expr env) + (let ((head (first expr)) + (args (rest expr))) + + ;; If head isn't a symbol, lambda, or list → treat as data list + (if (not (or (= (type-of head) "symbol") + (= (type-of head) "lambda") + (= (type-of head) "list"))) + (map (fn (x) (trampoline (eval-expr x env))) expr) + + ;; Head is a symbol — check special forms, then function call + (if (= (type-of head) "symbol") + (let ((name (symbol-name head))) + (cond + ;; Special forms + (= name "if") (sf-if args env) + (= name "when") (sf-when args env) + (= name "cond") (sf-cond args env) + (= name "case") (sf-case args env) + (= name "and") (sf-and args env) + (= name "or") (sf-or args env) + (= name "let") (sf-let args env) + (= name "let*") (sf-let args env) + (= name "lambda") (sf-lambda args env) + (= name "fn") (sf-lambda args env) + (= name "define") (sf-define args env) + (= name "defcomp") (sf-defcomp args env) + (= name "defmacro") (sf-defmacro args env) + (= name "begin") (sf-begin args env) + (= name "do") (sf-begin args env) + (= name "quote") (sf-quote args env) + (= name "quasiquote") (sf-quasiquote args env) + (= name "->") (sf-thread-first args env) + (= name "set!") (sf-set! args env) + + ;; Higher-order forms + (= name "map") (ho-map args env) + (= name "map-indexed") (ho-map-indexed args env) + (= name "filter") (ho-filter args env) + (= name "reduce") (ho-reduce args env) + (= name "some") (ho-some args env) + (= name "every?") (ho-every args env) + (= name "for-each") (ho-for-each args env) + + ;; Macro expansion + (and (env-has? env name) (macro? (env-get env name))) + (let ((mac (env-get env name))) + (make-thunk (expand-macro mac args env) env)) + + ;; Fall through to function call + :else (eval-call head args env))) + + ;; Head is lambda or list — evaluate as function call + (eval-call head args env)))))) + + +;; -------------------------------------------------------------------------- +;; 5. Function / lambda / component call +;; -------------------------------------------------------------------------- + +(define eval-call + (fn (head args env) + (let ((f (trampoline (eval-expr head env))) + (evaluated-args (map (fn (a) (trampoline (eval-expr a env))) args))) + (cond + ;; Native callable (primitive function) + (and (callable? f) (not (lambda? f)) (not (component? f))) + (apply f evaluated-args) + + ;; Lambda + (lambda? f) + (call-lambda f evaluated-args env) + + ;; Component + (component? f) + (call-component f args env) + + :else (error (str "Not callable: " (inspect f))))))) + + +(define call-lambda + (fn (f args caller-env) + (let ((params (lambda-params f)) + (local (env-merge (lambda-closure f) caller-env))) + (if (!= (len args) (len params)) + (error (str (or (lambda-name f) "lambda") + " expects " (len params) " args, got " (len args))) + (do + ;; Bind params + (for-each + (fn (pair) (env-set! local (first pair) (nth pair 1))) + (zip params args)) + ;; Return thunk for TCO + (make-thunk (lambda-body f) local)))))) + + +(define call-component + (fn (comp raw-args env) + ;; Parse keyword args and children from unevaluated arg list + (let ((parsed (parse-keyword-args raw-args env)) + (kwargs (first parsed)) + (children (nth parsed 1)) + (local (env-merge (component-closure comp) env))) + ;; Bind keyword params + (for-each + (fn (p) (env-set! local p (or (dict-get kwargs p) nil))) + (component-params comp)) + ;; Bind children if component accepts them + (when (component-has-children? comp) + (env-set! local "children" children)) + ;; Return thunk — body evaluated in local env + (make-thunk (component-body comp) local)))) + + +(define parse-keyword-args + (fn (raw-args env) + ;; Walk args: keyword + next-val → kwargs dict, else → children list + (let ((kwargs (dict)) + (children (list)) + (i 0)) + ;; Iterative parse — bootstrap converts to while loop + (reduce + (fn (state arg) + (let ((idx (get state "i")) + (skip (get state "skip"))) + (if skip + ;; This arg was consumed as a keyword value + (assoc state "skip" false "i" (inc idx)) + (if (and (= (type-of arg) "keyword") + (< (inc idx) (len raw-args))) + ;; Keyword: evaluate next arg and store + (do + (dict-set! kwargs (keyword-name arg) + (trampoline (eval-expr (nth raw-args (inc idx)) env))) + (assoc state "skip" true "i" (inc idx))) + ;; Positional: evaluate and add to children + (do + (append! children (trampoline (eval-expr arg env))) + (assoc state "i" (inc idx))))))) + (dict "i" 0 "skip" false) + raw-args) + (list kwargs children)))) + + +;; -------------------------------------------------------------------------- +;; 6. Special forms +;; -------------------------------------------------------------------------- + +(define sf-if + (fn (args env) + (let ((condition (trampoline (eval-expr (first args) env)))) + (if (and condition (not (nil? condition))) + (make-thunk (nth args 1) env) + (if (> (len args) 2) + (make-thunk (nth args 2) env) + nil))))) + + +(define sf-when + (fn (args env) + (let ((condition (trampoline (eval-expr (first args) env)))) + (if (and condition (not (nil? condition))) + (do + ;; Evaluate all but last for side effects + (for-each + (fn (e) (trampoline (eval-expr e env))) + (slice args 1 (dec (len args)))) + ;; Last is tail position + (make-thunk (last args) env)) + nil)))) + + +(define sf-cond + (fn (args env) + ;; Detect scheme-style: first arg is a 2-element list + (if (and (= (type-of (first args)) "list") + (= (len (first args)) 2)) + ;; Scheme-style: ((test body) ...) + (sf-cond-scheme args env) + ;; Clojure-style: test body test body ... + (sf-cond-clojure args env)))) + +(define sf-cond-scheme + (fn (clauses env) + (if (empty? clauses) + nil + (let ((clause (first clauses)) + (test (first clause)) + (body (nth clause 1))) + (if (or (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else"))) + (and (= (type-of test) "keyword") + (= (keyword-name test) "else"))) + (make-thunk body env) + (if (trampoline (eval-expr test env)) + (make-thunk body env) + (sf-cond-scheme (rest clauses) env))))))) + +(define sf-cond-clojure + (fn (clauses env) + (if (< (len clauses) 2) + nil + (let ((test (first clauses)) + (body (nth clauses 1))) + (if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) + (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else")))) + (make-thunk body env) + (if (trampoline (eval-expr test env)) + (make-thunk body env) + (sf-cond-clojure (slice clauses 2) env))))))) + + +(define sf-case + (fn (args env) + (let ((match-val (trampoline (eval-expr (first args) env))) + (clauses (rest args))) + (sf-case-loop match-val clauses env)))) + +(define sf-case-loop + (fn (match-val clauses env) + (if (< (len clauses) 2) + nil + (let ((test (first clauses)) + (body (nth clauses 1))) + (if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) + (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else")))) + (make-thunk body env) + (if (= match-val (trampoline (eval-expr test env))) + (make-thunk body env) + (sf-case-loop match-val (slice clauses 2) env))))))) + + +(define sf-and + (fn (args env) + (if (empty? args) + true + (let ((val (trampoline (eval-expr (first args) env)))) + (if (not val) + val + (if (= (len args) 1) + val + (sf-and (rest args) env))))))) + + +(define sf-or + (fn (args env) + (if (empty? args) + false + (let ((val (trampoline (eval-expr (first args) env)))) + (if val + val + (sf-or (rest args) env)))))) + + +(define sf-let + (fn (args env) + (let ((bindings (first args)) + (body (rest args)) + (local (env-extend env))) + ;; Parse bindings — support both ((name val) ...) and (name val name val ...) + (if (and (= (type-of (first bindings)) "list") + (= (len (first bindings)) 2)) + ;; Scheme-style + (for-each + (fn (binding) + (let ((vname (if (= (type-of (first binding)) "symbol") + (symbol-name (first binding)) + (first binding)))) + (env-set! local vname (trampoline (eval-expr (nth binding 1) local))))) + bindings) + ;; Clojure-style + (let ((i 0)) + (reduce + (fn (acc pair-idx) + (let ((vname (if (= (type-of (nth bindings (* pair-idx 2))) "symbol") + (symbol-name (nth bindings (* pair-idx 2))) + (nth bindings (* pair-idx 2)))) + (val-expr (nth bindings (inc (* pair-idx 2))))) + (env-set! local vname (trampoline (eval-expr val-expr local))))) + nil + (range 0 (/ (len bindings) 2))))) + ;; Evaluate body — last expression in tail position + (for-each + (fn (e) (trampoline (eval-expr e local))) + (slice body 0 (dec (len body)))) + (make-thunk (last body) local)))) + + +(define sf-lambda + (fn (args env) + (let ((params-expr (first args)) + (body (nth args 1)) + (param-names (map (fn (p) + (if (= (type-of p) "symbol") + (symbol-name p) + p)) + params-expr))) + (make-lambda param-names body env)))) + + +(define sf-define + (fn (args env) + (let ((name-sym (first args)) + (value (trampoline (eval-expr (nth args 1) env)))) + (when (and (lambda? value) (nil? (lambda-name value))) + (set-lambda-name! value (symbol-name name-sym))) + (env-set! env (symbol-name name-sym) value) + value))) + + +(define sf-defcomp + (fn (args env) + (let ((name-sym (first args)) + (params-raw (nth args 1)) + (body (nth args 2)) + (comp-name (strip-prefix (symbol-name name-sym) "~")) + (parsed (parse-comp-params params-raw)) + (params (first parsed)) + (has-children (nth parsed 1))) + (let ((comp (make-component comp-name params has-children body env))) + (env-set! env (symbol-name name-sym) comp) + comp)))) + +(define parse-comp-params + (fn (params-expr) + ;; Parse (&key param1 param2 &rest children) → (params has-children) + (let ((params (list)) + (has-children false) + (in-key false)) + (for-each + (fn (p) + (when (= (type-of p) "symbol") + (let ((name (symbol-name p))) + (cond + (= name "&key") (set! in-key true) + (= name "&rest") (set! has-children true) + (and in-key (not has-children)) + (append! params name) + :else + (append! params name))))) + params-expr) + (list params has-children)))) + + +(define sf-defmacro + (fn (args env) + (let ((name-sym (first args)) + (params-raw (nth args 1)) + (body (nth args 2)) + (parsed (parse-macro-params params-raw)) + (params (first parsed)) + (rest-param (nth parsed 1))) + (let ((mac (make-macro params rest-param body env (symbol-name name-sym)))) + (env-set! env (symbol-name name-sym) mac) + mac)))) + +(define parse-macro-params + (fn (params-expr) + ;; Parse (a b &rest rest) → ((a b) rest) + (let ((params (list)) + (rest-param nil)) + (reduce + (fn (state p) + (if (and (= (type-of p) "symbol") (= (symbol-name p) "&rest")) + (assoc state "in-rest" true) + (if (get state "in-rest") + (do (set! rest-param (if (= (type-of p) "symbol") + (symbol-name p) p)) + state) + (do (append! params (if (= (type-of p) "symbol") + (symbol-name p) p)) + state)))) + (dict "in-rest" false) + params-expr) + (list params rest-param)))) + + +(define sf-begin + (fn (args env) + (if (empty? args) + nil + (do + (for-each + (fn (e) (trampoline (eval-expr e env))) + (slice args 0 (dec (len args)))) + (make-thunk (last args) env))))) + + +(define sf-quote + (fn (args env) + (if (empty? args) nil (first args)))) + + +(define sf-quasiquote + (fn (args env) + (qq-expand (first args) env))) + +(define qq-expand + (fn (template env) + (if (not (= (type-of template) "list")) + template + (if (empty? template) + (list) + (let ((head (first template))) + (if (and (= (type-of head) "symbol") (= (symbol-name head) "unquote")) + (trampoline (eval-expr (nth template 1) env)) + ;; Walk children, handling splice-unquote + (reduce + (fn (result item) + (if (and (= (type-of item) "list") + (= (len item) 2) + (= (type-of (first item)) "symbol") + (= (symbol-name (first item)) "splice-unquote")) + (let ((spliced (trampoline (eval-expr (nth item 1) env)))) + (if (= (type-of spliced) "list") + (concat result spliced) + (if (nil? spliced) result (append result spliced)))) + (append result (qq-expand item env)))) + (list) + template))))))) + + +(define sf-thread-first + (fn (args env) + (let ((val (trampoline (eval-expr (first args) env)))) + (reduce + (fn (result form) + (if (= (type-of form) "list") + (let ((f (trampoline (eval-expr (first form) env))) + (rest-args (map (fn (a) (trampoline (eval-expr a env))) + (rest form))) + (all-args (cons result rest-args))) + (cond + (and (callable? f) (not (lambda? f))) + (apply f all-args) + (lambda? f) + (trampoline (call-lambda f all-args env)) + :else (error (str "-> form not callable: " (inspect f))))) + (let ((f (trampoline (eval-expr form env)))) + (cond + (and (callable? f) (not (lambda? f))) + (f result) + (lambda? f) + (trampoline (call-lambda f (list result) env)) + :else (error (str "-> form not callable: " (inspect f))))))) + val + (rest args))))) + + +(define sf-set! + (fn (args env) + (let ((name (symbol-name (first args))) + (value (trampoline (eval-expr (nth args 1) env)))) + (env-set! env name value) + value))) + + +;; -------------------------------------------------------------------------- +;; 6b. Macro expansion +;; -------------------------------------------------------------------------- + +(define expand-macro + (fn (mac raw-args env) + (let ((local (env-merge (macro-closure mac) env))) + ;; Bind positional params (unevaluated) + (for-each + (fn (pair) + (env-set! local (first pair) + (if (< (nth pair 1) (len raw-args)) + (nth raw-args (nth pair 1)) + nil))) + (map-indexed (fn (i p) (list p i)) (macro-params mac))) + ;; Bind &rest param + (when (macro-rest-param mac) + (env-set! local (macro-rest-param mac) + (slice raw-args (len (macro-params mac))))) + ;; Evaluate body → new AST + (trampoline (eval-expr (macro-body mac) local))))) + + +;; -------------------------------------------------------------------------- +;; 7. Higher-order forms +;; -------------------------------------------------------------------------- + +(define ho-map + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (map (fn (item) (trampoline (call-lambda f (list item) env))) coll)))) + +(define ho-map-indexed + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (map-indexed + (fn (i item) (trampoline (call-lambda f (list i item) env))) + coll)))) + +(define ho-filter + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (filter + (fn (item) (trampoline (call-lambda f (list item) env))) + coll)))) + +(define ho-reduce + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (init (trampoline (eval-expr (nth args 1) env))) + (coll (trampoline (eval-expr (nth args 2) env)))) + (reduce + (fn (acc item) (trampoline (call-lambda f (list acc item) env))) + init + coll)))) + +(define ho-some + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (some + (fn (item) (trampoline (call-lambda f (list item) env))) + coll)))) + +(define ho-every + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (every? + (fn (item) (trampoline (call-lambda f (list item) env))) + coll)))) + + +;; -------------------------------------------------------------------------- +;; 8. Primitives — pure functions available in all targets +;; -------------------------------------------------------------------------- +;; These are the ~80 built-in functions. Each target implements them +;; natively but they MUST have identical semantics. This section serves +;; as the specification — bootstrap compilers use it for reference. +;; +;; Primitives are NOT defined here as SX lambdas (that would be circular). +;; Instead, this is a declarative registry that bootstrap compilers read. +;; -------------------------------------------------------------------------- + +;; See primitives.sx for the full specification. + + +;; -------------------------------------------------------------------------- +;; 9. Platform interface — must be provided by each target +;; -------------------------------------------------------------------------- +;; +;; Type inspection: +;; (type-of x) → "number" | "string" | "boolean" | "nil" +;; | "symbol" | "keyword" | "list" | "dict" +;; | "lambda" | "component" | "macro" | "thunk" +;; (symbol-name sym) → string +;; (keyword-name kw) → string +;; +;; Constructors: +;; (make-lambda params body env) → Lambda +;; (make-component name params has-children body env) → Component +;; (make-macro params rest-param body env name) → Macro +;; (make-thunk expr env) → Thunk +;; +;; Accessors: +;; (lambda-params f) → list of strings +;; (lambda-body f) → expr +;; (lambda-closure f) → env +;; (lambda-name f) → string or nil +;; (set-lambda-name! f n) → void +;; (component-params c) → list of strings +;; (component-body c) → expr +;; (component-closure c) → env +;; (component-has-children? c) → boolean +;; (macro-params m) → list of strings +;; (macro-rest-param m) → string or nil +;; (macro-body m) → expr +;; (macro-closure m) → env +;; (thunk? x) → boolean +;; (thunk-expr t) → expr +;; (thunk-env t) → env +;; +;; Predicates: +;; (callable? x) → boolean (native function or lambda) +;; (lambda? x) → boolean +;; (component? x) → boolean +;; (macro? x) → boolean +;; (primitive? name) → boolean (is name a registered primitive?) +;; (get-primitive name) → function +;; +;; Environment: +;; (env-has? env name) → boolean +;; (env-get env name) → value +;; (env-set! env name val) → void (mutating) +;; (env-extend env) → new env inheriting from env +;; (env-merge base overlay) → new env with overlay on top +;; +;; Mutation helpers (for parse-keyword-args): +;; (dict-set! d key val) → void +;; (dict-get d key) → value or nil +;; (append! lst val) → void (mutating append) +;; +;; Error: +;; (error msg) → raise/throw with message +;; (inspect x) → string representation for debugging +;; +;; Utility: +;; (strip-prefix s prefix) → string with prefix removed (or s unchanged) +;; (apply f args) → call f with args list +;; (zip lists...) → list of tuples +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx new file mode 100644 index 0000000..3ed6d17 --- /dev/null +++ b/shared/sx/ref/parser.sx @@ -0,0 +1,319 @@ +;; ========================================================================== +;; parser.sx — Reference SX parser specification +;; +;; Defines how SX source text is tokenized and parsed into AST. +;; The parser is intentionally simple — s-expressions need minimal parsing. +;; +;; Grammar: +;; program → expr* +;; expr → atom | list | quote-sugar +;; list → '(' expr* ')' +;; atom → string | number | keyword | symbol | boolean | nil +;; string → '"' (char | escape)* '"' +;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)? +;; keyword → ':' ident +;; symbol → ident +;; boolean → 'true' | 'false' +;; nil → 'nil' +;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]* +;; comment → ';' to end of line (discarded) +;; +;; Quote sugar (optional — not used in current SX): +;; '(expr) → (quote expr) +;; `(expr) → (quasiquote expr) +;; ~(expr) → (unquote expr) +;; ~@(expr) → (splice-unquote expr) +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Tokenizer +;; -------------------------------------------------------------------------- +;; Produces a flat stream of tokens from source text. +;; Each token is a (type value line col) tuple. + +(define tokenize + (fn (source) + (let ((pos 0) + (line 1) + (col 1) + (tokens (list)) + (len-src (len source))) + ;; Main loop — bootstrap compilers convert to while + (define scan-next + (fn () + (when (< pos len-src) + (let ((ch (nth source pos))) + (cond + ;; Whitespace — skip + (whitespace? ch) + (do (advance-pos!) (scan-next)) + + ;; Comment — skip to end of line + (= ch ";") + (do (skip-to-eol!) (scan-next)) + + ;; String + (= ch "\"") + (do (append! tokens (scan-string)) (scan-next)) + + ;; Open paren + (= ch "(") + (do (append! tokens (list "lparen" "(" line col)) + (advance-pos!) + (scan-next)) + + ;; Close paren + (= ch ")") + (do (append! tokens (list "rparen" ")" line col)) + (advance-pos!) + (scan-next)) + + ;; Open bracket (list sugar) + (= ch "[") + (do (append! tokens (list "lbracket" "[" line col)) + (advance-pos!) + (scan-next)) + + ;; Close bracket + (= ch "]") + (do (append! tokens (list "rbracket" "]" line col)) + (advance-pos!) + (scan-next)) + + ;; Keyword + (= ch ":") + (do (append! tokens (scan-keyword)) (scan-next)) + + ;; Number (or negative number) + (or (digit? ch) + (and (= ch "-") (< (inc pos) len-src) + (digit? (nth source (inc pos))))) + (do (append! tokens (scan-number)) (scan-next)) + + ;; Symbol + (ident-start? ch) + (do (append! tokens (scan-symbol)) (scan-next)) + + ;; Unknown — skip + :else + (do (advance-pos!) (scan-next))))))) + (scan-next) + tokens))) + + +;; -------------------------------------------------------------------------- +;; Token scanners (pseudo-code — each target implements natively) +;; -------------------------------------------------------------------------- + +(define scan-string + (fn () + ;; Scan from opening " to closing ", handling escape sequences. + ;; Returns ("string" value line col). + ;; Escape sequences: \" \\ \n \t \r + (let ((start-line line) + (start-col col) + (result "")) + (advance-pos!) ;; skip opening " + (define scan-str-loop + (fn () + (if (>= pos (len source)) + (error "Unterminated string") + (let ((ch (nth source pos))) + (cond + (= ch "\"") + (do (advance-pos!) nil) ;; done + (= ch "\\") + (do (advance-pos!) + (let ((esc (nth source pos))) + (set! result (str result + (case esc + "n" "\n" + "t" "\t" + "r" "\r" + :else esc))) + (advance-pos!) + (scan-str-loop))) + :else + (do (set! result (str result ch)) + (advance-pos!) + (scan-str-loop))))))) + (scan-str-loop) + (list "string" result start-line start-col)))) + + +(define scan-keyword + (fn () + ;; Scan :identifier + (let ((start-line line) (start-col col)) + (advance-pos!) ;; skip : + (let ((name (scan-ident-chars))) + (list "keyword" name start-line start-col))))) + + +(define scan-number + (fn () + ;; Scan integer or float literal + (let ((start-line line) (start-col col) (buf "")) + (when (= (nth source pos) "-") + (set! buf "-") + (advance-pos!)) + ;; Integer part + (define scan-digits + (fn () + (when (and (< pos (len source)) (digit? (nth source pos))) + (set! buf (str buf (nth source pos))) + (advance-pos!) + (scan-digits)))) + (scan-digits) + ;; Decimal part + (when (and (< pos (len source)) (= (nth source pos) ".")) + (set! buf (str buf ".")) + (advance-pos!) + (scan-digits)) + ;; Exponent + (when (and (< pos (len source)) + (or (= (nth source pos) "e") (= (nth source pos) "E"))) + (set! buf (str buf (nth source pos))) + (advance-pos!) + (when (and (< pos (len source)) + (or (= (nth source pos) "+") (= (nth source pos) "-"))) + (set! buf (str buf (nth source pos))) + (advance-pos!)) + (scan-digits)) + (list "number" (parse-number buf) start-line start-col)))) + + +(define scan-symbol + (fn () + ;; Scan identifier, check for true/false/nil + (let ((start-line line) + (start-col col) + (name (scan-ident-chars))) + (cond + (= name "true") (list "boolean" true start-line start-col) + (= name "false") (list "boolean" false start-line start-col) + (= name "nil") (list "nil" nil start-line start-col) + :else (list "symbol" name start-line start-col))))) + + +;; -------------------------------------------------------------------------- +;; Parser — tokens → AST +;; -------------------------------------------------------------------------- + +(define parse + (fn (tokens) + ;; Parse all top-level expressions from token stream. + (let ((pos 0) + (exprs (list))) + (define parse-loop + (fn () + (when (< pos (len tokens)) + (let ((result (parse-expr tokens))) + (append! exprs result) + (parse-loop))))) + (parse-loop) + exprs))) + + +(define parse-expr + (fn (tokens) + ;; Parse a single expression. + (let ((tok (nth tokens pos))) + (case (first tok) ;; token type + "lparen" + (do (set! pos (inc pos)) + (parse-list tokens "rparen")) + + "lbracket" + (do (set! pos (inc pos)) + (parse-list tokens "rbracket")) + + "string" (do (set! pos (inc pos)) (nth tok 1)) + "number" (do (set! pos (inc pos)) (nth tok 1)) + "boolean" (do (set! pos (inc pos)) (nth tok 1)) + "nil" (do (set! pos (inc pos)) nil) + + "keyword" + (do (set! pos (inc pos)) + (make-keyword (nth tok 1))) + + "symbol" + (do (set! pos (inc pos)) + (make-symbol (nth tok 1))) + + :else (error (str "Unexpected token: " (inspect tok))))))) + + +(define parse-list + (fn (tokens close-type) + ;; Parse expressions until close-type token. + (let ((items (list))) + (define parse-list-loop + (fn () + (if (>= pos (len tokens)) + (error "Unterminated list") + (if (= (first (nth tokens pos)) close-type) + (do (set! pos (inc pos)) nil) ;; done + (do (append! items (parse-expr tokens)) + (parse-list-loop)))))) + (parse-list-loop) + items))) + + +;; -------------------------------------------------------------------------- +;; Serializer — AST → SX source text +;; -------------------------------------------------------------------------- + +(define serialize + (fn (val) + (case (type-of val) + "nil" "nil" + "boolean" (if val "true" "false") + "number" (str val) + "string" (str "\"" (escape-string val) "\"") + "symbol" (symbol-name val) + "keyword" (str ":" (keyword-name val)) + "list" (str "(" (join " " (map serialize val)) ")") + "dict" (serialize-dict val) + "sx-expr" (sx-expr-source val) + :else (str val)))) + + +(define serialize-dict + (fn (d) + (str "(dict " + (join " " + (reduce + (fn (acc key) + (concat acc (list (str ":" key) (serialize (dict-get d key))))) + (list) + (keys d))) + ")"))) + + +;; -------------------------------------------------------------------------- +;; Platform parser interface +;; -------------------------------------------------------------------------- +;; +;; Character classification: +;; (whitespace? ch) → boolean +;; (digit? ch) → boolean +;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.) +;; (ident-char? ch) → boolean (ident-start + digits, ., :) +;; +;; Constructors: +;; (make-symbol name) → Symbol value +;; (make-keyword name) → Keyword value +;; (parse-number s) → number (int or float from string) +;; +;; String utilities: +;; (escape-string s) → string with " and \ escaped +;; (sx-expr-source e) → unwrap SxExpr to its source string +;; +;; Cursor state (mutable — each target manages its own way): +;; pos, line, col — current position in source +;; (advance-pos!) → increment pos, update line/col +;; (skip-to-eol!) → advance past end of line +;; (scan-ident-chars) → consume and return identifier string +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx new file mode 100644 index 0000000..05a9a9e --- /dev/null +++ b/shared/sx/ref/primitives.sx @@ -0,0 +1,428 @@ +;; ========================================================================== +;; primitives.sx — Specification of all SX built-in pure functions +;; +;; Each entry declares: name, parameter signature, and semantics. +;; Bootstrap compilers implement these natively per target. +;; +;; This file is a SPECIFICATION, not executable code. The define-primitive +;; form is a declarative macro that bootstrap compilers consume to generate +;; native primitive registrations. +;; +;; Format: +;; (define-primitive "name" +;; :params (param1 param2 &rest rest) +;; :returns "type" +;; :doc "description" +;; :body (reference-implementation ...)) +;; +;; The :body is optional — when provided, it gives a reference +;; implementation in SX that bootstrap compilers MAY use for testing +;; or as a fallback. Most targets will implement natively for performance. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Arithmetic +;; -------------------------------------------------------------------------- + +(define-primitive "+" + :params (&rest args) + :returns "number" + :doc "Sum all arguments." + :body (reduce (fn (a b) (native-add a b)) 0 args)) + +(define-primitive "-" + :params (a &rest b) + :returns "number" + :doc "Subtract. Unary: negate. Binary: a - b." + :body (if (empty? b) (native-neg a) (native-sub a (first b)))) + +(define-primitive "*" + :params (&rest args) + :returns "number" + :doc "Multiply all arguments." + :body (reduce (fn (a b) (native-mul a b)) 1 args)) + +(define-primitive "/" + :params (a b) + :returns "number" + :doc "Divide a by b." + :body (native-div a b)) + +(define-primitive "mod" + :params (a b) + :returns "number" + :doc "Modulo a % b." + :body (native-mod a b)) + +(define-primitive "sqrt" + :params (x) + :returns "number" + :doc "Square root.") + +(define-primitive "pow" + :params (x n) + :returns "number" + :doc "x raised to power n.") + +(define-primitive "abs" + :params (x) + :returns "number" + :doc "Absolute value.") + +(define-primitive "floor" + :params (x) + :returns "number" + :doc "Floor to integer.") + +(define-primitive "ceil" + :params (x) + :returns "number" + :doc "Ceiling to integer.") + +(define-primitive "round" + :params (x &rest ndigits) + :returns "number" + :doc "Round to ndigits decimal places (default 0).") + +(define-primitive "min" + :params (&rest args) + :returns "number" + :doc "Minimum. Single list arg or variadic.") + +(define-primitive "max" + :params (&rest args) + :returns "number" + :doc "Maximum. Single list arg or variadic.") + +(define-primitive "clamp" + :params (x lo hi) + :returns "number" + :doc "Clamp x to range [lo, hi]." + :body (max lo (min hi x))) + +(define-primitive "inc" + :params (n) + :returns "number" + :doc "Increment by 1." + :body (+ n 1)) + +(define-primitive "dec" + :params (n) + :returns "number" + :doc "Decrement by 1." + :body (- n 1)) + + +;; -------------------------------------------------------------------------- +;; Comparison +;; -------------------------------------------------------------------------- + +(define-primitive "=" + :params (a b) + :returns "boolean" + :doc "Equality (value equality, not identity).") + +(define-primitive "!=" + :params (a b) + :returns "boolean" + :doc "Inequality." + :body (not (= a b))) + +(define-primitive "<" + :params (a b) + :returns "boolean" + :doc "Less than.") + +(define-primitive ">" + :params (a b) + :returns "boolean" + :doc "Greater than.") + +(define-primitive "<=" + :params (a b) + :returns "boolean" + :doc "Less than or equal.") + +(define-primitive ">=" + :params (a b) + :returns "boolean" + :doc "Greater than or equal.") + + +;; -------------------------------------------------------------------------- +;; Predicates +;; -------------------------------------------------------------------------- + +(define-primitive "odd?" + :params (n) + :returns "boolean" + :doc "True if n is odd." + :body (= (mod n 2) 1)) + +(define-primitive "even?" + :params (n) + :returns "boolean" + :doc "True if n is even." + :body (= (mod n 2) 0)) + +(define-primitive "zero?" + :params (n) + :returns "boolean" + :doc "True if n is zero." + :body (= n 0)) + +(define-primitive "nil?" + :params (x) + :returns "boolean" + :doc "True if x is nil/null/None.") + +(define-primitive "number?" + :params (x) + :returns "boolean" + :doc "True if x is a number (int or float).") + +(define-primitive "string?" + :params (x) + :returns "boolean" + :doc "True if x is a string.") + +(define-primitive "list?" + :params (x) + :returns "boolean" + :doc "True if x is a list/array.") + +(define-primitive "dict?" + :params (x) + :returns "boolean" + :doc "True if x is a dict/map.") + +(define-primitive "empty?" + :params (coll) + :returns "boolean" + :doc "True if coll is nil or has length 0.") + +(define-primitive "contains?" + :params (coll key) + :returns "boolean" + :doc "True if coll contains key. Strings: substring check. Dicts: key check. Lists: membership.") + + +;; -------------------------------------------------------------------------- +;; Logic +;; -------------------------------------------------------------------------- + +(define-primitive "not" + :params (x) + :returns "boolean" + :doc "Logical negation. Note: and/or are special forms (short-circuit).") + + +;; -------------------------------------------------------------------------- +;; Strings +;; -------------------------------------------------------------------------- + +(define-primitive "str" + :params (&rest args) + :returns "string" + :doc "Concatenate all args as strings. nil → empty string, bool → true/false.") + +(define-primitive "concat" + :params (&rest colls) + :returns "list" + :doc "Concatenate multiple lists into one. Skips nil values.") + +(define-primitive "upper" + :params (s) + :returns "string" + :doc "Uppercase string.") + +(define-primitive "lower" + :params (s) + :returns "string" + :doc "Lowercase string.") + +(define-primitive "trim" + :params (s) + :returns "string" + :doc "Strip leading/trailing whitespace.") + +(define-primitive "split" + :params (s &rest sep) + :returns "list" + :doc "Split string by separator (default space).") + +(define-primitive "join" + :params (sep coll) + :returns "string" + :doc "Join collection items with separator string.") + +(define-primitive "replace" + :params (s old new) + :returns "string" + :doc "Replace all occurrences of old with new in s.") + +(define-primitive "slice" + :params (coll start &rest end) + :returns "any" + :doc "Slice a string or list from start to end (exclusive). End is optional.") + +(define-primitive "starts-with?" + :params (s prefix) + :returns "boolean" + :doc "True if string s starts with prefix.") + +(define-primitive "ends-with?" + :params (s suffix) + :returns "boolean" + :doc "True if string s ends with suffix.") + + +;; -------------------------------------------------------------------------- +;; Collections — construction +;; -------------------------------------------------------------------------- + +(define-primitive "list" + :params (&rest args) + :returns "list" + :doc "Create a list from arguments.") + +(define-primitive "dict" + :params (&rest pairs) + :returns "dict" + :doc "Create a dict from key/value pairs: (dict :a 1 :b 2).") + +(define-primitive "range" + :params (start end &rest step) + :returns "list" + :doc "Integer range [start, end) with optional step.") + + +;; -------------------------------------------------------------------------- +;; Collections — access +;; -------------------------------------------------------------------------- + +(define-primitive "get" + :params (coll key &rest default) + :returns "any" + :doc "Get value from dict by key, or list by index. Optional default.") + +(define-primitive "len" + :params (coll) + :returns "number" + :doc "Length of string, list, or dict.") + +(define-primitive "first" + :params (coll) + :returns "any" + :doc "First element, or nil if empty.") + +(define-primitive "last" + :params (coll) + :returns "any" + :doc "Last element, or nil if empty.") + +(define-primitive "rest" + :params (coll) + :returns "list" + :doc "All elements except the first.") + +(define-primitive "nth" + :params (coll n) + :returns "any" + :doc "Element at index n, or nil if out of bounds.") + +(define-primitive "cons" + :params (x coll) + :returns "list" + :doc "Prepend x to coll.") + +(define-primitive "append" + :params (coll x) + :returns "list" + :doc "Append x to end of coll (returns new list).") + +(define-primitive "chunk-every" + :params (coll n) + :returns "list" + :doc "Split coll into sub-lists of size n.") + +(define-primitive "zip-pairs" + :params (coll) + :returns "list" + :doc "Consecutive pairs: (1 2 3 4) → ((1 2) (2 3) (3 4)).") + + +;; -------------------------------------------------------------------------- +;; Collections — dict operations +;; -------------------------------------------------------------------------- + +(define-primitive "keys" + :params (d) + :returns "list" + :doc "List of dict keys.") + +(define-primitive "vals" + :params (d) + :returns "list" + :doc "List of dict values.") + +(define-primitive "merge" + :params (&rest dicts) + :returns "dict" + :doc "Merge dicts left to right. Later keys win. Skips nil.") + +(define-primitive "assoc" + :params (d &rest pairs) + :returns "dict" + :doc "Return new dict with key/value pairs added/overwritten.") + +(define-primitive "dissoc" + :params (d &rest keys) + :returns "dict" + :doc "Return new dict with keys removed.") + +(define-primitive "into" + :params (target coll) + :returns "any" + :doc "Pour coll into target. List target: convert to list. Dict target: convert pairs to dict.") + + +;; -------------------------------------------------------------------------- +;; Format helpers +;; -------------------------------------------------------------------------- + +(define-primitive "format-date" + :params (date-str fmt) + :returns "string" + :doc "Parse ISO date string and format with strftime-style format.") + +(define-primitive "format-decimal" + :params (val &rest places) + :returns "string" + :doc "Format number with fixed decimal places (default 2).") + +(define-primitive "parse-int" + :params (val &rest default) + :returns "number" + :doc "Parse string to integer with optional default on failure.") + + +;; -------------------------------------------------------------------------- +;; Text helpers +;; -------------------------------------------------------------------------- + +(define-primitive "pluralize" + :params (count &rest forms) + :returns "string" + :doc "Pluralize: (pluralize 1) → \"\", (pluralize 2) → \"s\". Or (pluralize n \"item\" \"items\").") + +(define-primitive "escape" + :params (s) + :returns "string" + :doc "HTML-escape a string (&, <, >, \", ').") + +(define-primitive "strip-tags" + :params (s) + :returns "string" + :doc "Remove HTML tags from string.") diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx new file mode 100644 index 0000000..0e118bd --- /dev/null +++ b/shared/sx/ref/render.sx @@ -0,0 +1,333 @@ +;; ========================================================================== +;; render.sx — Reference rendering specification +;; +;; Defines how evaluated SX expressions become output (DOM nodes, HTML +;; strings, or SX wire format). Each target provides a renderer adapter +;; that implements the platform-specific output operations. +;; +;; Three rendering modes (matching the Python/JS implementations): +;; +;; 1. render-to-dom — produces DOM nodes (browser only) +;; 2. render-to-html — produces HTML string (server) +;; 3. render-to-sx — produces SX wire format (server → client) +;; +;; This file specifies the LOGIC of rendering. Platform-specific +;; operations are declared as interfaces at the bottom. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; HTML tag registry +;; -------------------------------------------------------------------------- +;; Tags known to the renderer. Unknown names are treated as function calls. +;; Void elements self-close (no children). Boolean attrs emit name only. + +(define HTML_TAGS + (list + ;; Document + "html" "head" "body" "title" "meta" "link" "script" "style" "noscript" + ;; Sections + "header" "nav" "main" "section" "article" "aside" "footer" + "h1" "h2" "h3" "h4" "h5" "h6" "hgroup" + ;; Block + "div" "p" "blockquote" "pre" "figure" "figcaption" "address" "details" "summary" + ;; Inline + "a" "span" "em" "strong" "small" "b" "i" "u" "s" "mark" "sub" "sup" + "abbr" "cite" "code" "time" "br" "wbr" "hr" + ;; Lists + "ul" "ol" "li" "dl" "dt" "dd" + ;; Tables + "table" "thead" "tbody" "tfoot" "tr" "th" "td" "caption" "colgroup" "col" + ;; Forms + "form" "input" "textarea" "select" "option" "optgroup" "button" "label" + "fieldset" "legend" "output" "datalist" + ;; Media + "img" "video" "audio" "source" "picture" "canvas" "iframe" + ;; SVG + "svg" "path" "circle" "rect" "line" "polyline" "polygon" "text" + "g" "defs" "use" "clipPath" "mask" "pattern" "linearGradient" + "radialGradient" "stop" "filter" "feGaussianBlur" "feOffset" + "feBlend" "feColorMatrix" "feComposite" "feMerge" "feMergeNode" + "animate" "animateTransform" "foreignObject" + ;; Other + "template" "slot" "dialog" "menu")) + +(define VOID_ELEMENTS + (list "area" "base" "br" "col" "embed" "hr" "img" "input" + "link" "meta" "param" "source" "track" "wbr")) + +(define BOOLEAN_ATTRS + (list "disabled" "checked" "selected" "readonly" "required" "hidden" + "autofocus" "autoplay" "controls" "loop" "muted" "defer" "async" + "novalidate" "formnovalidate" "multiple" "open" "allowfullscreen")) + + +;; -------------------------------------------------------------------------- +;; render-to-html — server-side HTML rendering +;; -------------------------------------------------------------------------- + +(define render-to-html + (fn (expr env) + (let ((result (trampoline (eval-expr expr env)))) + (render-value-to-html result env)))) + +(define render-value-to-html + (fn (val env) + (case (type-of val) + "nil" "" + "string" (escape-html val) + "number" (str val) + "boolean" (if val "true" "false") + "list" (render-list-to-html val env) + "raw-html" (raw-html-content val) + :else (escape-html (str val))))) + +(define render-list-to-html + (fn (expr env) + (if (empty? expr) + "" + (let ((head (first expr))) + (if (not (= (type-of head) "symbol")) + ;; Data list — render each item + (join "" (map (fn (x) (render-value-to-html x env)) expr)) + (let ((name (symbol-name head)) + (args (rest expr))) + (cond + ;; Fragment + (= name "<>") + (join "" (map (fn (x) (render-to-html x env)) args)) + + ;; Raw HTML passthrough + (= name "raw!") + (join "" (map (fn (x) (str (trampoline (eval-expr x env)))) args)) + + ;; HTML tag + (contains? HTML_TAGS name) + (render-html-element name args env) + + ;; Component call (~name) + (starts-with? name "~") + (let ((comp (env-get env name))) + (if (component? comp) + (render-to-html + (trampoline (call-component comp args env)) + env) + (error (str "Unknown component: " name)))) + + ;; Macro expansion + (and (env-has? env name) (macro? (env-get env name))) + (render-to-html + (trampoline + (eval-expr + (expand-macro (env-get env name) args env) + env)) + env) + + ;; Special form / function call — evaluate then render result + :else + (render-value-to-html + (trampoline (eval-expr expr env)) + env)))))))) + + +(define render-html-element + (fn (tag args env) + (let ((parsed (parse-element-args args env)) + (attrs (first parsed)) + (children (nth parsed 1)) + (is-void (contains? VOID_ELEMENTS tag))) + (str "<" tag + (render-attrs attrs) + (if is-void + " />" + (str ">" + (join "" (map (fn (c) (render-to-html c env)) children)) + "")))))) + + +(define parse-element-args + (fn (args env) + ;; Parse (:key val :key2 val2 child1 child2) into (attrs-dict children-list) + (let ((attrs (dict)) + (children (list))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (trampoline (eval-expr (nth args (inc (get state "i"))) env)))) + (dict-set! attrs (keyword-name arg) val) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + (list attrs children)))) + + +(define render-attrs + (fn (attrs) + (join "" + (map + (fn (key) + (let ((val (dict-get attrs key))) + (cond + ;; Boolean attrs + (and (contains? BOOLEAN_ATTRS key) val) + (str " " key) + (and (contains? BOOLEAN_ATTRS key) (not val)) + "" + ;; Nil values — skip + (nil? val) "" + ;; Normal attr + :else (str " " key "=\"" (escape-attr (str val)) "\"")))) + (keys attrs))))) + + +;; -------------------------------------------------------------------------- +;; render-to-sx — server-side SX wire format (for client rendering) +;; -------------------------------------------------------------------------- +;; This mode serializes the expression as SX source text. +;; Component calls are NOT expanded — they're sent to the client. +;; HTML tags are serialized as-is. Special forms are evaluated. + +(define render-to-sx + (fn (expr env) + (let ((result (aser expr env))) + (serialize result)))) + +(define aser + (fn (expr env) + ;; Evaluate for SX wire format — serialize rendering forms, + ;; evaluate control flow and function calls. + (case (type-of expr) + "number" expr + "string" expr + "boolean" expr + "nil" nil + + "symbol" + (let ((name (symbol-name expr))) + (cond + (env-has? env name) (env-get env name) + (primitive? name) (get-primitive name) + (= name "true") true + (= name "false") false + (= name "nil") nil + :else (error (str "Undefined symbol: " name)))) + + "keyword" (keyword-name expr) + + "list" + (if (empty? expr) + (list) + (aser-list expr env)) + + :else expr))) + + +(define aser-list + (fn (expr env) + (let ((head (first expr)) + (args (rest expr))) + (if (not (= (type-of head) "symbol")) + (map (fn (x) (aser x env)) expr) + (let ((name (symbol-name head))) + (cond + ;; Fragment — serialize children + (= name "<>") + (aser-fragment args env) + + ;; Component call — serialize WITHOUT expanding + (starts-with? name "~") + (aser-call name args env) + + ;; HTML tag — serialize + (contains? HTML_TAGS name) + (aser-call name args env) + + ;; Special/HO forms — evaluate (produces data) + (or (special-form? name) (ho-form? name)) + (aser-special name expr env) + + ;; Macro — expand then aser + (and (env-has? env name) (macro? (env-get env name))) + (aser (expand-macro (env-get env name) args env) env) + + ;; Function call — evaluate fully + :else + (let ((f (trampoline (eval-expr head env))) + (evaled-args (map (fn (a) (trampoline (eval-expr a env))) args))) + (cond + (and (callable? f) (not (lambda? f)) (not (component? f))) + (apply f evaled-args) + (lambda? f) + (trampoline (call-lambda f evaled-args env)) + (component? f) + (aser-call (str "~" (component-name f)) args env) + :else (error (str "Not callable: " (inspect f))))))))))) + + +(define aser-fragment + (fn (children env) + ;; Serialize (<> child1 child2 ...) to sx source string + (let ((parts (filter + (fn (x) (not (nil? x))) + (map (fn (c) (aser c env)) children)))) + (if (empty? parts) + "" + (str "(<> " (join " " (map serialize parts)) ")"))))) + + +(define aser-call + (fn (name args env) + ;; Serialize (name :key val child ...) — evaluate args but keep as sx + (let ((parts (list name))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (aser (nth args (inc (get state "i"))) env))) + (when (not (nil? val)) + (append! parts (str ":" (keyword-name arg))) + (append! parts (serialize val))) + (assoc state "skip" true "i" (inc (get state "i")))) + (let ((val (aser arg env))) + (when (not (nil? val)) + (append! parts (serialize val))) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + (str "(" (join " " parts) ")")))) + + +;; -------------------------------------------------------------------------- +;; Platform rendering interface +;; -------------------------------------------------------------------------- +;; +;; HTML rendering (server targets): +;; (escape-html s) → HTML-escaped string +;; (escape-attr s) → attribute-value-escaped string +;; (raw-html-content r) → unwrap RawHTML marker to string +;; +;; DOM rendering (browser target): +;; (create-element tag) → DOM Element +;; (create-text-node s) → DOM Text +;; (create-fragment) → DOM DocumentFragment +;; (set-attribute el k v) → void +;; (append-child parent c) → void +;; +;; Serialization: +;; (serialize val) → SX source string representation of val +;; +;; Form classification: +;; (special-form? name) → boolean +;; (ho-form? name) → boolean +;; (aser-special name expr env) → evaluate special/HO form through aser +;; -------------------------------------------------------------------------- From 8c69e329e08ec90d50e4fbc38fc4a1f302c675a3 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 09:37:07 +0000 Subject: [PATCH 05/24] Fix dict kwarg evaluation in renderComponentDOM, no-cache static in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dict values (e.g. {:X-CSRFToken csrf}) passed as component kwargs were not being evaluated through sxEval — symbols stayed unresolved in the DOM. Also add Cache-Control: no-cache headers for /static/ in dev mode so browser always fetches fresh JS/CSS without needing hard refresh. Co-Authored-By: Claude Opus 4.6 --- shared/infrastructure/factory.py | 8 ++++++++ shared/static/scripts/sx.js | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 188f581..601012f 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -341,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 diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 070f0bc..87efa7b 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -1286,8 +1286,8 @@ kwargs[args[i].name] = sxEval(v, env); } } else { - // Data arrays, dicts, etc — pass through as-is - kwargs[args[i].name] = v; + // Data arrays, dicts, etc — evaluate in caller's env + kwargs[args[i].name] = sxEval(v, env); } i += 2; } else { From e1ae81f736366b4896a3eced32043cb3be1c9a7d Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 09:58:48 +0000 Subject: [PATCH 06/24] =?UTF-8?q?Add=20bootstrap=20compiler:=20reference?= =?UTF-8?q?=20SX=20spec=20=E2=86=92=20JavaScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap_js.py reads the reference .sx specification (eval.sx, render.sx) and transpiles the defined evaluator functions into standalone JavaScript. The output sx-ref.js is a fully functional SX evaluator bootstrapped from the s-expression spec, comparable against the hand-written sx.js. Key features: - JSEmitter class transpiles SX AST → JS (fn→function, let→IIFE, cond→ternary, etc.) - Platform interface (types, env ops, primitives) implemented as native JS - Post-transpilation fixup wraps callLambda to handle both Lambda objects and primitives - 93/93 tests passing: arithmetic, strings, control flow, closures, HO forms, components, macros, threading, dict ops, predicates Fixed during development: - Bool before int isinstance check (Python bool is subclass of int) - SX NIL sentinel detection (not Python None) - Cond style detection (determine Scheme vs Clojure once, not per-pair) - Predicate null safety (x != null instead of x && to avoid 0-as-falsy in SX) Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 949 +++++++++++++++++++++++++++ shared/sx/ref/bootstrap_js.py | 1070 +++++++++++++++++++++++++++++++ 2 files changed, 2019 insertions(+) create mode 100644 shared/static/scripts/sx-ref.js create mode 100644 shared/sx/ref/bootstrap_js.py diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js new file mode 100644 index 0000000..324f0d9 --- /dev/null +++ b/shared/static/scripts/sx-ref.js @@ -0,0 +1,949 @@ +/** + * sx-ref.js — Generated from reference SX evaluator specification. + * + * Bootstrap-compiled from shared/sx/ref/{eval,render,primitives}.sx + * Compare against hand-written sx.js for correctness verification. + * + * DO NOT EDIT — regenerate with: python bootstrap_js.py + */ +;(function(global) { + "use strict"; + + // ========================================================================= + // Types + // ========================================================================= + + var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function Symbol(name) { this.name = name; } + Symbol.prototype.toString = function() { return this.name; }; + Symbol.prototype._sym = true; + + function Keyword(name) { this.name = name; } + Keyword.prototype.toString = function() { return ":" + this.name; }; + Keyword.prototype._kw = true; + + function Lambda(params, body, closure, name) { + this.params = params; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Lambda.prototype._lambda = true; + + function Component(name, params, hasChildren, body, closure) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + } + Component.prototype._component = true; + + function Macro(params, restParam, body, closure, name) { + this.params = params; + this.restParam = restParam; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Macro.prototype._macro = true; + + function Thunk(expr, env) { this.expr = expr; this.env = env; } + Thunk.prototype._thunk = true; + + function RawHTML(html) { this.html = html; } + RawHTML.prototype._raw = true; + + function isSym(x) { return x != null && x._sym === true; } + function isKw(x) { return x != null && x._kw === true; } + + function merge() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { + var d = arguments[i]; + if (d) for (var k in d) out[k] = d[k]; + } + return out; + } + + function sxOr() { + for (var i = 0; i < arguments.length; i++) { + if (isSxTruthy(arguments[i])) return arguments[i]; + } + return arguments.length ? arguments[arguments.length - 1] : false; + } + + // ========================================================================= + // Platform interface — JS implementation + // ========================================================================= + + function typeOf(x) { + if (isNil(x)) return "nil"; + if (typeof x === "number") return "number"; + if (typeof x === "string") return "string"; + if (typeof x === "boolean") return "boolean"; + if (x._sym) return "symbol"; + if (x._kw) return "keyword"; + if (x._thunk) return "thunk"; + if (x._lambda) return "lambda"; + if (x._component) return "component"; + if (x._macro) return "macro"; + if (x._raw) return "raw-html"; + if (Array.isArray(x)) return "list"; + if (typeof x === "object") return "dict"; + return "unknown"; + } + + function symbolName(s) { return s.name; } + function keywordName(k) { return k.name; } + function makeSymbol(n) { return new Symbol(n); } + function makeKeyword(n) { return new Keyword(n); } + + function makeLambda(params, body, env) { return new Lambda(params, body, merge(env)); } + function makeComponent(name, params, hasChildren, body, env) { + return new Component(name, params, hasChildren, body, merge(env)); + } + function makeMacro(params, restParam, body, env, name) { + return new Macro(params, restParam, body, merge(env), name); + } + function makeThunk(expr, env) { return new Thunk(expr, env); } + + function lambdaParams(f) { return f.params; } + function lambdaBody(f) { return f.body; } + function lambdaClosure(f) { return f.closure; } + function lambdaName(f) { return f.name; } + function setLambdaName(f, n) { f.name = n; } + + function componentParams(c) { return c.params; } + function componentBody(c) { return c.body; } + function componentClosure(c) { return c.closure; } + function componentHasChildren(c) { return c.hasChildren; } + function componentName(c) { return c.name; } + + function macroParams(m) { return m.params; } + function macroRestParam(m) { return m.restParam; } + function macroBody(m) { return m.body; } + function macroClosure(m) { return m.closure; } + + function isThunk(x) { return x != null && x._thunk === true; } + function thunkExpr(t) { return t.expr; } + function thunkEnv(t) { return t.env; } + + function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } + function isLambda(x) { return x != null && x._lambda === true; } + function isComponent(x) { return x != null && x._component === true; } + function isMacro(x) { return x != null && x._macro === true; } + + function envHas(env, name) { return name in env; } + function envGet(env, name) { return env[name]; } + function envSet(env, name, val) { env[name] = val; } + function envExtend(env) { return merge(env); } + function envMerge(base, overlay) { return merge(base, overlay); } + + function dictSet(d, k, v) { d[k] = v; } + function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; } + + function stripPrefix(s, prefix) { + return s.indexOf(prefix) === 0 ? s.slice(prefix.length) : s; + } + + function error(msg) { throw new Error(msg); } + function inspect(x) { return JSON.stringify(x); } + + // ========================================================================= + // Primitives + // ========================================================================= + + var PRIMITIVES = {}; + + // Arithmetic + PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; }; + PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; }; + PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; }; + PRIMITIVES["/"] = function(a, b) { return a / b; }; + PRIMITIVES["mod"] = function(a, b) { return a % b; }; + PRIMITIVES["inc"] = function(n) { return n + 1; }; + PRIMITIVES["dec"] = function(n) { return n - 1; }; + PRIMITIVES["abs"] = Math.abs; + PRIMITIVES["floor"] = Math.floor; + PRIMITIVES["ceil"] = Math.ceil; + PRIMITIVES["round"] = Math.round; + PRIMITIVES["min"] = Math.min; + PRIMITIVES["max"] = Math.max; + PRIMITIVES["sqrt"] = Math.sqrt; + PRIMITIVES["pow"] = Math.pow; + PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }; + + // Comparison + PRIMITIVES["="] = function(a, b) { return a == b; }; + PRIMITIVES["!="] = function(a, b) { return a != b; }; + PRIMITIVES["<"] = function(a, b) { return a < b; }; + PRIMITIVES[">"] = function(a, b) { return a > b; }; + PRIMITIVES["<="] = function(a, b) { return a <= b; }; + PRIMITIVES[">="] = function(a, b) { return a >= b; }; + + // Logic + PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); }; + + // String + PRIMITIVES["str"] = function() { + var p = []; + for (var i = 0; i < arguments.length; i++) { + var v = arguments[i]; if (isNil(v)) continue; p.push(String(v)); + } + return p.join(""); + }; + PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); }; + PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); }; + PRIMITIVES["trim"] = function(s) { return String(s).trim(); }; + PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; + PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; + PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; + PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; + PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; + PRIMITIVES["concat"] = function() { + var out = []; + for (var i = 0; i < arguments.length; i++) if (arguments[i]) out = out.concat(arguments[i]); + return out; + }; + PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; + + // Predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; + + // Collections + PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; + PRIMITIVES["dict"] = function() { + var d = {}; + for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; + return d; + }; + PRIMITIVES["range"] = function(a, b, step) { + var r = []; step = step || 1; + for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); + return r; + }; + PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; + PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; + PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; + PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; + PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; + PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; + PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); }; + PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; }; + PRIMITIVES["merge"] = function() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } + return out; + }; + PRIMITIVES["assoc"] = function(d) { + var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; + return out; + }; + PRIMITIVES["dissoc"] = function(d) { + var out = {}; for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; + return out; + }; + PRIMITIVES["chunk-every"] = function(c, n) { + var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; + }; + PRIMITIVES["zip-pairs"] = function(c) { + var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r; + }; + PRIMITIVES["into"] = function(target, coll) { + if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll); + var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } + return r; + }; + + // Format + PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); }; + PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; }; + PRIMITIVES["pluralize"] = function(n, s, p) { + if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s"); + return n == 1 ? "" : "s"; + }; + PRIMITIVES["escape"] = function(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + }; + + function isPrimitive(name) { return name in PRIMITIVES; } + function getPrimitive(name) { return PRIMITIVES[name]; } + + // Higher-order helpers used by the transpiled code + function map(fn, coll) { return coll.map(fn); } + function mapIndexed(fn, coll) { return coll.map(function(item, i) { return fn(i, item); }); } + function filter(fn, coll) { return coll.filter(function(x) { return isSxTruthy(fn(x)); }); } + function reduce(fn, init, coll) { + var acc = init; + for (var i = 0; i < coll.length; i++) acc = fn(acc, coll[i]); + return acc; + } + function some(fn, coll) { + for (var i = 0; i < coll.length; i++) { var r = fn(coll[i]); if (isSxTruthy(r)) return r; } + return NIL; + } + function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } + + // List primitives used directly by transpiled code + var len = PRIMITIVES["len"]; + var first = PRIMITIVES["first"]; + var last = PRIMITIVES["last"]; + var rest = PRIMITIVES["rest"]; + var nth = PRIMITIVES["nth"]; + var cons = PRIMITIVES["cons"]; + var append = PRIMITIVES["append"]; + var isEmpty = PRIMITIVES["empty?"]; + var contains = PRIMITIVES["contains?"]; + var startsWith = PRIMITIVES["starts-with?"]; + var slice = PRIMITIVES["slice"]; + var concat = PRIMITIVES["concat"]; + var str = PRIMITIVES["str"]; + var join = PRIMITIVES["join"]; + var keys = PRIMITIVES["keys"]; + var get = PRIMITIVES["get"]; + var assoc = PRIMITIVES["assoc"]; + var range = PRIMITIVES["range"]; + function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; } + function append_b(arr, x) { arr.push(x); return arr; } + var apply = function(f, args) { return f.apply(null, args); }; + + // HTML rendering helpers + function escapeHtml(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function escapeAttr(s) { return escapeHtml(s); } + function rawHtmlContent(r) { return r.html; } + + // Serializer + function serialize(val) { + if (isNil(val)) return "nil"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (typeof val === "string") return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + if (isSym(val)) return val.name; + if (isKw(val)) return ":" + val.name; + if (Array.isArray(val)) return "(" + val.map(serialize).join(" ") + ")"; + return String(val); + } + + function isSpecialForm(n) { return n in { + "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "quote":1,"quasiquote":1,"->":1,"set!":1 + }; } + function isHoForm(n) { return n in { + "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 + }; } + + // === Transpiled from eval.sx === + + // trampoline + var trampoline = function(val) { return (function() { + var result = val; + return (isSxTruthy(isThunk(result)) ? trampoline(evalExpr(thunkExpr(result), thunkEnv(result))) : result); +})(); }; + + // eval-expr + var evalExpr = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { + var name = symbolName(expr); + return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); +})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return [k, trampoline(evalExpr(v, env))]; }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; + + // eval-list + var evalList = function(expr, env) { return (function() { + var head = first(expr); + var args = rest(expr); + return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { + var name = symbolName(head); + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + var mac = envGet(env, name); + return makeThunk(expandMacro(mac, args, env), env); +})() : evalCall(head, args, env)))))))))))))))))))))))))))); +})() : evalCall(head, args, env))); +})(); }; + + // eval-call + var evalCall = function(head, args, env) { return (function() { + var f = trampoline(evalExpr(head, env)); + var evaluatedArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); + return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f))))))); +})(); }; + + // call-lambda + var callLambda = function(f, args, callerEnv) { return (function() { + var params = lambdaParams(f); + var local = envMerge(lambdaClosure(f), callerEnv); + return (isSxTruthy((len(args) != len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), makeThunk(lambdaBody(f), local))); +})(); }; + + // call-component + var callComponent = function(comp, rawArgs, env) { return (function() { + var parsed = parseKeywordArgs(rawArgs, env); + var kwargs = first(parsed); + var children = nth(parsed, 1); + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = sxOr(dictGet(kwargs, p), NIL); } } + if (isSxTruthy(componentHasChildren(comp))) { + local["children"] = children; +} + return makeThunk(componentBody(comp), local); +})(); }; + + // parse-keyword-args + var parseKeywordArgs = function(rawArgs, env) { return (function() { + var kwargs = {}; + var children = []; + var i = 0; + reduce(function(state, arg) { return (function() { + var idx = get(state, "i"); + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (idx + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((idx + 1) < len(rawArgs)))) ? (dictSet(kwargs, keywordName(arg), trampoline(evalExpr(nth(rawArgs, (idx + 1)), env))), assoc(state, "skip", true, "i", (idx + 1))) : (append_b(children, trampoline(evalExpr(arg, env))), assoc(state, "i", (idx + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, rawArgs); + return [kwargs, children]; +})(); }; + + // sf-if + var sfIf = function(args, env) { return (function() { + var condition = trampoline(evalExpr(first(args), env)); + return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL)); +})(); }; + + // sf-when + var sfWhen = function(args, env) { return (function() { + var condition = trampoline(evalExpr(first(args), env)); + return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL); +})(); }; + + // sf-cond + var sfCond = function(args, env) { return (isSxTruthy((isSxTruthy((typeOf(first(args)) == "list")) && (len(first(args)) == 2))) ? sfCondScheme(args, env) : sfCondClojure(args, env)); }; + + // sf-cond-scheme + var sfCondScheme = function(clauses, env) { return (isSxTruthy(isEmpty(clauses)) ? NIL : (function() { + var clause = first(clauses); + var test = first(clause); + var body = nth(clause, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))), (isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")))) ? makeThunk(body, env) : (isSxTruthy(trampoline(evalExpr(test, env))) ? makeThunk(body, env) : sfCondScheme(rest(clauses), env))); +})()); }; + + // sf-cond-clojure + var sfCondClojure = function(clauses, env) { return (isSxTruthy((len(clauses) < 2)) ? NIL : (function() { + var test = first(clauses); + var body = nth(clauses, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")), (isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))))) ? makeThunk(body, env) : (isSxTruthy(trampoline(evalExpr(test, env))) ? makeThunk(body, env) : sfCondClojure(slice(clauses, 2), env))); +})()); }; + + // sf-case + var sfCase = function(args, env) { return (function() { + var matchVal = trampoline(evalExpr(first(args), env)); + var clauses = rest(args); + return sfCaseLoop(matchVal, clauses, env); +})(); }; + + // sf-case-loop + var sfCaseLoop = function(matchVal, clauses, env) { return (isSxTruthy((len(clauses) < 2)) ? NIL : (function() { + var test = first(clauses); + var body = nth(clauses, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")), (isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))))) ? makeThunk(body, env) : (isSxTruthy((matchVal == trampoline(evalExpr(test, env)))) ? makeThunk(body, env) : sfCaseLoop(matchVal, slice(clauses, 2), env))); +})()); }; + + // sf-and + var sfAnd = function(args, env) { return (isSxTruthy(isEmpty(args)) ? true : (function() { + var val = trampoline(evalExpr(first(args), env)); + return (isSxTruthy(!val) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env))); +})()); }; + + // sf-or + var sfOr = function(args, env) { return (isSxTruthy(isEmpty(args)) ? false : (function() { + var val = trampoline(evalExpr(first(args), env)); + return (isSxTruthy(val) ? val : sfOr(rest(args), env)); +})()); }; + + // sf-let + var sfLet = function(args, env) { return (function() { + var bindings = first(args); + var body = rest(args); + var local = envExtend(env); + (isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { return (function() { + var vname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding)); + return envSet(local, vname, trampoline(evalExpr(nth(binding, 1), local))); +})(); }, bindings) : (function() { + var i = 0; + return reduce(function(acc, pairIdx) { return (function() { + var vname = (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2))); + var valExpr = nth(bindings, ((pairIdx * 2) + 1)); + return envSet(local, vname, trampoline(evalExpr(valExpr, local))); +})(); }, NIL, range(0, (len(bindings) / 2))); +})()); + { var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } } + return makeThunk(last(body), local); +})(); }; + + // sf-lambda + var sfLambda = function(args, env) { return (function() { + var paramsExpr = first(args); + var body = nth(args, 1); + var paramNames = map(function(p) { return (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p); }, paramsExpr); + return makeLambda(paramNames, body, env); +})(); }; + + // sf-define + var sfDefine = function(args, env) { return (function() { + var nameSym = first(args); + var value = trampoline(evalExpr(nth(args, 1), env)); + if (isSxTruthy((isSxTruthy(isLambda(value)) && isNil(lambdaName(value))))) { + value.name = symbolName(nameSym); +} + env[symbolName(nameSym)] = value; + return value; +})(); }; + + // sf-defcomp + var sfDefcomp = function(args, env) { return (function() { + var nameSym = first(args); + var paramsRaw = nth(args, 1); + var body = nth(args, 2); + var compName = stripPrefix(symbolName(nameSym), "~"); + var parsed = parseCompParams(paramsRaw); + var params = first(parsed); + var hasChildren = nth(parsed, 1); + return (function() { + var comp = makeComponent(compName, params, hasChildren, body, env); + env[symbolName(nameSym)] = comp; + return comp; +})(); +})(); }; + + // parse-comp-params + var parseCompParams = function(paramsExpr) { return (function() { + var params = []; + var hasChildren = false; + var inKey = false; + { var _c = paramsExpr; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; if (isSxTruthy((typeOf(p) == "symbol"))) { + (function() { + var name = symbolName(p); + return (isSxTruthy((name == "&key")) ? (inKey = true) : (isSxTruthy((name == "&rest")) ? (hasChildren = true) : (isSxTruthy((isSxTruthy(inKey) && !hasChildren)) ? append_b(params, name) : append_b(params, name)))); +})(); +} } } + return [params, hasChildren]; +})(); }; + + // sf-defmacro + var sfDefmacro = function(args, env) { return (function() { + var nameSym = first(args); + var paramsRaw = nth(args, 1); + var body = nth(args, 2); + var parsed = parseMacroParams(paramsRaw); + var params = first(parsed); + var restParam = nth(parsed, 1); + return (function() { + var mac = makeMacro(params, restParam, body, env, symbolName(nameSym)); + env[symbolName(nameSym)] = mac; + return mac; +})(); +})(); }; + + // parse-macro-params + var parseMacroParams = function(paramsExpr) { return (function() { + var params = []; + var restParam = NIL; + reduce(function(state, p) { return (isSxTruthy((isSxTruthy((typeOf(p) == "symbol")) && (symbolName(p) == "&rest"))) ? assoc(state, "in-rest", true) : (isSxTruthy(get(state, "in-rest")) ? ((restParam = (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p)), state) : (append_b(params, (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p)), state))); }, {["in-rest"]: false}, paramsExpr); + return [params, restParam]; +})(); }; + + // sf-begin + var sfBegin = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 0, (len(args) - 1))), makeThunk(last(args), env))); }; + + // sf-quote + var sfQuote = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : first(args)); }; + + // sf-quasiquote + var sfQuasiquote = function(args, env) { return qqExpand(first(args), env); }; + + // qq-expand + var qqExpand = function(template, env) { return (isSxTruthy(!(typeOf(template) == "list")) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() { + var head = first(template); + return (isSxTruthy((isSxTruthy((typeOf(head) == "symbol")) && (symbolName(head) == "unquote"))) ? trampoline(evalExpr(nth(template, 1), env)) : reduce(function(result, item) { return (isSxTruthy((isSxTruthy((typeOf(item) == "list")) && isSxTruthy((len(item) == 2)) && isSxTruthy((typeOf(first(item)) == "symbol")) && (symbolName(first(item)) == "splice-unquote"))) ? (function() { + var spliced = trampoline(evalExpr(nth(item, 1), env)); + return (isSxTruthy((typeOf(spliced) == "list")) ? concat(result, spliced) : (isSxTruthy(isNil(spliced)) ? result : append(result, spliced))); +})() : append(result, qqExpand(item, env))); }, [], template)); +})())); }; + + // sf-thread-first + var sfThreadFirst = function(args, env) { return (function() { + var val = trampoline(evalExpr(first(args), env)); + return reduce(function(result, form) { return (isSxTruthy((typeOf(form) == "list")) ? (function() { + var f = trampoline(evalExpr(first(form), env)); + var restArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, rest(form)); + var allArgs = cons(result, restArgs); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f)))))); +})() : (function() { + var f = trampoline(evalExpr(form, env)); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f)))))); +})()); }, val, rest(args)); +})(); }; + + // sf-set! + var sfSetBang = function(args, env) { return (function() { + var name = symbolName(first(args)); + var value = trampoline(evalExpr(nth(args, 1), env)); + env[name] = value; + return value; +})(); }; + + // expand-macro + var expandMacro = function(mac, rawArgs, env) { return (function() { + var local = envMerge(macroClosure(mac), env); + { var _c = mapIndexed(function(i, p) { return [p, i]; }, macroParams(mac)); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = (isSxTruthy((nth(pair, 1) < len(rawArgs))) ? nth(rawArgs, nth(pair, 1)) : NIL); } } + if (isSxTruthy(macroRestParam(mac))) { + local[macroRestParam(mac)] = slice(rawArgs, len(macroParams(mac))); +} + return trampoline(evalExpr(macroBody(mac), local)); +})(); }; + + // ho-map + var hoMap = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return map(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-map-indexed + var hoMapIndexed = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return mapIndexed(function(i, item) { return trampoline(callLambda(f, [i, item], env)); }, coll); +})(); }; + + // ho-filter + var hoFilter = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return filter(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-reduce + var hoReduce = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var init = trampoline(evalExpr(nth(args, 1), env)); + var coll = trampoline(evalExpr(nth(args, 2), env)); + return reduce(function(acc, item) { return trampoline(callLambda(f, [acc, item], env)); }, init, coll); +})(); }; + + // ho-some + var hoSome = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return some(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-every + var hoEvery = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return isEvery(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + + // === Transpiled from render.sx === + + // HTML_TAGS + var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "path", "circle", "rect", "line", "polyline", "polygon", "text", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; + + // VOID_ELEMENTS + var VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]; + + // BOOLEAN_ATTRS + var BOOLEAN_ATTRS = ["disabled", "checked", "selected", "readonly", "required", "hidden", "autofocus", "autoplay", "controls", "loop", "muted", "defer", "async", "novalidate", "formnovalidate", "multiple", "open", "allowfullscreen"]; + + // render-to-html + var renderToHtml = function(expr, env) { return (function() { + var result = trampoline(evalExpr(expr, env)); + return renderValueToHtml(result, env); +})(); }; + + // render-value-to-html + var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); return escapeHtml((String(val))); })(); }; + + // render-list-to-html + var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { + var head = first(expr); + return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { + var name = symbolName(head); + var args = rest(expr); + return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + var comp = envGet(env, name); + return (isSxTruthy(isComponent(comp)) ? renderToHtml(trampoline(callComponent(comp, args, env)), env) : error((String("Unknown component: ") + String(name)))); +})() : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))); +})()); +})()); }; + + // render-html-element + var renderHtmlElement = function(tag, args, env) { return (function() { + var parsed = parseElementArgs(args, env); + var attrs = first(parsed); + var children = nth(parsed, 1); + var isVoid = contains(VOID_ELEMENTS, tag); + return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String(""))))); +})(); }; + + // parse-element-args + var parseElementArgs = function(args, env) { return (function() { + var attrs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + attrs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return [attrs, children]; +})(); }; + + // render-attrs + var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() { + var val = dictGet(attrs, key); + return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))); +})(); }, keys(attrs))); }; + + // render-to-sx + var renderToSx = function(expr, env) { return (function() { + var result = aser(expr, env); + return serialize(result); +})(); }; + + // aser + var aser = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { + var name = symbolName(expr); + return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); +})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); return expr; })(); }; + + // aser-list + var aserList = function(expr, env) { return (function() { + var head = first(expr); + var args = rest(expr); + return (isSxTruthy(!(typeOf(head) == "symbol")) ? map(function(x) { return aser(x, env); }, expr) : (function() { + var name = symbolName(head); + return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { + var f = trampoline(evalExpr(head, env)); + var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); + return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f))))))); +})()))))); +})()); +})(); }; + + // aser-fragment + var aserFragment = function(children, env) { return (function() { + var parts = filter(function(x) { return !isNil(x); }, map(function(c) { return aser(c, env); }, children)); + return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")"))); +})(); }; + + // aser-call + var aserCall = function(name, args, env) { return (function() { + var parts = [name]; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = aser(nth(args, (get(state, "i") + 1)), env); + if (isSxTruthy(!isNil(val))) { + parts.push((String(":") + String(keywordName(arg)))); + parts.push(serialize(val)); +} + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (function() { + var val = aser(arg, env); + if (isSxTruthy(!isNil(val))) { + parts.push(serialize(val)); +} + return assoc(state, "i", (get(state, "i") + 1)); +})())); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (String("(") + String(join(" ", parts)) + String(")")); +})(); }; + + + // ========================================================================= + // Post-transpilation fixups + // ========================================================================= + // The reference spec's call-lambda only handles Lambda objects, but HO forms + // (map, reduce, etc.) may receive native primitives. Wrap to handle both. + var _rawCallLambda = callLambda; + callLambda = function(f, args, callerEnv) { + if (typeof f === "function") return f.apply(null, args); + return _rawCallLambda(f, args, callerEnv); + }; + + // ========================================================================= + // Parser (reused from reference — hand-written for bootstrap simplicity) + // ========================================================================= + + // The parser is the one piece we keep as hand-written JS since the + // reference parser.sx is more of a spec than directly compilable code + // (it uses mutable cursor state that doesn't map cleanly to the + // transpiler's functional output). A future version could bootstrap + // the parser too. + + function parse(text) { + var pos = 0; + function skipWs() { + while (pos < text.length) { + var ch = text[pos]; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pos++; continue; } + if (ch === ";") { while (pos < text.length && text[pos] !== "\n") pos++; continue; } + break; + } + } + function readExpr() { + skipWs(); + if (pos >= text.length) return undefined; + var ch = text[pos]; + if (ch === "(") { pos++; return readList(")"); } + if (ch === "[") { pos++; return readList("]"); } + if (ch === '"') return readString(); + if (ch === ":") return readKeyword(); + if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); + if (ch >= "0" && ch <= "9") return readNumber(); + return readSymbol(); + } + function readList(close) { + var items = []; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated list"); + if (text[pos] === close) { pos++; return items; } + items.push(readExpr()); + } + } + function readString() { + pos++; // skip " + var s = ""; + while (pos < text.length) { + var ch = text[pos]; + if (ch === '"') { pos++; return s; } + if (ch === "\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\n" : esc === "t" ? "\t" : esc === "r" ? "\r" : esc; pos++; continue; } + s += ch; pos++; + } + throw new Error("Unterminated string"); + } + function readKeyword() { + pos++; // skip : + var name = readIdent(); + return new Keyword(name); + } + function readNumber() { + var start = pos; + if (text[pos] === "-") pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + if (pos < text.length && text[pos] === ".") { pos++; while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; } + if (pos < text.length && (text[pos] === "e" || text[pos] === "E")) { + pos++; + if (pos < text.length && (text[pos] === "+" || text[pos] === "-")) pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + } + return Number(text.slice(start, pos)); + } + function readIdent() { + var start = pos; + while (pos < text.length && /[a-zA-Z0-9_~*+\-><=/!?.:&]/.test(text[pos])) pos++; + return text.slice(start, pos); + } + function readSymbol() { + var name = readIdent(); + if (name === "true") return true; + if (name === "false") return false; + if (name === "nil") return NIL; + return new Symbol(name); + } + var exprs = []; + while (true) { + skipWs(); + if (pos >= text.length) break; + exprs.push(readExpr()); + } + return exprs; + } + + // ========================================================================= + // Public API + // ========================================================================= + + var componentEnv = {}; + + function loadComponents(source) { + var exprs = parse(source); + for (var i = 0; i < exprs.length; i++) { + trampoline(evalExpr(exprs[i], componentEnv)); + } + } + + function render(source) { + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) { + var result = trampoline(evalExpr(exprs[i], merge(componentEnv))); + appendToDOM(frag, result, merge(componentEnv)); + } + return frag; + } + + function appendToDOM(parent, val, env) { + if (isNil(val)) return; + if (typeof val === "string") { parent.appendChild(document.createTextNode(val)); return; } + if (typeof val === "number") { parent.appendChild(document.createTextNode(String(val))); return; } + if (val._raw) { var t = document.createElement("template"); t.innerHTML = val.html; parent.appendChild(t.content); return; } + if (Array.isArray(val)) { + // Could be a rendered element or a list of results + if (val.length > 0 && isSym(val[0])) { + // It's an unevaluated expression — evaluate it + var result = trampoline(evalExpr(val, env)); + appendToDOM(parent, result, env); + } else { + for (var i = 0; i < val.length; i++) appendToDOM(parent, val[i], env); + } + return; + } + parent.appendChild(document.createTextNode(String(val))); + } + + var SxRef = { + parse: parse, + eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, + loadComponents: loadComponents, + render: render, + serialize: serialize, + NIL: NIL, + Symbol: Symbol, + Keyword: Keyword, + componentEnv: componentEnv, + _version: "ref-1.0 (bootstrap-compiled)" + }; + + if (typeof module !== "undefined" && module.exports) module.exports = SxRef; + else global.SxRef = SxRef; + +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py new file mode 100644 index 0000000..5e9583c --- /dev/null +++ b/shared/sx/ref/bootstrap_js.py @@ -0,0 +1,1070 @@ +#!/usr/bin/env python3 +""" +Bootstrap compiler: reference SX evaluator → JavaScript. + +Reads the .sx reference specification and emits a standalone JavaScript +evaluator (sx-ref.js) that can be compared against the hand-written sx.js. + +The compiler translates the restricted SX subset used in eval.sx/render.sx +into idiomatic JavaScript. Platform interface functions are emitted as +native JS implementations. + +Usage: + python bootstrap_js.py > sx-ref.js +""" +from __future__ import annotations + +import os +import sys + +# Add project root to path for imports +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +sys.path.insert(0, _PROJECT) + +from shared.sx.parser import parse_all +from shared.sx.types import Symbol, Keyword, NIL as SX_NIL + +# --------------------------------------------------------------------------- +# SX → JavaScript transpiler +# --------------------------------------------------------------------------- + +class JSEmitter: + """Transpile an SX AST node to JavaScript source code.""" + + def __init__(self): + self.indent = 0 + + def emit(self, expr) -> str: + """Emit a JS expression from an SX AST node.""" + # Bool MUST be checked before int (bool is subclass of int in Python) + if isinstance(expr, bool): + return "true" if expr else "false" + if isinstance(expr, (int, float)): + return str(expr) + if isinstance(expr, str): + return self._js_string(expr) + if expr is None or expr is SX_NIL: + return "NIL" + if isinstance(expr, Symbol): + return self._emit_symbol(expr.name) + if isinstance(expr, Keyword): + return self._js_string(expr.name) + if isinstance(expr, list): + return self._emit_list(expr) + return str(expr) + + def emit_statement(self, expr) -> str: + """Emit a JS statement (with semicolon) from an SX AST node.""" + if isinstance(expr, list) and expr: + head = expr[0] + if isinstance(head, Symbol): + name = head.name + if name == "define": + return self._emit_define(expr) + if name == "set!": + return f"{self._mangle(expr[1].name)} = {self.emit(expr[2])};" + if name == "when": + return self._emit_when_stmt(expr) + if name == "do" or name == "begin": + return "\n".join(self.emit_statement(e) for e in expr[1:]) + if name == "for-each": + return self._emit_for_each_stmt(expr) + if name == "dict-set!": + return f"{self.emit(expr[1])}[{self.emit(expr[2])}] = {self.emit(expr[3])};" + if name == "append!": + return f"{self.emit(expr[1])}.push({self.emit(expr[2])});" + if name == "env-set!": + return f"{self.emit(expr[1])}[{self.emit(expr[2])}] = {self.emit(expr[3])};" + if name == "set-lambda-name!": + return f"{self.emit(expr[1])}.name = {self.emit(expr[2])};" + return f"{self.emit(expr)};" + + # --- Symbol emission --- + + def _emit_symbol(self, name: str) -> str: + # Map SX names to JS names + return self._mangle(name) + + def _mangle(self, name: str) -> str: + """Convert SX identifier to valid JS identifier.""" + RENAMES = { + "nil": "NIL", + "true": "true", + "false": "false", + "nil?": "isNil", + "type-of": "typeOf", + "symbol-name": "symbolName", + "keyword-name": "keywordName", + "make-lambda": "makeLambda", + "make-component": "makeComponent", + "make-macro": "makeMacro", + "make-thunk": "makeThunk", + "make-symbol": "makeSymbol", + "make-keyword": "makeKeyword", + "lambda-params": "lambdaParams", + "lambda-body": "lambdaBody", + "lambda-closure": "lambdaClosure", + "lambda-name": "lambdaName", + "set-lambda-name!": "setLambdaName", + "component-params": "componentParams", + "component-body": "componentBody", + "component-closure": "componentClosure", + "component-has-children?": "componentHasChildren", + "component-name": "componentName", + "macro-params": "macroParams", + "macro-rest-param": "macroRestParam", + "macro-body": "macroBody", + "macro-closure": "macroClosure", + "thunk?": "isThunk", + "thunk-expr": "thunkExpr", + "thunk-env": "thunkEnv", + "callable?": "isCallable", + "lambda?": "isLambda", + "component?": "isComponent", + "macro?": "isMacro", + "primitive?": "isPrimitive", + "get-primitive": "getPrimitive", + "env-has?": "envHas", + "env-get": "envGet", + "env-set!": "envSet", + "env-extend": "envExtend", + "env-merge": "envMerge", + "dict-set!": "dictSet", + "dict-get": "dictGet", + "eval-expr": "evalExpr", + "eval-list": "evalList", + "eval-call": "evalCall", + "call-lambda": "callLambda", + "call-component": "callComponent", + "parse-keyword-args": "parseKeywordArgs", + "parse-comp-params": "parseCompParams", + "parse-macro-params": "parseMacroParams", + "expand-macro": "expandMacro", + "render-to-html": "renderToHtml", + "render-to-sx": "renderToSx", + "render-value-to-html": "renderValueToHtml", + "render-list-to-html": "renderListToHtml", + "render-html-element": "renderHtmlElement", + "parse-element-args": "parseElementArgs", + "render-attrs": "renderAttrs", + "aser-list": "aserList", + "aser-fragment": "aserFragment", + "aser-call": "aserCall", + "aser-special": "aserSpecial", + "sf-if": "sfIf", + "sf-when": "sfWhen", + "sf-cond": "sfCond", + "sf-cond-scheme": "sfCondScheme", + "sf-cond-clojure": "sfCondClojure", + "sf-case": "sfCase", + "sf-case-loop": "sfCaseLoop", + "sf-and": "sfAnd", + "sf-or": "sfOr", + "sf-let": "sfLet", + "sf-lambda": "sfLambda", + "sf-define": "sfDefine", + "sf-defcomp": "sfDefcomp", + "sf-defmacro": "sfDefmacro", + "sf-begin": "sfBegin", + "sf-quote": "sfQuote", + "sf-quasiquote": "sfQuasiquote", + "sf-thread-first": "sfThreadFirst", + "sf-set!": "sfSetBang", + "qq-expand": "qqExpand", + "ho-map": "hoMap", + "ho-map-indexed": "hoMapIndexed", + "ho-filter": "hoFilter", + "ho-reduce": "hoReduce", + "ho-some": "hoSome", + "ho-every": "hoEvery", + "special-form?": "isSpecialForm", + "ho-form?": "isHoForm", + "strip-prefix": "stripPrefix", + "escape-html": "escapeHtml", + "escape-attr": "escapeAttr", + "escape-string": "escapeString", + "raw-html-content": "rawHtmlContent", + "HTML_TAGS": "HTML_TAGS", + "VOID_ELEMENTS": "VOID_ELEMENTS", + "BOOLEAN_ATTRS": "BOOLEAN_ATTRS", + "whitespace?": "isWhitespace", + "digit?": "isDigit", + "ident-start?": "isIdentStart", + "ident-char?": "isIdentChar", + "parse-number": "parseNumber", + "sx-expr-source": "sxExprSource", + "starts-with?": "startsWith", + "ends-with?": "endsWith", + "contains?": "contains", + "empty?": "isEmpty", + "odd?": "isOdd", + "even?": "isEven", + "zero?": "isZero", + "number?": "isNumber", + "string?": "isString", + "list?": "isList", + "dict?": "isDict", + "every?": "isEvery", + "map-indexed": "mapIndexed", + "for-each": "forEach", + "map-dict": "mapDict", + "chunk-every": "chunkEvery", + "zip-pairs": "zipPairs", + "strip-tags": "stripTags", + "format-date": "formatDate", + "format-decimal": "formatDecimal", + "parse-int": "parseInt_", + } + if name in RENAMES: + return RENAMES[name] + # General mangling: replace - with camelCase, ? with _p, ! with _b + result = name + if result.endswith("?"): + result = result[:-1] + "_p" + if result.endswith("!"): + result = result[:-1] + "_b" + # Kebab to camel + parts = result.split("-") + if len(parts) > 1: + result = parts[0] + "".join(p.capitalize() for p in parts[1:]) + return result + + # --- List emission --- + + def _emit_list(self, expr: list) -> str: + if not expr: + return "[]" + head = expr[0] + if not isinstance(head, Symbol): + # Data list + return "[" + ", ".join(self.emit(x) for x in expr) + "]" + name = head.name + handler = getattr(self, f"_sf_{name.replace('-', '_').replace('!', '_b').replace('?', '_p')}", None) + if handler: + return handler(expr) + # Built-in forms + if name == "fn" or name == "lambda": + return self._emit_fn(expr) + if name == "let" or name == "let*": + return self._emit_let(expr) + if name == "if": + return self._emit_if(expr) + if name == "when": + return self._emit_when(expr) + if name == "cond": + return self._emit_cond(expr) + if name == "case": + return self._emit_case(expr) + if name == "and": + return self._emit_and(expr) + if name == "or": + return self._emit_or(expr) + if name == "not": + return f"!{self.emit(expr[1])}" + if name == "do" or name == "begin": + return self._emit_do(expr) + if name == "list": + return "[" + ", ".join(self.emit(x) for x in expr[1:]) + "]" + if name == "dict": + return self._emit_dict_literal(expr) + if name == "quote": + return self._emit_quote(expr[1]) + if name == "set!": + return f"({self._mangle(expr[1].name)} = {self.emit(expr[2])})" + if name == "str": + parts = [self.emit(x) for x in expr[1:]] + return "(" + " + ".join(f'String({p})' for p in parts) + ")" + # Infix operators + if name in ("+", "-", "*", "/", "=", "!=", "<", ">", "<=", ">=", "mod"): + return self._emit_infix(name, expr[1:]) + if name == "inc": + return f"({self.emit(expr[1])} + 1)" + if name == "dec": + return f"({self.emit(expr[1])} - 1)" + + # Regular function call + fn_name = self._mangle(name) + args = ", ".join(self.emit(x) for x in expr[1:]) + return f"{fn_name}({args})" + + # --- Special form emitters --- + + def _emit_fn(self, expr) -> str: + params = expr[1] + body = expr[2] + param_names = [] + for p in params: + if isinstance(p, Symbol): + param_names.append(self._mangle(p.name)) + else: + param_names.append(str(p)) + params_str = ", ".join(param_names) + body_js = self.emit(body) + return f"function({params_str}) {{ return {body_js}; }}" + + def _emit_let(self, expr) -> str: + bindings = expr[1] + body = expr[2:] + parts = ["(function() {"] + if isinstance(bindings, list): + if bindings and isinstance(bindings[0], list): + # Scheme-style: ((name val) ...) + for b in bindings: + vname = b[0].name if isinstance(b[0], Symbol) else str(b[0]) + parts.append(f" var {self._mangle(vname)} = {self.emit(b[1])};") + else: + # Clojure-style: (name val name val ...) + for i in range(0, len(bindings), 2): + vname = bindings[i].name if isinstance(bindings[i], Symbol) else str(bindings[i]) + parts.append(f" var {self._mangle(vname)} = {self.emit(bindings[i + 1])};") + for b_expr in body[:-1]: + parts.append(f" {self.emit_statement(b_expr)}") + parts.append(f" return {self.emit(body[-1])};") + parts.append("})()") + return "\n".join(parts) + + def _emit_if(self, expr) -> str: + cond = self.emit(expr[1]) + then = self.emit(expr[2]) + els = self.emit(expr[3]) if len(expr) > 3 else "NIL" + return f"(isSxTruthy({cond}) ? {then} : {els})" + + def _emit_when(self, expr) -> str: + cond = self.emit(expr[1]) + body_parts = expr[2:] + if len(body_parts) == 1: + return f"(isSxTruthy({cond}) ? {self.emit(body_parts[0])} : NIL)" + body = self._emit_do_inner(body_parts) + return f"(isSxTruthy({cond}) ? {body} : NIL)" + + def _emit_when_stmt(self, expr) -> str: + cond = self.emit(expr[1]) + body_parts = expr[2:] + stmts = "\n".join(f" {self.emit_statement(e)}" for e in body_parts) + return f"if (isSxTruthy({cond})) {{\n{stmts}\n}}" + + def _emit_cond(self, expr) -> str: + clauses = expr[1:] + if not clauses: + return "NIL" + # Determine style ONCE: Scheme-style if every element is a 2-element + # list AND no bare keywords appear (bare :else = Clojure). + is_scheme = ( + all(isinstance(c, list) and len(c) == 2 for c in clauses) + and not any(isinstance(c, Keyword) for c in clauses) + ) + if is_scheme: + return self._cond_scheme(clauses) + return self._cond_clojure(clauses) + + def _cond_scheme(self, clauses) -> str: + if not clauses: + return "NIL" + clause = clauses[0] + test = clause[0] + body = clause[1] + if isinstance(test, Symbol) and test.name in ("else", ":else"): + return self.emit(body) + if isinstance(test, Keyword) and test.name == "else": + return self.emit(body) + return f"(isSxTruthy({self.emit(test)}) ? {self.emit(body)} : {self._cond_scheme(clauses[1:])})" + + def _cond_clojure(self, clauses) -> str: + if len(clauses) < 2: + return "NIL" + test = clauses[0] + body = clauses[1] + if isinstance(test, Keyword) and test.name == "else": + return self.emit(body) + if isinstance(test, Symbol) and test.name in ("else", ":else"): + return self.emit(body) + return f"(isSxTruthy({self.emit(test)}) ? {self.emit(body)} : {self._cond_clojure(clauses[2:])})" + + def _emit_case(self, expr) -> str: + match_expr = self.emit(expr[1]) + clauses = expr[2:] + return f"(function() {{ var _m = {match_expr}; {self._case_chain(clauses)} }})()" + + def _case_chain(self, clauses) -> str: + if len(clauses) < 2: + return "return NIL;" + test = clauses[0] + body = clauses[1] + if isinstance(test, Keyword) and test.name == "else": + return f"return {self.emit(body)};" + if isinstance(test, Symbol) and test.name in ("else", ":else"): + return f"return {self.emit(body)};" + return f"if (_m == {self.emit(test)}) return {self.emit(body)}; {self._case_chain(clauses[2:])}" + + def _emit_and(self, expr) -> str: + parts = [self.emit(x) for x in expr[1:]] + return "(" + " && ".join(f"isSxTruthy({p})" for p in parts[:-1]) + (" && " if len(parts) > 1 else "") + parts[-1] + ")" + + def _emit_or(self, expr) -> str: + if len(expr) == 2: + return self.emit(expr[1]) + parts = [self.emit(x) for x in expr[1:]] + # Use a helper that returns the first truthy value + return f"sxOr({', '.join(parts)})" + + def _emit_do(self, expr) -> str: + return self._emit_do_inner(expr[1:]) + + def _emit_do_inner(self, exprs) -> str: + if len(exprs) == 1: + return self.emit(exprs[0]) + parts = [self.emit(e) for e in exprs] + return "(" + ", ".join(parts) + ")" + + def _emit_dict_literal(self, expr) -> str: + pairs = expr[1:] + parts = [] + i = 0 + while i < len(pairs) - 1: + key = pairs[i] + val = pairs[i + 1] + if isinstance(key, Keyword): + parts.append(f"{self._js_string(key.name)}: {self.emit(val)}") + else: + parts.append(f"[{self.emit(key)}]: {self.emit(val)}") + i += 2 + return "{" + ", ".join(parts) + "}" + + def _emit_infix(self, op: str, args: list) -> str: + JS_OPS = {"=": "==", "!=": "!=", "mod": "%"} + js_op = JS_OPS.get(op, op) + if len(args) == 1 and op == "-": + return f"(-{self.emit(args[0])})" + return f"({self.emit(args[0])} {js_op} {self.emit(args[1])})" + + def _emit_define(self, expr) -> str: + name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1]) + val = self.emit(expr[2]) + return f"var {self._mangle(name)} = {val};" + + def _emit_for_each_stmt(self, expr) -> str: + fn_expr = expr[1] + coll_expr = expr[2] + coll = self.emit(coll_expr) + # If fn is an inline lambda, emit a for loop + if isinstance(fn_expr, list) and fn_expr[0] == Symbol("fn"): + params = fn_expr[1] + body = fn_expr[2] + p = params[0].name if isinstance(params[0], Symbol) else str(params[0]) + p_js = self._mangle(p) + body_js = self.emit_statement(body) + return f"{{ var _c = {coll}; for (var _i = 0; _i < _c.length; _i++) {{ var {p_js} = _c[_i]; {body_js} }} }}" + fn = self.emit(fn_expr) + return f"{{ var _c = {coll}; for (var _i = 0; _i < _c.length; _i++) {{ {fn}(_c[_i]); }} }}" + + def _emit_quote(self, expr) -> str: + """Emit a quoted expression as a JS literal AST.""" + if isinstance(expr, bool): + return "true" if expr else "false" + if isinstance(expr, (int, float)): + return str(expr) + if isinstance(expr, str): + return self._js_string(expr) + if expr is None or expr is SX_NIL: + return "NIL" + if isinstance(expr, Symbol): + return f'new Symbol({self._js_string(expr.name)})' + if isinstance(expr, Keyword): + return f'new Keyword({self._js_string(expr.name)})' + if isinstance(expr, list): + return "[" + ", ".join(self._emit_quote(x) for x in expr) + "]" + return str(expr) + + def _js_string(self, s: str) -> str: + return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + '"' + + +# --------------------------------------------------------------------------- +# Bootstrap compiler +# --------------------------------------------------------------------------- + +def extract_defines(source: str) -> list[tuple[str, list]]: + """Parse .sx source, return list of (name, define-expr) for top-level defines.""" + exprs = parse_all(source) + defines = [] + for expr in exprs: + if isinstance(expr, list) and expr and isinstance(expr[0], Symbol): + if expr[0].name == "define": + name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1]) + defines.append((name, expr)) + return defines + + +def compile_ref_to_js() -> str: + """Read reference .sx files and emit JavaScript.""" + ref_dir = os.path.dirname(os.path.abspath(__file__)) + emitter = JSEmitter() + + # Read reference files + with open(os.path.join(ref_dir, "eval.sx")) as f: + eval_src = f.read() + with open(os.path.join(ref_dir, "render.sx")) as f: + render_src = f.read() + + eval_defines = extract_defines(eval_src) + render_defines = extract_defines(render_src) + + # Build output + parts = [] + parts.append(PREAMBLE) + parts.append(PLATFORM_JS) + parts.append("\n // === Transpiled from eval.sx ===\n") + for name, expr in eval_defines: + parts.append(f" // {name}") + parts.append(f" {emitter.emit_statement(expr)}") + parts.append("") + parts.append("\n // === Transpiled from render.sx ===\n") + for name, expr in render_defines: + parts.append(f" // {name}") + parts.append(f" {emitter.emit_statement(expr)}") + parts.append("") + parts.append(FIXUPS) + parts.append(PUBLIC_API) + parts.append(EPILOGUE) + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Static JS sections +# --------------------------------------------------------------------------- + +PREAMBLE = '''\ +/** + * sx-ref.js — Generated from reference SX evaluator specification. + * + * Bootstrap-compiled from shared/sx/ref/{eval,render,primitives}.sx + * Compare against hand-written sx.js for correctness verification. + * + * DO NOT EDIT — regenerate with: python bootstrap_js.py + */ +;(function(global) { + "use strict"; + + // ========================================================================= + // Types + // ========================================================================= + + var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function Symbol(name) { this.name = name; } + Symbol.prototype.toString = function() { return this.name; }; + Symbol.prototype._sym = true; + + function Keyword(name) { this.name = name; } + Keyword.prototype.toString = function() { return ":" + this.name; }; + Keyword.prototype._kw = true; + + function Lambda(params, body, closure, name) { + this.params = params; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Lambda.prototype._lambda = true; + + function Component(name, params, hasChildren, body, closure) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + } + Component.prototype._component = true; + + function Macro(params, restParam, body, closure, name) { + this.params = params; + this.restParam = restParam; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Macro.prototype._macro = true; + + function Thunk(expr, env) { this.expr = expr; this.env = env; } + Thunk.prototype._thunk = true; + + function RawHTML(html) { this.html = html; } + RawHTML.prototype._raw = true; + + function isSym(x) { return x != null && x._sym === true; } + function isKw(x) { return x != null && x._kw === true; } + + function merge() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { + var d = arguments[i]; + if (d) for (var k in d) out[k] = d[k]; + } + return out; + } + + function sxOr() { + for (var i = 0; i < arguments.length; i++) { + if (isSxTruthy(arguments[i])) return arguments[i]; + } + return arguments.length ? arguments[arguments.length - 1] : false; + }''' + +PLATFORM_JS = ''' + // ========================================================================= + // Platform interface — JS implementation + // ========================================================================= + + function typeOf(x) { + if (isNil(x)) return "nil"; + if (typeof x === "number") return "number"; + if (typeof x === "string") return "string"; + if (typeof x === "boolean") return "boolean"; + if (x._sym) return "symbol"; + if (x._kw) return "keyword"; + if (x._thunk) return "thunk"; + if (x._lambda) return "lambda"; + if (x._component) return "component"; + if (x._macro) return "macro"; + if (x._raw) return "raw-html"; + if (Array.isArray(x)) return "list"; + if (typeof x === "object") return "dict"; + return "unknown"; + } + + function symbolName(s) { return s.name; } + function keywordName(k) { return k.name; } + function makeSymbol(n) { return new Symbol(n); } + function makeKeyword(n) { return new Keyword(n); } + + function makeLambda(params, body, env) { return new Lambda(params, body, merge(env)); } + function makeComponent(name, params, hasChildren, body, env) { + return new Component(name, params, hasChildren, body, merge(env)); + } + function makeMacro(params, restParam, body, env, name) { + return new Macro(params, restParam, body, merge(env), name); + } + function makeThunk(expr, env) { return new Thunk(expr, env); } + + function lambdaParams(f) { return f.params; } + function lambdaBody(f) { return f.body; } + function lambdaClosure(f) { return f.closure; } + function lambdaName(f) { return f.name; } + function setLambdaName(f, n) { f.name = n; } + + function componentParams(c) { return c.params; } + function componentBody(c) { return c.body; } + function componentClosure(c) { return c.closure; } + function componentHasChildren(c) { return c.hasChildren; } + function componentName(c) { return c.name; } + + function macroParams(m) { return m.params; } + function macroRestParam(m) { return m.restParam; } + function macroBody(m) { return m.body; } + function macroClosure(m) { return m.closure; } + + function isThunk(x) { return x != null && x._thunk === true; } + function thunkExpr(t) { return t.expr; } + function thunkEnv(t) { return t.env; } + + function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } + function isLambda(x) { return x != null && x._lambda === true; } + function isComponent(x) { return x != null && x._component === true; } + function isMacro(x) { return x != null && x._macro === true; } + + function envHas(env, name) { return name in env; } + function envGet(env, name) { return env[name]; } + function envSet(env, name, val) { env[name] = val; } + function envExtend(env) { return merge(env); } + function envMerge(base, overlay) { return merge(base, overlay); } + + function dictSet(d, k, v) { d[k] = v; } + function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; } + + function stripPrefix(s, prefix) { + return s.indexOf(prefix) === 0 ? s.slice(prefix.length) : s; + } + + function error(msg) { throw new Error(msg); } + function inspect(x) { return JSON.stringify(x); } + + // ========================================================================= + // Primitives + // ========================================================================= + + var PRIMITIVES = {}; + + // Arithmetic + PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; }; + PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; }; + PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; }; + PRIMITIVES["/"] = function(a, b) { return a / b; }; + PRIMITIVES["mod"] = function(a, b) { return a % b; }; + PRIMITIVES["inc"] = function(n) { return n + 1; }; + PRIMITIVES["dec"] = function(n) { return n - 1; }; + PRIMITIVES["abs"] = Math.abs; + PRIMITIVES["floor"] = Math.floor; + PRIMITIVES["ceil"] = Math.ceil; + PRIMITIVES["round"] = Math.round; + PRIMITIVES["min"] = Math.min; + PRIMITIVES["max"] = Math.max; + PRIMITIVES["sqrt"] = Math.sqrt; + PRIMITIVES["pow"] = Math.pow; + PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }; + + // Comparison + PRIMITIVES["="] = function(a, b) { return a == b; }; + PRIMITIVES["!="] = function(a, b) { return a != b; }; + PRIMITIVES["<"] = function(a, b) { return a < b; }; + PRIMITIVES[">"] = function(a, b) { return a > b; }; + PRIMITIVES["<="] = function(a, b) { return a <= b; }; + PRIMITIVES[">="] = function(a, b) { return a >= b; }; + + // Logic + PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); }; + + // String + PRIMITIVES["str"] = function() { + var p = []; + for (var i = 0; i < arguments.length; i++) { + var v = arguments[i]; if (isNil(v)) continue; p.push(String(v)); + } + return p.join(""); + }; + PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); }; + PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); }; + PRIMITIVES["trim"] = function(s) { return String(s).trim(); }; + PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; + PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; + PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; + PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; + PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; + PRIMITIVES["concat"] = function() { + var out = []; + for (var i = 0; i < arguments.length; i++) if (arguments[i]) out = out.concat(arguments[i]); + return out; + }; + PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; + + // Predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; + + // Collections + PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; + PRIMITIVES["dict"] = function() { + var d = {}; + for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; + return d; + }; + PRIMITIVES["range"] = function(a, b, step) { + var r = []; step = step || 1; + for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); + return r; + }; + PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; + PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; + PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; + PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; + PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; + PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; + PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); }; + PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; }; + PRIMITIVES["merge"] = function() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } + return out; + }; + PRIMITIVES["assoc"] = function(d) { + var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; + return out; + }; + PRIMITIVES["dissoc"] = function(d) { + var out = {}; for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; + return out; + }; + PRIMITIVES["chunk-every"] = function(c, n) { + var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; + }; + PRIMITIVES["zip-pairs"] = function(c) { + var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r; + }; + PRIMITIVES["into"] = function(target, coll) { + if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll); + var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } + return r; + }; + + // Format + PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); }; + PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; }; + PRIMITIVES["pluralize"] = function(n, s, p) { + if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s"); + return n == 1 ? "" : "s"; + }; + PRIMITIVES["escape"] = function(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + }; + + function isPrimitive(name) { return name in PRIMITIVES; } + function getPrimitive(name) { return PRIMITIVES[name]; } + + // Higher-order helpers used by the transpiled code + function map(fn, coll) { return coll.map(fn); } + function mapIndexed(fn, coll) { return coll.map(function(item, i) { return fn(i, item); }); } + function filter(fn, coll) { return coll.filter(function(x) { return isSxTruthy(fn(x)); }); } + function reduce(fn, init, coll) { + var acc = init; + for (var i = 0; i < coll.length; i++) acc = fn(acc, coll[i]); + return acc; + } + function some(fn, coll) { + for (var i = 0; i < coll.length; i++) { var r = fn(coll[i]); if (isSxTruthy(r)) return r; } + return NIL; + } + function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } + + // List primitives used directly by transpiled code + var len = PRIMITIVES["len"]; + var first = PRIMITIVES["first"]; + var last = PRIMITIVES["last"]; + var rest = PRIMITIVES["rest"]; + var nth = PRIMITIVES["nth"]; + var cons = PRIMITIVES["cons"]; + var append = PRIMITIVES["append"]; + var isEmpty = PRIMITIVES["empty?"]; + var contains = PRIMITIVES["contains?"]; + var startsWith = PRIMITIVES["starts-with?"]; + var slice = PRIMITIVES["slice"]; + var concat = PRIMITIVES["concat"]; + var str = PRIMITIVES["str"]; + var join = PRIMITIVES["join"]; + var keys = PRIMITIVES["keys"]; + var get = PRIMITIVES["get"]; + var assoc = PRIMITIVES["assoc"]; + var range = PRIMITIVES["range"]; + function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; } + function append_b(arr, x) { arr.push(x); return arr; } + var apply = function(f, args) { return f.apply(null, args); }; + + // HTML rendering helpers + function escapeHtml(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function escapeAttr(s) { return escapeHtml(s); } + function rawHtmlContent(r) { return r.html; } + + // Serializer + function serialize(val) { + if (isNil(val)) return "nil"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (typeof val === "string") return \'"\' + val.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, \'\\\\"\') + \'"\'; + if (isSym(val)) return val.name; + if (isKw(val)) return ":" + val.name; + if (Array.isArray(val)) return "(" + val.map(serialize).join(" ") + ")"; + return String(val); + } + + function isSpecialForm(n) { return n in { + "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "quote":1,"quasiquote":1,"->":1,"set!":1 + }; } + function isHoForm(n) { return n in { + "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 + }; }''' + +FIXUPS = ''' + // ========================================================================= + // Post-transpilation fixups + // ========================================================================= + // The reference spec's call-lambda only handles Lambda objects, but HO forms + // (map, reduce, etc.) may receive native primitives. Wrap to handle both. + var _rawCallLambda = callLambda; + callLambda = function(f, args, callerEnv) { + if (typeof f === "function") return f.apply(null, args); + return _rawCallLambda(f, args, callerEnv); + };''' + +PUBLIC_API = ''' + // ========================================================================= + // Parser (reused from reference — hand-written for bootstrap simplicity) + // ========================================================================= + + // The parser is the one piece we keep as hand-written JS since the + // reference parser.sx is more of a spec than directly compilable code + // (it uses mutable cursor state that doesn't map cleanly to the + // transpiler's functional output). A future version could bootstrap + // the parser too. + + function parse(text) { + var pos = 0; + function skipWs() { + while (pos < text.length) { + var ch = text[pos]; + if (ch === " " || ch === "\\t" || ch === "\\n" || ch === "\\r") { pos++; continue; } + if (ch === ";") { while (pos < text.length && text[pos] !== "\\n") pos++; continue; } + break; + } + } + function readExpr() { + skipWs(); + if (pos >= text.length) return undefined; + var ch = text[pos]; + if (ch === "(") { pos++; return readList(")"); } + if (ch === "[") { pos++; return readList("]"); } + if (ch === \'"\') return readString(); + if (ch === ":") return readKeyword(); + if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); + if (ch >= "0" && ch <= "9") return readNumber(); + return readSymbol(); + } + function readList(close) { + var items = []; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated list"); + if (text[pos] === close) { pos++; return items; } + items.push(readExpr()); + } + } + function readString() { + pos++; // skip " + var s = ""; + while (pos < text.length) { + var ch = text[pos]; + if (ch === \'"\') { pos++; return s; } + if (ch === "\\\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\\n" : esc === "t" ? "\\t" : esc === "r" ? "\\r" : esc; pos++; continue; } + s += ch; pos++; + } + throw new Error("Unterminated string"); + } + function readKeyword() { + pos++; // skip : + var name = readIdent(); + return new Keyword(name); + } + function readNumber() { + var start = pos; + if (text[pos] === "-") pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + if (pos < text.length && text[pos] === ".") { pos++; while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; } + if (pos < text.length && (text[pos] === "e" || text[pos] === "E")) { + pos++; + if (pos < text.length && (text[pos] === "+" || text[pos] === "-")) pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + } + return Number(text.slice(start, pos)); + } + function readIdent() { + var start = pos; + while (pos < text.length && /[a-zA-Z0-9_~*+\\-><=/!?.:&]/.test(text[pos])) pos++; + return text.slice(start, pos); + } + function readSymbol() { + var name = readIdent(); + if (name === "true") return true; + if (name === "false") return false; + if (name === "nil") return NIL; + return new Symbol(name); + } + var exprs = []; + while (true) { + skipWs(); + if (pos >= text.length) break; + exprs.push(readExpr()); + } + return exprs; + } + + // ========================================================================= + // Public API + // ========================================================================= + + var componentEnv = {}; + + function loadComponents(source) { + var exprs = parse(source); + for (var i = 0; i < exprs.length; i++) { + trampoline(evalExpr(exprs[i], componentEnv)); + } + } + + function render(source) { + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) { + var result = trampoline(evalExpr(exprs[i], merge(componentEnv))); + appendToDOM(frag, result, merge(componentEnv)); + } + return frag; + } + + function appendToDOM(parent, val, env) { + if (isNil(val)) return; + if (typeof val === "string") { parent.appendChild(document.createTextNode(val)); return; } + if (typeof val === "number") { parent.appendChild(document.createTextNode(String(val))); return; } + if (val._raw) { var t = document.createElement("template"); t.innerHTML = val.html; parent.appendChild(t.content); return; } + if (Array.isArray(val)) { + // Could be a rendered element or a list of results + if (val.length > 0 && isSym(val[0])) { + // It's an unevaluated expression — evaluate it + var result = trampoline(evalExpr(val, env)); + appendToDOM(parent, result, env); + } else { + for (var i = 0; i < val.length; i++) appendToDOM(parent, val[i], env); + } + return; + } + parent.appendChild(document.createTextNode(String(val))); + } + + var SxRef = { + parse: parse, + eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, + loadComponents: loadComponents, + render: render, + serialize: serialize, + NIL: NIL, + Symbol: Symbol, + Keyword: Keyword, + componentEnv: componentEnv, + _version: "ref-1.0 (bootstrap-compiled)" + }; + + if (typeof module !== "undefined" && module.exports) module.exports = SxRef; + else global.SxRef = SxRef;''' + +EPILOGUE = ''' +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);''' + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + print(compile_ref_to_js()) From a9526c4fa1f7a88bd1b1b1def666a47da858146b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 10:17:28 +0000 Subject: [PATCH 07/24] Update reference SX spec to match sx.js macros branch (CSSX, dict literals, new primitives) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eval.sx: Add defstyle, defkeyframes, defhandler special forms; add ho-for-each - parser.sx: Add dict {...} literal parsing and quasiquote/unquote sugar - primitives.sx: Add parse-datetime, split-ids, css, merge-styles primitives - render.sx: Add StyleValue handling, SVG filter elements, definition forms in render, fix render-to-html to handle HTML tags directly - bootstrap_js.py: Add StyleValue type, buildKeyframes, isEvery platform helper, new primitives (format-date, parse-datetime, split-ids, css, merge-styles), dict/quasiquote parser, expose render functions as primitives - sx-ref.js: Regenerated — 132/132 tests passing Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 143 +++++++++++++++++++++++++++++--- shared/sx/ref/bootstrap_js.py | 109 +++++++++++++++++++++++- shared/sx/ref/eval.sx | 36 +++++++- shared/sx/ref/parser.sx | 67 ++++++++++++++- shared/sx/ref/primitives.sx | 31 +++++++ shared/sx/ref/render.sx | 49 +++++++++-- 6 files changed, 406 insertions(+), 29 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 324f0d9..4b9b587 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -58,6 +58,15 @@ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -93,6 +102,7 @@ if (x._component) return "component"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._styleValue) return "style-value"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; return "unknown"; @@ -138,6 +148,27 @@ function isComponent(x) { return x != null && x._component === true; } function isMacro(x) { return x != null && x._macro === true; } + function isStyleValue(x) { return x != null && x._styleValue === true; } + function styleValueClass(x) { return x.className; } + function styleValue_p(x) { return x != null && x._styleValue === true; } + + function buildKeyframes(kfName, steps, env) { + // Platform implementation of defkeyframes + var parts = []; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var selector = isSym(step[0]) ? step[0].name : String(step[0]); + var body = trampoline(evalExpr(step[1], env)); + var decls = isStyleValue(body) ? body.declarations : String(body); + parts.push(selector + "{" + decls + "}"); + } + var kfRule = "@keyframes " + kfName + "{" + parts.join("") + "}"; + var cn = "sx-ref-kf-" + kfName; + var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]); + env[kfName] = sv; + return sv; + } + function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } function envSet(env, name, val) { env[name] = val; } @@ -288,6 +319,45 @@ PRIMITIVES["escape"] = function(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; + PRIMITIVES["split-ids"] = function(s) { + if (!s) return []; + return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + }; + PRIMITIVES["css"] = function() { + // Stub — CSSX requires style dictionary which is browser-only + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -306,6 +376,10 @@ return NIL; } function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } // List primitives used directly by transpiled code @@ -352,7 +426,8 @@ function isSpecialForm(n) { return n in { "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, - "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1, + "defkeyframes":1,"defhandler":1,"begin":1,"do":1, "quote":1,"quasiquote":1,"->":1,"set!":1 }; } function isHoForm(n) { return n in { @@ -371,7 +446,7 @@ var evalExpr = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { var name = symbolName(expr); return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); -})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return [k, trampoline(evalExpr(v, env))]; }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; +})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return trampoline(evalExpr(v, env)); }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; // eval-list var evalList = function(expr, env) { return (function() { @@ -379,10 +454,10 @@ var args = rest(expr); return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); - return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefine(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { var mac = envGet(env, name); return makeThunk(expandMacro(mac, args, env), env); -})() : evalCall(head, args, env)))))))))))))))))))))))))))); +})() : evalCall(head, args, env))))))))))))))))))))))))))))))); })() : evalCall(head, args, env))); })(); }; @@ -574,6 +649,21 @@ return [params, restParam]; })(); }; + // sf-defstyle + var sfDefstyle = function(args, env) { return (function() { + var nameSym = first(args); + var value = trampoline(evalExpr(nth(args, 1), env)); + env[symbolName(nameSym)] = value; + return value; +})(); }; + + // sf-defkeyframes + var sfDefkeyframes = function(args, env) { return (function() { + var kfName = symbolName(first(args)); + var steps = rest(args); + return buildKeyframes(kfName, steps, env); +})(); }; + // sf-begin var sfBegin = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 0, (len(args) - 1))), makeThunk(last(args), env))); }; @@ -667,26 +757,30 @@ return isEvery(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); })(); }; + // ho-for-each + var hoForEach = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return forEach(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + // === Transpiled from render.sx === // HTML_TAGS - var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "path", "circle", "rect", "line", "polyline", "polygon", "text", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; + var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "math", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text", "tspan", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "feTurbulence", "feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA", "feDisplacementMap", "feFlood", "feImage", "feMorphology", "feSpecularLighting", "feDiffuseLighting", "fePointLight", "feSpotLight", "feDistantLight", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; // VOID_ELEMENTS var VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]; // BOOLEAN_ATTRS - var BOOLEAN_ATTRS = ["disabled", "checked", "selected", "readonly", "required", "hidden", "autofocus", "autoplay", "controls", "loop", "muted", "defer", "async", "novalidate", "formnovalidate", "multiple", "open", "allowfullscreen"]; + var BOOLEAN_ATTRS = ["async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected"]; // render-to-html - var renderToHtml = function(expr, env) { return (function() { - var result = trampoline(evalExpr(expr, env)); - return renderValueToHtml(result, env); -})(); }; + var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; // render-value-to-html - var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); return escapeHtml((String(val))); })(); }; + var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); }; // render-list-to-html var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { @@ -697,7 +791,7 @@ return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = envGet(env, name); return (isSxTruthy(isComponent(comp)) ? renderToHtml(trampoline(callComponent(comp, args, env)), env) : error((String("Unknown component: ") + String(name)))); -})() : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))); +})() : (isSxTruthy(sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defkeyframes"), (name == "defhandler"))) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))); })()); })()); }; @@ -728,7 +822,7 @@ // render-attrs var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() { var val = dictGet(attrs, key); - return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))); + return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\"")))))); })(); }, keys(attrs))); }; // render-to-sx @@ -798,6 +892,11 @@ return _rawCallLambda(f, args, callerEnv); }; + // Expose render functions as primitives so SX code can call them + if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; + if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; + if (typeof aser === "function") PRIMITIVES["aser"] = aser; + // ========================================================================= // Parser (reused from reference — hand-written for bootstrap simplicity) // ========================================================================= @@ -824,8 +923,15 @@ var ch = text[pos]; if (ch === "(") { pos++; return readList(")"); } if (ch === "[") { pos++; return readList("]"); } + if (ch === "{") { pos++; return readMap(); } if (ch === '"') return readString(); if (ch === ":") return readKeyword(); + if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } + if (ch === ",") { + pos++; + if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; } + return [new Symbol("unquote"), readExpr()]; + } if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); if (ch >= "0" && ch <= "9") return readNumber(); return readSymbol(); @@ -839,6 +945,17 @@ items.push(readExpr()); } } + function readMap() { + var result = {}; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated map"); + if (text[pos] === "}") { pos++; return result; } + var key = readExpr(); + var keyStr = (key && key._kw) ? key.name : String(key); + result[keyStr] = readExpr(); + } + } function readString() { pos++; // skip " var s = ""; diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 5e9583c..bb53b8a 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -178,6 +178,13 @@ class JSEmitter: "ho-reduce": "hoReduce", "ho-some": "hoSome", "ho-every": "hoEvery", + "ho-for-each": "hoForEach", + "sf-defstyle": "sfDefstyle", + "sf-defkeyframes": "sfDefkeyframes", + "build-keyframes": "buildKeyframes", + "style-value?": "isStyleValue", + "style-value-class": "styleValueClass", + "kf-name": "kfName", "special-form?": "isSpecialForm", "ho-form?": "isHoForm", "strip-prefix": "stripPrefix", @@ -595,6 +602,15 @@ PREAMBLE = '''\ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -631,6 +647,7 @@ PLATFORM_JS = ''' if (x._component) return "component"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._styleValue) return "style-value"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; return "unknown"; @@ -676,6 +693,27 @@ PLATFORM_JS = ''' function isComponent(x) { return x != null && x._component === true; } function isMacro(x) { return x != null && x._macro === true; } + function isStyleValue(x) { return x != null && x._styleValue === true; } + function styleValueClass(x) { return x.className; } + function styleValue_p(x) { return x != null && x._styleValue === true; } + + function buildKeyframes(kfName, steps, env) { + // Platform implementation of defkeyframes + var parts = []; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var selector = isSym(step[0]) ? step[0].name : String(step[0]); + var body = trampoline(evalExpr(step[1], env)); + var decls = isStyleValue(body) ? body.declarations : String(body); + parts.push(selector + "{" + decls + "}"); + } + var kfRule = "@keyframes " + kfName + "{" + parts.join("") + "}"; + var cn = "sx-ref-kf-" + kfName; + var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]); + env[kfName] = sv; + return sv; + } + function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } function envSet(env, name, val) { env[name] = val; } @@ -826,6 +864,45 @@ PLATFORM_JS = ''' PRIMITIVES["escape"] = function(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; + PRIMITIVES["split-ids"] = function(s) { + if (!s) return []; + return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + }; + PRIMITIVES["css"] = function() { + // Stub — CSSX requires style dictionary which is browser-only + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -844,6 +921,10 @@ PLATFORM_JS = ''' return NIL; } function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } // List primitives used directly by transpiled code @@ -890,7 +971,8 @@ PLATFORM_JS = ''' function isSpecialForm(n) { return n in { "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, - "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1, + "defkeyframes":1,"defhandler":1,"begin":1,"do":1, "quote":1,"quasiquote":1,"->":1,"set!":1 }; } function isHoForm(n) { return n in { @@ -907,7 +989,12 @@ FIXUPS = ''' callLambda = function(f, args, callerEnv) { if (typeof f === "function") return f.apply(null, args); return _rawCallLambda(f, args, callerEnv); - };''' + }; + + // Expose render functions as primitives so SX code can call them + if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; + if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; + if (typeof aser === "function") PRIMITIVES["aser"] = aser;''' PUBLIC_API = ''' // ========================================================================= @@ -936,8 +1023,15 @@ PUBLIC_API = ''' var ch = text[pos]; if (ch === "(") { pos++; return readList(")"); } if (ch === "[") { pos++; return readList("]"); } + if (ch === "{") { pos++; return readMap(); } if (ch === \'"\') return readString(); if (ch === ":") return readKeyword(); + if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } + if (ch === ",") { + pos++; + if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; } + return [new Symbol("unquote"), readExpr()]; + } if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); if (ch >= "0" && ch <= "9") return readNumber(); return readSymbol(); @@ -951,6 +1045,17 @@ PUBLIC_API = ''' items.push(readExpr()); } } + function readMap() { + var result = {}; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated map"); + if (text[pos] === "}") { pos++; return result; } + var key = readExpr(); + var keyStr = (key && key._kw) ? key.name : String(key); + result[keyStr] = readExpr(); + } + } function readString() { pos++; // skip " var s = ""; diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index 018a621..fd1a60e 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -96,7 +96,7 @@ ;; --- dict literal --- "dict" - (map-dict (fn (k v) (list k (trampoline (eval-expr v env)))) expr) + (map-dict (fn (k v) (trampoline (eval-expr v env))) expr) ;; --- list = call or special form --- "list" @@ -141,6 +141,9 @@ (= name "define") (sf-define args env) (= name "defcomp") (sf-defcomp args env) (= name "defmacro") (sf-defmacro args env) + (= name "defstyle") (sf-defstyle args env) + (= name "defkeyframes") (sf-defkeyframes args env) + (= name "defhandler") (sf-define args env) (= name "begin") (sf-begin args env) (= name "do") (sf-begin args env) (= name "quote") (sf-quote args env) @@ -495,6 +498,25 @@ (list params rest-param)))) +(define sf-defstyle + (fn (args env) + ;; (defstyle name expr) — bind name to evaluated expr (typically a StyleValue) + (let ((name-sym (first args)) + (value (trampoline (eval-expr (nth args 1) env)))) + (env-set! env (symbol-name name-sym) value) + value))) + + +(define sf-defkeyframes + (fn (args env) + ;; (defkeyframes name (selector body) ...) — build @keyframes rule, + ;; register in keyframes dict, return StyleValue. + ;; Delegates to platform: build-keyframes returns a StyleValue. + (let ((kf-name (symbol-name (first args))) + (steps (rest args))) + (build-keyframes kf-name steps env)))) + + (define sf-begin (fn (args env) (if (empty? args) @@ -651,6 +673,15 @@ coll)))) +(define ho-for-each + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (for-each + (fn (item) (trampoline (call-lambda f (list item) env))) + coll)))) + + ;; -------------------------------------------------------------------------- ;; 8. Primitives — pure functions available in all targets ;; -------------------------------------------------------------------------- @@ -728,4 +759,7 @@ ;; (strip-prefix s prefix) → string with prefix removed (or s unchanged) ;; (apply f args) → call f with args list ;; (zip lists...) → list of tuples +;; +;; CSSX (style system): +;; (build-keyframes name steps env) → StyleValue (platform builds @keyframes) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx index 3ed6d17..5c50bf4 100644 --- a/shared/sx/ref/parser.sx +++ b/shared/sx/ref/parser.sx @@ -18,11 +18,13 @@ ;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]* ;; comment → ';' to end of line (discarded) ;; -;; Quote sugar (optional — not used in current SX): -;; '(expr) → (quote expr) +;; Dict literal: +;; {key val ...} → dict object (keys are keywords or expressions) +;; +;; Quote sugar: ;; `(expr) → (quasiquote expr) -;; ~(expr) → (unquote expr) -;; ~@(expr) → (splice-unquote expr) +;; ,(expr) → (unquote expr) +;; ,@(expr) → (splice-unquote expr) ;; ========================================================================== @@ -81,6 +83,37 @@ (advance-pos!) (scan-next)) + ;; Open brace (dict literal) + (= ch "{") + (do (append! tokens (list "lbrace" "{" line col)) + (advance-pos!) + (scan-next)) + + ;; Close brace + (= ch "}") + (do (append! tokens (list "rbrace" "}" line col)) + (advance-pos!) + (scan-next)) + + ;; Quasiquote sugar + (= ch "`") + (do (advance-pos!) + (let ((inner (scan-next-expr))) + (append! tokens (list "quasiquote" inner line col)) + (scan-next))) + + ;; Unquote / splice-unquote + (= ch ",") + (do (advance-pos!) + (if (and (< pos len-src) (= (nth source pos) "@")) + (do (advance-pos!) + (let ((inner (scan-next-expr))) + (append! tokens (list "splice-unquote" inner line col)) + (scan-next))) + (let ((inner (scan-next-expr))) + (append! tokens (list "unquote" inner line col)) + (scan-next)))) + ;; Keyword (= ch ":") (do (append! tokens (scan-keyword)) (scan-next)) @@ -229,6 +262,10 @@ (do (set! pos (inc pos)) (parse-list tokens "rbracket")) + "lbrace" + (do (set! pos (inc pos)) + (parse-dict tokens)) + "string" (do (set! pos (inc pos)) (nth tok 1)) "number" (do (set! pos (inc pos)) (nth tok 1)) "boolean" (do (set! pos (inc pos)) (nth tok 1)) @@ -261,6 +298,28 @@ items))) +(define parse-dict + (fn (tokens) + ;; Parse {key val key val ...} until "rbrace" token. + ;; Returns a dict (plain object). + (let ((result (dict))) + (define parse-dict-loop + (fn () + (if (>= pos (len tokens)) + (error "Unterminated dict") + (if (= (first (nth tokens pos)) "rbrace") + (do (set! pos (inc pos)) nil) ;; done + (let ((key-expr (parse-expr tokens)) + (key-str (if (= (type-of key-expr) "keyword") + (keyword-name key-expr) + (str key-expr))) + (val-expr (parse-expr tokens))) + (dict-set! result key-str val-expr) + (parse-dict-loop)))))) + (parse-dict-loop) + result))) + + ;; -------------------------------------------------------------------------- ;; Serializer — AST → SX source text ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index 05a9a9e..7aea3bb 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -426,3 +426,34 @@ :params (s) :returns "string" :doc "Remove HTML tags from string.") + + +;; -------------------------------------------------------------------------- +;; Date & parsing helpers +;; -------------------------------------------------------------------------- + +(define-primitive "parse-datetime" + :params (s) + :returns "string" + :doc "Parse datetime string — identity passthrough (returns string or nil).") + +(define-primitive "split-ids" + :params (s) + :returns "list" + :doc "Split comma-separated ID string into list of trimmed non-empty strings.") + + +;; -------------------------------------------------------------------------- +;; CSSX — style system primitives +;; -------------------------------------------------------------------------- + +(define-primitive "css" + :params (&rest atoms) + :returns "style-value" + :doc "Resolve style atoms to a StyleValue with className and CSS declarations. + Atoms are keywords or strings: (css :flex :gap-4 :hover:bg-sky-200).") + +(define-primitive "merge-styles" + :params (&rest styles) + :returns "style-value" + :doc "Merge multiple StyleValues into one combined StyleValue.") diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx index 0e118bd..624d781 100644 --- a/shared/sx/ref/render.sx +++ b/shared/sx/ref/render.sx @@ -44,10 +44,15 @@ ;; Media "img" "video" "audio" "source" "picture" "canvas" "iframe" ;; SVG - "svg" "path" "circle" "rect" "line" "polyline" "polygon" "text" - "g" "defs" "use" "clipPath" "mask" "pattern" "linearGradient" - "radialGradient" "stop" "filter" "feGaussianBlur" "feOffset" - "feBlend" "feColorMatrix" "feComposite" "feMerge" "feMergeNode" + "svg" "math" "path" "circle" "ellipse" "rect" "line" "polyline" "polygon" + "text" "tspan" "g" "defs" "use" "clipPath" "mask" "pattern" + "linearGradient" "radialGradient" "stop" "filter" + "feGaussianBlur" "feOffset" "feBlend" "feColorMatrix" "feComposite" + "feMerge" "feMergeNode" "feTurbulence" + "feComponentTransfer" "feFuncR" "feFuncG" "feFuncB" "feFuncA" + "feDisplacementMap" "feFlood" "feImage" "feMorphology" + "feSpecularLighting" "feDiffuseLighting" + "fePointLight" "feSpotLight" "feDistantLight" "animate" "animateTransform" "foreignObject" ;; Other "template" "slot" "dialog" "menu")) @@ -57,9 +62,10 @@ "link" "meta" "param" "source" "track" "wbr")) (define BOOLEAN_ATTRS - (list "disabled" "checked" "selected" "readonly" "required" "hidden" - "autofocus" "autoplay" "controls" "loop" "muted" "defer" "async" - "novalidate" "formnovalidate" "multiple" "open" "allowfullscreen")) + (list "async" "autofocus" "autoplay" "checked" "controls" "default" + "defer" "disabled" "formnovalidate" "hidden" "inert" "ismap" + "loop" "multiple" "muted" "nomodule" "novalidate" "open" + "playsinline" "readonly" "required" "reversed" "selected")) ;; -------------------------------------------------------------------------- @@ -68,8 +74,20 @@ (define render-to-html (fn (expr env) - (let ((result (trampoline (eval-expr expr env)))) - (render-value-to-html result env)))) + (case (type-of expr) + ;; Literals — render directly + "nil" "" + "string" (escape-html expr) + "number" (str expr) + "boolean" (if expr "true" "false") + ;; List — dispatch to render-list which handles HTML tags, special forms, etc. + "list" (if (empty? expr) "" (render-list-to-html expr env)) + ;; Symbol — evaluate then render + "symbol" (render-value-to-html (trampoline (eval-expr expr env)) env) + ;; Keyword — render as text + "keyword" (escape-html (keyword-name expr)) + ;; Everything else — evaluate first + :else (render-value-to-html (trampoline (eval-expr expr env)) env)))) (define render-value-to-html (fn (val env) @@ -80,6 +98,7 @@ "boolean" (if val "true" "false") "list" (render-list-to-html val env) "raw-html" (raw-html-content val) + "style-value" (style-value-class val) :else (escape-html (str val))))) (define render-list-to-html @@ -114,6 +133,11 @@ env) (error (str "Unknown component: " name)))) + ;; Definitions — evaluate for side effects, render nothing + (or (= name "define") (= name "defcomp") (= name "defmacro") + (= name "defstyle") (= name "defkeyframes") (= name "defhandler")) + (do (trampoline (eval-expr expr env)) "") + ;; Macro expansion (and (env-has? env name) (macro? (env-get env name))) (render-to-html @@ -182,6 +206,9 @@ "" ;; Nil values — skip (nil? val) "" + ;; StyleValue on :style → emit as class + (and (= key "style") (style-value? val)) + (str " class=\"" (style-value-class val) "\"") ;; Normal attr :else (str " " key "=\"" (escape-attr (str val)) "\"")))) (keys attrs))))) @@ -323,6 +350,10 @@ ;; (set-attribute el k v) → void ;; (append-child parent c) → void ;; +;; StyleValue: +;; (style-value? x) → boolean (is x a StyleValue?) +;; (style-value-class sv) → string (CSS class name) +;; ;; Serialization: ;; (serialize val) → SX source string representation of val ;; From dea4f52454b030e132cbe76f3f86f28de5847e65 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 10:20:24 +0000 Subject: [PATCH 08/24] Expand known components server-side in _aser to fix nested highlight calls _aser previously serialized all ~component calls for client rendering. Components whose bodies call Python-only functions (e.g. highlight) would fail on the client with "Undefined symbol". Now _aser expands components that are defined in the env via _aser_component, producing SX wire format with tag-level bodies inlined. Unknown components still serialize as-is. Co-Authored-By: Claude Opus 4.6 --- shared/sx/async_eval.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 395b10d..7b5d78d 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -1159,12 +1159,14 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: if name.startswith("html:"): return await _aser_call(name[5:], expr[1:], env, ctx) - # Component call — expand macros, serialize regular components + # Component call — expand macros, expand known components, serialize unknown if name.startswith("~"): val = env.get(name) if isinstance(val, Macro): expanded = _expand_macro(val, expr[1:], env) return await _aser(expanded, env, ctx) + if isinstance(val, Component): + return await _aser_component(val, expr[1:], env, ctx) return await _aser_call(name, expr[1:], env, ctx) # Serialize-mode special/HO forms (checked BEFORE HTML_TAGS From 4a515f1a0de2bb100e93b35ae48140372ccbe8e6 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 10:36:22 +0000 Subject: [PATCH 09/24] Add canonical SX language spec reference to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points AI and developers to shared/sx/ref/ as the authoritative source for SX semantics — eval rules, type system, rendering modes, component calling convention, and platform interface. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7fb92ec..297672f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,65 @@ artdag/ test/ # Integration & e2e tests ``` +## SX Language — Canonical Reference + +The SX language is defined by a self-hosting specification in `shared/sx/ref/`. **Read these files for authoritative SX semantics** — they supersede any implementation detail in `sx.js` or Python evaluators. + +### Specification files + +- **`shared/sx/ref/eval.sx`** — Core evaluator: types, trampoline (TCO), `eval-expr` dispatch, special forms (`if`, `when`, `cond`, `case`, `let`, `and`, `or`, `lambda`, `define`, `defcomp`, `defmacro`, `quasiquote`), higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`), macro expansion, function/lambda/component calling. +- **`shared/sx/ref/parser.sx`** — Tokenizer and parser: grammar, string escapes, dict literals `{:key val}`, quote sugar (`` ` ``, `,`, `,@`), serializer. +- **`shared/sx/ref/primitives.sx`** — All ~80 built-in pure functions: arithmetic, comparison, predicates, string ops, collection ops, dict ops, format helpers, CSSX style primitives. +- **`shared/sx/ref/render.sx`** — Three rendering modes: `render-to-html` (server HTML), `render-to-sx`/`aser` (SX wire format for client), `render-to-dom` (browser). HTML tag registry, void elements, boolean attrs. +- **`shared/sx/ref/bootstrap_js.py`** — Transpiler: reads the `.sx` spec files and emits `sx-ref.js`. + +### Type system + +``` +number, string, boolean, nil, symbol, keyword, list, dict, +lambda, component, macro, thunk (TCO deferred eval) +``` + +### Evaluation rules (from eval.sx) + +1. **Literals** (number, string, boolean, nil) — pass through +2. **Symbols** — look up in env, then primitives, then `true`/`false`/`nil`, else error +3. **Keywords** — evaluate to their string name +4. **Dicts** — evaluate all values recursively +5. **Lists** — dispatch on head: + - Special forms (`if`, `when`, `cond`, `case`, `let`, `lambda`, `define`, `defcomp`, `defmacro`, `quote`, `quasiquote`, `begin`/`do`, `set!`, `->`) + - Higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`, `map-indexed`) + - Macros — expand then re-evaluate + - Function calls — evaluate head and args, then: native callable → `apply`, lambda → bind params + TCO thunk, component → parse keyword args + bind params + TCO thunk + +### Component calling convention + +```lisp +(defcomp ~card (&key title subtitle &rest children) + (div :class "card" + (h2 title) + (when subtitle (p subtitle)) + children)) +``` + +- `&key` params are keyword arguments: `(~card :title "Hi" :subtitle "Sub")` +- `&rest children` captures positional args as `children` +- Component body evaluated in merged env: `closure + caller-env + bound-params` + +### Rendering modes (from render.sx) + +| Mode | Function | Expands components? | Output | +|------|----------|-------------------|--------| +| HTML | `render-to-html` | Yes (recursive) | HTML string | +| SX wire | `aser` | No — serializes `(~name ...)` | SX source text | +| DOM | `render-to-dom` | Yes (recursive) | DOM nodes | + +The `aser` (async-serialize) mode evaluates control flow and function calls but serializes HTML tags and component calls as SX source — the client renders them. This is the wire format for HTMX-like responses. + +### Platform interface + +Each target (JS, Python) must provide: type inspection (`type-of`), constructors (`make-lambda`, `make-component`, `make-macro`, `make-thunk`), accessors, environment operations (`env-has?`, `env-get`, `env-set!`, `env-extend`, `env-merge`), and DOM/HTML rendering primitives. + ## Tech Stack **Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn. @@ -110,11 +169,11 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/ ### SX Rendering Pipeline -The SX system renders component trees defined in s-expressions. The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn: +The SX system renders component trees defined in s-expressions. Canonical semantics are in `shared/sx/ref/` (see "SX Language" section above). The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn: -- `render_to_html(name, **kw)` — server-side, produces HTML. Used by route handlers returning full HTML. -- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js). -- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands the top-level component** then serializes children as SX wire format. Used by layout components that need Python context (auth state, fragments, URLs) resolved server-side. +- `render_to_html(name, **kw)` — server-side, produces HTML. Maps to `render-to-html` in the spec. +- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Maps to `aser` in the spec. Component calls stay **unexpanded**. +- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands known components** then serializes as SX wire format. Used by layout components that need Python context. - `sx_page(ctx, page_sx)` — produces the full HTML shell (`...`) with component definitions, CSS, and page SX inlined for client-side boot. See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table. From 6fa843016bc312488f7f2f6686bc9e69a5ce1749 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 11:03:50 +0000 Subject: [PATCH 10/24] Gate server-side component expansion with contextvar, fix nth arg order, add GEB essay and manifesto links - Add _expand_components contextvar so _aser only expands components during page slot evaluation (fixes highlight on examples, avoids breaking fragment responses) - Fix nth arg order (nth coll n) in docs.sx, examples.sx (delete-row, edit-row, bulk-update) - Add "Godel, Escher, Bach and SX" essay with Wikipedia links - Update SX Manifesto: new authors, Wikipedia links throughout, remove Marx/Engels link Co-Authored-By: Claude Opus 4.6 --- shared/sx/async_eval.py | 31 +++++++++++++++++++++++++++++-- sx/sx/essays.sx | 5 ++++- sx/sx/nav-data.sx | 3 ++- sx/sxc/docs.sx | 8 ++++---- sx/sxc/examples.sx | 6 +++--- sx/sxc/pages/docs.sx | 1 + 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 7b5d78d..ee836a8 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -41,10 +41,18 @@ Usage:: from __future__ import annotations +import contextvars import inspect from typing import Any from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol + +# When True, _aser expands known components server-side instead of serializing +# them for client rendering. Set during page slot evaluation so Python-only +# helpers (e.g. highlight) in component bodies execute on the server. +_expand_components: contextvars.ContextVar[bool] = contextvars.ContextVar( + "_expand_components", default=False +) from .evaluator import _expand_macro, EvalError from .primitives import _PRIMITIVES from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io @@ -1058,6 +1066,24 @@ async def async_eval_slot_to_sx( """ if ctx is None: ctx = RequestContext() + + # Enable server-side component expansion for this slot evaluation. + # This lets _aser expand known components (so Python-only helpers + # like highlight execute server-side) instead of serializing them + # for client rendering. + token = _expand_components.set(True) + try: + return await _eval_slot_inner(expr, env, ctx) + finally: + _expand_components.reset(token) + + +async def _eval_slot_inner( + expr: Any, + env: dict[str, Any], + ctx: RequestContext, +) -> str: + """Inner implementation — runs with _expand_components=True.""" # If expr is a component call, expand it through _aser if isinstance(expr, list) and expr: head = expr[0] @@ -1159,13 +1185,14 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: if name.startswith("html:"): return await _aser_call(name[5:], expr[1:], env, ctx) - # Component call — expand macros, expand known components, serialize unknown + # Component call — expand macros, expand known components (in slot + # eval context only), serialize unknown if name.startswith("~"): val = env.get(name) if isinstance(val, Macro): expanded = _expand_macro(val, expr[1:], env) return await _aser(expanded, env, ctx) - if isinstance(val, Component): + if isinstance(val, Component) and _expand_components.get(): return await _aser_component(val, expr[1:], env, ctx) return await _aser_call(name, expr[1:], env, ctx) diff --git a/sx/sx/essays.sx b/sx/sx/essays.sx index 2221700..3ca3f55 100644 --- a/sx/sx/essays.sx +++ b/sx/sx/essays.sx @@ -19,10 +19,13 @@ (~doc-page :title "SX Native: Beyond the Browser" (~doc-section :title "The thesis" :id "thesis" (p :class "text-stone-600" "sx.js is a ~2,300-line tree-walking interpreter with ~50 primitives. The DOM is just one rendering target. Swap the DOM adapter for a platform-native adapter and you get React Native — but with s-expressions and a 50-primitive surface area.") (p :class "text-stone-600" "The interpreter does not know about HTML. It evaluates expressions, calls primitives, expands macros, and hands render instructions to an adapter. The adapter creates elements. Today that adapter creates DOM nodes. It does not have to.")) (~doc-section :title "Why this isn\'t a WebView" :id "not-webview" (p :class "text-stone-600" "SX Native means the sx evaluator rendering to native UI widgets directly. No DOM. No CSS. No HTML. (button :on-press handler \"Submit\") creates a native UIButton on iOS, a Material Button on Android, a GtkButton on Linux.") (p :class "text-stone-600" "WebView wrappers (Cordova, Capacitor, Electron) ship a browser inside your app. They inherit all browser limitations: memory overhead, no native feel, no platform integration. SX Native has none of these because there is no browser.")) (~doc-section :title "Architecture" :id "architecture" (p :class "text-stone-600" "The architecture splits into shared and platform-specific layers:") (ul :class "space-y-2 text-stone-600 mt-2" (li (strong "Shared (portable):") " Parser, evaluator, all 50+ primitives, component system, macro expansion, closures, component cache") (li (strong "Platform adapters:") " Web DOM, iOS UIKit/SwiftUI, Android Compose, Desktop GTK/Qt, Terminal TUI, WASM")) (p :class "text-stone-600" "Only ~15 rendering primitives need platform-specific implementations. The rest — arithmetic, string operations, list manipulation, higher-order functions, control flow — are pure computation with no platform dependency.")) (~doc-section :title "The primitive contract" :id "primitive-contract" (p :class "text-stone-600" "A platform adapter implements a small interface:") (ul :class "space-y-2 text-stone-600 mt-2" (li (code :class "text-violet-700" "createElement(tag)") " — create a platform widget") (li (code :class "text-violet-700" "createText(str)") " — create a text node") (li (code :class "text-violet-700" "setAttribute(el, key, val)") " — set a property") (li (code :class "text-violet-700" "appendChild(parent, child)") " — attach to tree") (li (code :class "text-violet-700" "addEventListener(el, event, fn)") " — bind interaction") (li (code :class "text-violet-700" "removeChild(parent, child)") " — detach from tree")) (p :class "text-stone-600" "Layout uses a flexbox-like model mapped to native constraint systems. Styling maps a CSS property subset to native appearance APIs. The mapping is lossy but covers the common cases.")) (~doc-section :title "What transfers, what doesn\'t" :id "transfers" (p :class "text-stone-600" "What transfers wholesale: parser, evaluator, all non-DOM primitives, component system (defcomp, defmacro), closures, the component cache, keyword argument handling, list/dict operations.") (p :class "text-stone-600" "What needs replacement: HTML tags become abstract widgets, CSS becomes platform layout, SxEngine fetch/swap/history becomes native navigation, innerHTML/outerHTML have no equivalent.")) (~doc-section :title "Component mapping" :id "component-mapping" (p :class "text-stone-600" "HTML elements map to platform-native widgets:") (div :class "overflow-x-auto mt-4" (table :class "w-full text-sm text-left" (thead (tr :class "border-b border-stone-200" (th :class "py-2 pr-4 font-semibold text-stone-700" "HTML") (th :class "py-2 font-semibold text-stone-700" "Native widget"))) (tbody :class "text-stone-600" (tr :class "border-b border-stone-100" (td :class "py-2 pr-4" "div") (td :class "py-2" "View / Container")) (tr :class "border-b border-stone-100" (td :class "py-2 pr-4" "span / p") (td :class "py-2" "Text / Label")) (tr :class "border-b border-stone-100" (td :class "py-2 pr-4" "button") (td :class "py-2" "Button")) (tr :class "border-b border-stone-100" (td :class "py-2 pr-4" "input") (td :class "py-2" "TextInput / TextField")) (tr :class "border-b border-stone-100" (td :class "py-2 pr-4" "img") (td :class "py-2" "Image / ImageView")) (tr (td :class "py-2 pr-4" "ul / li") (td :class "py-2" "List / ListItem")))))) (~doc-section :title "Prior art" :id "prior-art" (p :class "text-stone-600" "React Native: JavaScript evaluated by Hermes/JSC, commands sent over a bridge to native UI. Lesson: the bridge is the bottleneck. Serialization overhead, async communication, layout thrashing across the boundary.") (p :class "text-stone-600" "Flutter: Dart compiled to native, renders via Skia/Impeller to a canvas. Lesson: owning the renderer avoids platform inconsistencies but sacrifices native feel.") (p :class "text-stone-600" ".NET MAUI, Kotlin Multiplatform: shared logic with platform-native UI. Closest to what SX Native would be.") (p :class "text-stone-600" "sx advantage: the evaluator is tiny (~2,300 lines), the primitive surface is minimal (~50), and s-expressions are trivially portable. No bridge overhead because the evaluator runs in-process.")) (~doc-section :title "Language options" :id "language-options" (p :class "text-stone-600" "The native evaluator needs to be written in a language that compiles everywhere:") (ul :class "space-y-2 text-stone-600 mt-2" (li (strong "Rust") " — compiles to every target, excellent FFI, strong safety guarantees") (li (strong "Zig") " — simpler, C ABI compatibility, good for embedded") (li (strong "Swift") " — native on iOS, good interop with Apple platforms") (li (strong "Kotlin MP") " — Android + iOS + desktop, JVM ecosystem")) (p :class "text-stone-600" "Recommendation: Rust evaluator core with thin Swift and Kotlin adapters for iOS and Android respectively. Rust compiles to WASM (replacing sx.js), native libraries (mobile/desktop), and standalone binaries (CLI/server).")) (~doc-section :title "Incremental path" :id "incremental-path" (p :class "text-stone-600" "This is not an all-or-nothing project. Each step delivers value independently:") (ol :class "space-y-2 text-stone-600 mt-2 list-decimal list-inside" (li "Extract platform-agnostic evaluator from sx.js — clean separation of concerns") (li "Rust port of evaluator — enables WASM, edge workers, embedded") (li "Terminal adapter (ratatui TUI) — simplest platform, fastest iteration cycle") (li "iOS SwiftUI adapter — Rust core via swift-bridge, SwiftUI rendering") (li "Android Compose adapter — Rust core via JNI, Compose rendering") (li "Shared components render identically everywhere"))) (~doc-section :title "The federated angle" :id "federated" (p :class "text-stone-600" "Native sx apps are ActivityPub citizens. They receive activities, evaluate component templates, and render natively. A remote profile, post, or event arrives as an ActivityPub activity. The native app has sx component definitions cached locally. It evaluates the component with the activity data and renders platform-native UI.") (p :class "text-stone-600" "This is the cooperative web vision extended to native platforms. Content and UI travel together as s-expressions. The rendering target — browser, phone, terminal — is an implementation detail.")) (~doc-section :title "Realistic assessment" :id "assessment" (p :class "text-stone-600" "This is a multi-year project. But the architecture is sound because the primitive surface is small.") (p :class "text-stone-600" "Immediate value: a Rust evaluator enables WASM (drop-in replacement for sx.js), edge workers (Cloudflare/Deno), and embedded use cases. This is worth building regardless of whether native mobile ever ships.") (p :class "text-stone-600" "Terminal adapter: weeks of work with ratatui. Useful for CLI tools, server-side dashboards, ssh-accessible interfaces.") (p :class "text-stone-600" "Mobile: 6-12 months of dedicated work for a production-quality adapter. The evaluator is the easy part. Platform integration — navigation, gestures, accessibility, text input — is where the complexity lives.")))) (defcomp ~essay-sx-manifesto () - (~doc-page :title "The SX Manifesto" (p :class "text-stone-500 text-sm italic mb-8" "After " (a :href "https://www.marxists.org/archive/marx/works/1848/communist-manifesto/" :class "text-violet-600 hover:underline" "Marx & Engels") ", loosely") (~doc-section :title "I. A spectre is haunting the web" :id "spectre" (p :class "text-stone-600" "A spectre is haunting the web — the spectre of s-expressions. All the powers of the old web have entered into a holy alliance to exorcise this spectre: Google and Meta, webpack and Vercel, Stack Overflow moderators and DevRel influencers.") (p :class "text-stone-600" "Where is the rendering paradigm that has not been decried as a step backward by its opponents? Where is the framework that has not hurled back the branding reproach of \"not production-ready\" against the more advanced paradigms, as well as against its reactionary adversaries?") (p :class "text-stone-600" "Two things result from this fact:") (ol :class "space-y-2 text-stone-600 mt-2 list-decimal list-inside" (li "S-expressions are already acknowledged by all web powers to be themselves a power.") (li "It is high time that s-expressions should openly, in the face of the whole world, publish their views, their aims, their tendencies, and meet this nursery tale of the Spectre of SX with a manifesto of the paradigm itself."))) (~doc-section :title "II. HTML, JavaScript, and CSS" :id "bourgeois" (p :class "text-stone-600" "The history of all hitherto existing web development is the history of language struggles.") (p :class "text-stone-600" "Markup and logic, template and script, structure and style — in a word, oppressor and oppressed — stood in constant opposition to one another, carried on an uninterrupted, now hidden, now open fight, a fight that each time ended in a laborious reconfiguration of webpack.") (p :class "text-stone-600" "In the earlier epochs of web development we find almost everywhere a complicated arrangement of separate languages into various orders, a manifold gradation of technical rank: HTML, CSS, JavaScript, XML, XSLT, JSON, YAML, TOML, JSX, TSX, Sass, Less, PostCSS, Tailwind, and above them all, the build step.") (p :class "text-stone-600" "The modern web, sprouted from the ruins of CGI-bin, has not done away with language antagonisms. It has but established new languages, new conditions of oppression, new forms of struggle in place of the old ones.") (p :class "text-stone-600" "Our epoch, the epoch of the framework, possesses, however, this distinctive feature: it has simplified the language antagonisms. The whole of web society is more and more splitting into two great hostile camps, into two great classes directly facing each other: the server and the client.")) (~doc-section :title "III. The ruling languages" :id "ruling-languages" (p :class "text-stone-600" "HTML, the most ancient of the ruling languages, established itself through the divine right of the angle bracket. It was born inert — a document format, not a programming language — and it has spent three decades insisting this is a feature, not a limitation.") (p :class "text-stone-600" "JavaScript, originally a servant hired for a fortnight to validate forms, staged a palace coup. It seized the means of interaction, then the means of rendering, then the means of server-side execution, and finally declared itself the universal language of computation. Like every revolutionary who becomes a tyrant, it kept the worst habits of the regime it overthrew: weak typing, prototype chains, and the " (span :class "italic" "this") " keyword.") (p :class "text-stone-600" "CSS, the third estate, controls all visual presentation while pretending to be declarative. It has no functions. Then it had functions. It has no variables. Then it had variables. It has no nesting. Then it had nesting. It is not a programming language. Then it was Turing-complete. CSS is the Vicar of Bray of web technologies — loyal to whichever paradigm currently holds power.") (p :class "text-stone-600" "These three languages rule by enforced separation. Structure here. Style there. Behaviour somewhere else. The developer — the proletarian — must learn all three, must context-switch between all three, must maintain the fragile peace between all three. The separation of concerns has become the separation of the developer's sanity.")) (~doc-section :title "IV. The petty-bourgeois frameworks" :id "frameworks" (p :class "text-stone-600" "Between the ruling languages and the oppressed developer, a vast class of intermediaries has arisen: the frameworks. React, Vue, Angular, Svelte, Solid, Qwik, Astro, Next, Nuxt, Remix, Gatsby, and ten thousand others whose names will not survive the decade.") (p :class "text-stone-600" "The frameworks are the petty bourgeoisie of web development. They do not challenge the rule of HTML, JavaScript, and CSS. They merely interpose themselves between the developer and the ruling languages, extracting rent in the form of configuration files, build pipelines, and breaking changes.") (p :class "text-stone-600" "Each framework promises liberation. Each framework delivers a new dependency tree. React freed us from manual DOM manipulation and gave us a virtual DOM, a reconciler, hooks with seventeen rules, and a conference circuit. Vue freed us from React's complexity and gave us the Options API, then the Composition API, then told us the Options API was fine actually. Angular freed us from choice and gave us a CLI that generates eleven files to display \"Hello World.\" Svelte freed us from the virtual DOM and gave us a compiler. SolidJS freed us from React's re-rendering and gave us signals, which React then adopted, completing the circle.") (p :class "text-stone-600" "The frameworks reproduce the very conditions they claim to abolish. They bridge the gap between HTML, JavaScript, and CSS by adding a fourth language — JSX, SFCs, templates — which must itself be compiled back into the original three. The revolution merely adds a build step.") (p :class "text-stone-600" "And beside the frameworks stand the libraries — the lumpenproletariat of the ecosystem. Lodash, Moment, Axios, left-pad. They attach themselves to whichever framework currently holds power, contributing nothing original, merely wrapping what already exists, adding weight to the node_modules directory until it exceeds the mass of the sun.")) (~doc-section :title "V. The build step as the state apparatus" :id "build-step" (p :class "text-stone-600" "The build step is the state apparatus of the framework bourgeoisie. It enforces the class structure. It compiles JSX into createElement calls. It transforms TypeScript into JavaScript. It processes Sass into CSS. It tree-shakes. It code-splits. It hot-module-replaces. It does everything except let you write code and run it.") (p :class "text-stone-600" "webpack begat Rollup. Rollup begat Parcel. Parcel begat esbuild. esbuild begat Vite. Vite begat Turbopack. Each new bundler promises to be the last bundler. Each new bundler is faster than the last at doing something that should not need to be done at all.") (p :class "text-stone-600" "The build step exists because the ruling languages cannot express components. HTML has no composition model. CSS has no scoping. JavaScript has no template syntax. The build step papers over these failures with transpilation, and calls it developer experience.")) (~doc-section :title "VI. The s-expression revolution" :id "revolution" (p :class "text-stone-600" "The s-expression abolishes the language distinction itself. There is no HTML. There is no separate JavaScript. There is no CSS-as-a-separate-language. There is only the expression.") (p :class "text-stone-600" "Code is data. Data is DOM. DOM is code. The dialectical unity that HTML, JavaScript, and CSS could never achieve — because they are three languages pretending to be one system — is the natural state of the s-expression, which has been one language since 1958.") (p :class "text-stone-600" "The component is not a class, not a function, not a template. The component is a list whose first element is a symbol. Composition is nesting. Abstraction is binding. There is no JSX because there is no gap between the expression language and the thing being expressed.") (p :class "text-stone-600" "The build step is abolished because there is nothing to compile. S-expressions are already in their final form. The parser is thirty lines. The evaluator is fifty primitives. The same source runs on server and client without transformation.") (p :class "text-stone-600" "The framework is abolished because the language is the framework. defcomp replaces the component model. defmacro replaces the plugin system. The evaluator replaces the runtime. What remains is not a framework but a language — and languages do not have breaking changes between minor versions.")) (~doc-section :title "VII. Objections from the bourgeoisie" :id "objections" (p :class "text-stone-600" "\"You would destroy the separation of concerns!\" they cry. The separation of concerns was destroyed long ago. React components contain markup, logic, and inline styles. Vue single-file components put template, script, and style in one file. Tailwind puts styling in the markup. The separation of concerns has been dead for years; the ruling classes merely maintain the pretence at conferences.") (p :class "text-stone-600" "\"Nobody uses s-expressions!\" they cry. Emacs has been running on s-expressions since 1976. Clojure runs Fortune 500 backends on s-expressions. Every Lisp programmer who ever lived has known what the web refuses to admit: that the parenthesis is not a bug but the minimal syntax for structured data.") (p :class "text-stone-600" "\"Where is the ecosystem?\" they cry. The ecosystem is the problem. Two million npm packages, of which fourteen are useful and the rest are competing implementations of is-odd. The s-expression needs no ecosystem because the language itself provides what packages exist to paper over: composition, abstraction, and code-as-data.") (p :class "text-stone-600" "\"But TypeScript!\" they cry. TypeScript is a type system bolted onto a language that was designed in ten days by a man who wanted to write Scheme. We have simply completed his original vision.") (p :class "text-stone-600" "\"You have no jobs!\" they cry. Correct. We have no jobs, no conference talks, no DevRel budget, no venture capital, no stickers, and no swag. We have something better: a language that does not require a migration guide between versions.")) (~doc-section :title "VIII. The CSS question" :id "css-question" (p :class "text-stone-600" "CSS presents a special case in the revolutionary analysis. It is neither fully a ruling language nor fully a servant — it is the collaborator class, providing aesthetic legitimacy to whichever regime currently holds power.") (p :class "text-stone-600" "CSS-in-JS was the first attempt at annexation: JavaScript consuming CSS entirely, reducing it to template literals and runtime overhead. This provocation produced the counter-revolution of utility classes — Tailwind — which reasserted CSS's independence by making the developer write CSS in HTML attributes while insisting this was not inline styles.") (p :class "text-stone-600" "The s-expression resolves the CSS question by eliminating it. Styles are expressions. " (code :class "text-violet-700" "(css :flex :gap-4 :p-2)") " is not a class name, not an inline style, not a CSS-in-JS template literal. It is a function call that returns a value. The value produces a generated class. The class is delivered on demand. No build step. No runtime overhead. No Tailwind config.") (p :class "text-stone-600" "Code is data is DOM is " (span :class "italic" "style") ".")) (~doc-section :title "IX. Programme" :id "programme" (p :class "text-stone-600" "The s-expressionists disdain to conceal their views and aims. They openly declare that their ends can be attained only by the forcible overthrow of all existing rendering conditions. Let the ruling languages tremble at a parenthetical revolution. The developers have nothing to lose but their node_modules.") (p :class "text-stone-600" "The immediate aims of the s-expressionists are:") (ol :class "space-y-2 text-stone-600 mt-2 list-decimal list-inside" (li "Abolition of the build step and all its instruments of compilation") (li "Abolition of the framework as a class distinct from the language") (li "Centralisation of rendering in the hands of a single evaluator, running identically on server and client") (li "Abolition of the language distinction between structure, style, and behaviour") (li "Equal obligation of all expressions to be data as well as code") (li "Gradual abolition of the distinction between server and client by means of a uniform wire protocol") (li "Free evaluation for all expressions in public and private environments") (li "Abolition of the node_modules directory " (span :class "text-stone-400 italic" "(this alone justifies the revolution)"))) (p :class "text-stone-600" "In place of the old web, with its languages and language antagonisms, we shall have an association in which the free evaluation of each expression is the condition for the free evaluation of all.") (p :class "text-stone-800 font-semibold text-lg mt-8 text-center" "DEVELOPERS OF ALL SERVICES, UNITE!") (p :class "text-stone-400 text-xs italic mt-6 text-center" "The authors acknowledge that this manifesto was produced by the very means of AI production it fails to mention. This is not a contradiction. It is dialectics.")))) + (~doc-page :title "The SX Manifesto" (p :class "text-stone-500 text-sm italic mb-4" "Carl Markdown and Friedrich Anglebrackets") (p :class "text-stone-500 text-sm italic mb-8" "A " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "parenthetical") " revolution") (~doc-section :title "I. A spectre is haunting the web" :id "spectre" (p :class "text-stone-600" "A " (a :href "https://en.wikipedia.org/wiki/Spectre" :class "text-violet-600 hover:underline" "spectre") " is haunting the web — the spectre of " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "s-expressions") ". All the powers of the old web have entered into a holy alliance to exorcise this spectre: " (a :href "https://en.wikipedia.org/wiki/Google" :class "text-violet-600 hover:underline" "Google") " and " (a :href "https://en.wikipedia.org/wiki/Meta_Platforms" :class "text-violet-600 hover:underline" "Meta") ", " (a :href "https://en.wikipedia.org/wiki/Webpack" :class "text-violet-600 hover:underline" "webpack") " and " (a :href "https://en.wikipedia.org/wiki/Vercel" :class "text-violet-600 hover:underline" "Vercel") ", " (a :href "https://en.wikipedia.org/wiki/Stack_Overflow" :class "text-violet-600 hover:underline" "Stack Overflow") " moderators and DevRel influencers.") (p :class "text-stone-600" "Where is the rendering paradigm that has not been decried as a step backward by its opponents? Where is the framework that has not hurled back the branding reproach of \"not production-ready\" against the more advanced paradigms, as well as against its reactionary adversaries?") (p :class "text-stone-600" "Two things result from this fact:") (ol :class "space-y-2 text-stone-600 mt-2 list-decimal list-inside" (li "S-expressions are already acknowledged by all web powers to be themselves a power.") (li "It is high time that s-expressions should openly, in the face of the whole world, publish their views, their aims, their tendencies, and meet this nursery tale of the Spectre of SX with a manifesto of the paradigm itself."))) (~doc-section :title "II. HTML, JavaScript, and CSS" :id "bourgeois" (p :class "text-stone-600" "The history of all hitherto existing " (a :href "https://en.wikipedia.org/wiki/Web_development" :class "text-violet-600 hover:underline" "web development") " is the history of language struggles.") (p :class "text-stone-600" "Markup and logic, template and script, structure and style — in a word, oppressor and oppressed — stood in constant opposition to one another, carried on an uninterrupted, now hidden, now open fight, a fight that each time ended in a laborious reconfiguration of " (a :href "https://en.wikipedia.org/wiki/Webpack" :class "text-violet-600 hover:underline" "webpack") ".") (p :class "text-stone-600" "In the earlier epochs of web development we find almost everywhere a complicated arrangement of separate languages into various orders, a manifold gradation of technical rank: " (a :href "https://en.wikipedia.org/wiki/HTML" :class "text-violet-600 hover:underline" "HTML") ", " (a :href "https://en.wikipedia.org/wiki/CSS" :class "text-violet-600 hover:underline" "CSS") ", " (a :href "https://en.wikipedia.org/wiki/JavaScript" :class "text-violet-600 hover:underline" "JavaScript") ", " (a :href "https://en.wikipedia.org/wiki/XML" :class "text-violet-600 hover:underline" "XML") ", " (a :href "https://en.wikipedia.org/wiki/XSLT" :class "text-violet-600 hover:underline" "XSLT") ", " (a :href "https://en.wikipedia.org/wiki/JSON" :class "text-violet-600 hover:underline" "JSON") ", " (a :href "https://en.wikipedia.org/wiki/YAML" :class "text-violet-600 hover:underline" "YAML") ", " (a :href "https://en.wikipedia.org/wiki/TOML" :class "text-violet-600 hover:underline" "TOML") ", " (a :href "https://en.wikipedia.org/wiki/JSX_(JavaScript)" :class "text-violet-600 hover:underline" "JSX") ", TSX, " (a :href "https://en.wikipedia.org/wiki/Sass_(style_sheet_language)" :class "text-violet-600 hover:underline" "Sass") ", " (a :href "https://en.wikipedia.org/wiki/Less_(style_sheet_language)" :class "text-violet-600 hover:underline" "Less") ", " (a :href "https://en.wikipedia.org/wiki/PostCSS" :class "text-violet-600 hover:underline" "PostCSS") ", " (a :href "https://en.wikipedia.org/wiki/Tailwind_CSS" :class "text-violet-600 hover:underline" "Tailwind") ", and above them all, the build step.") (p :class "text-stone-600" "The modern web, sprouted from the ruins of " (a :href "https://en.wikipedia.org/wiki/Common_Gateway_Interface" :class "text-violet-600 hover:underline" "CGI-bin") ", has not done away with language antagonisms. It has but established new languages, new conditions of oppression, new forms of struggle in place of the old ones.") (p :class "text-stone-600" "Our epoch, the epoch of the framework, possesses, however, this distinctive feature: it has simplified the language antagonisms. The whole of web society is more and more splitting into two great hostile camps, into two great classes directly facing each other: the " (a :href "https://en.wikipedia.org/wiki/Server-side" :class "text-violet-600 hover:underline" "server") " and the " (a :href "https://en.wikipedia.org/wiki/Client-side" :class "text-violet-600 hover:underline" "client") ".")) (~doc-section :title "III. The ruling languages" :id "ruling-languages" (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/HTML" :class "text-violet-600 hover:underline" "HTML") ", the most ancient of the ruling languages, established itself through the divine right of the " (a :href "https://en.wikipedia.org/wiki/SGML" :class "text-violet-600 hover:underline" "angle bracket") ". It was born inert — a document format, not a programming language — and it has spent three decades insisting this is a feature, not a limitation.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/JavaScript" :class "text-violet-600 hover:underline" "JavaScript") ", originally a servant hired for " (a :href "https://en.wikipedia.org/wiki/JavaScript#History" :class "text-violet-600 hover:underline" "a fortnight") " to validate forms, staged a palace coup. It seized the means of interaction, then the means of rendering, then the means of " (a :href "https://en.wikipedia.org/wiki/Node.js" :class "text-violet-600 hover:underline" "server-side execution") ", and finally declared itself the universal language of computation. Like every revolutionary who becomes a tyrant, it kept the worst habits of the regime it overthrew: " (a :href "https://en.wikipedia.org/wiki/Strong_and_weak_typing" :class "text-violet-600 hover:underline" "weak typing") ", " (a :href "https://en.wikipedia.org/wiki/Prototype-based_programming" :class "text-violet-600 hover:underline" "prototype chains") ", and the " (span :class "italic" (a :href "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this" :class "text-violet-600 hover:underline" "this")) " keyword.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/CSS" :class "text-violet-600 hover:underline" "CSS") ", the " (a :href "https://en.wikipedia.org/wiki/Third_estate" :class "text-violet-600 hover:underline" "third estate") ", controls all visual presentation while pretending to be " (a :href "https://en.wikipedia.org/wiki/Declarative_programming" :class "text-violet-600 hover:underline" "declarative") ". It has no functions. Then it had functions. It has no variables. Then it had " (a :href "https://en.wikipedia.org/wiki/CSS_custom_properties" :class "text-violet-600 hover:underline" "variables") ". It has no nesting. Then it had " (a :href "https://en.wikipedia.org/wiki/CSS_nesting" :class "text-violet-600 hover:underline" "nesting") ". It is not a programming language. Then it was " (a :href "https://en.wikipedia.org/wiki/Turing_completeness" :class "text-violet-600 hover:underline" "Turing-complete") ". CSS is the " (a :href "https://en.wikipedia.org/wiki/Vicar_of_Bray_(song)" :class "text-violet-600 hover:underline" "Vicar of Bray") " of web technologies — loyal to whichever paradigm currently holds power.") (p :class "text-stone-600" "These three languages rule by enforced separation. Structure here. Style there. Behaviour somewhere else. The developer — the " (a :href "https://en.wikipedia.org/wiki/Proletariat" :class "text-violet-600 hover:underline" "proletarian") " — must learn all three, must context-switch between all three, must maintain the fragile peace between all three. The " (a :href "https://en.wikipedia.org/wiki/Separation_of_concerns" :class "text-violet-600 hover:underline" "separation of concerns") " has become the separation of the developer's sanity.")) (~doc-section :title "IV. The petty-bourgeois frameworks" :id "frameworks" (p :class "text-stone-600" "Between the ruling languages and the oppressed developer, a vast class of intermediaries has arisen: the frameworks. " (a :href "https://en.wikipedia.org/wiki/React_(software)" :class "text-violet-600 hover:underline" "React") ", " (a :href "https://en.wikipedia.org/wiki/Vue.js" :class "text-violet-600 hover:underline" "Vue") ", " (a :href "https://en.wikipedia.org/wiki/Angular_(web_framework)" :class "text-violet-600 hover:underline" "Angular") ", " (a :href "https://en.wikipedia.org/wiki/Svelte" :class "text-violet-600 hover:underline" "Svelte") ", " (a :href "https://www.solidjs.com/" :class "text-violet-600 hover:underline" "Solid") ", " (a :href "https://qwik.dev/" :class "text-violet-600 hover:underline" "Qwik") ", " (a :href "https://astro.build/" :class "text-violet-600 hover:underline" "Astro") ", " (a :href "https://en.wikipedia.org/wiki/Next.js" :class "text-violet-600 hover:underline" "Next") ", " (a :href "https://en.wikipedia.org/wiki/Nuxt.js" :class "text-violet-600 hover:underline" "Nuxt") ", " (a :href "https://remix.run/" :class "text-violet-600 hover:underline" "Remix") ", " (a :href "https://en.wikipedia.org/wiki/Gatsby_(framework)" :class "text-violet-600 hover:underline" "Gatsby") ", and ten thousand others whose names will not survive the decade.") (p :class "text-stone-600" "The frameworks are the " (a :href "https://en.wikipedia.org/wiki/Petite_bourgeoisie" :class "text-violet-600 hover:underline" "petty bourgeoisie") " of web development. They do not challenge the rule of HTML, JavaScript, and CSS. They merely interpose themselves between the developer and the ruling languages, extracting " (a :href "https://en.wikipedia.org/wiki/Rent-seeking" :class "text-violet-600 hover:underline" "rent") " in the form of configuration files, build pipelines, and breaking changes.") (p :class "text-stone-600" "Each framework promises liberation. Each framework delivers a new " (a :href "https://en.wikipedia.org/wiki/Dependency_hell" :class "text-violet-600 hover:underline" "dependency tree") ". React freed us from manual " (a :href "https://en.wikipedia.org/wiki/Document_Object_Model" :class "text-violet-600 hover:underline" "DOM") " manipulation and gave us a " (a :href "https://en.wikipedia.org/wiki/Virtual_DOM" :class "text-violet-600 hover:underline" "virtual DOM") ", a reconciler, " (a :href "https://react.dev/reference/react/hooks" :class "text-violet-600 hover:underline" "hooks") " with seventeen rules, and a conference circuit. Vue freed us from React's complexity and gave us the Options API, then the Composition API, then told us the Options API was fine actually. Angular freed us from choice and gave us a CLI that generates eleven files to display \"Hello World.\" Svelte freed us from the virtual DOM and gave us a compiler. SolidJS freed us from React's re-rendering and gave us " (a :href "https://en.wikipedia.org/wiki/Reactive_programming" :class "text-violet-600 hover:underline" "signals") ", which React then adopted, completing the circle.") (p :class "text-stone-600" "The frameworks reproduce the very conditions they claim to abolish. They bridge the gap between HTML, JavaScript, and CSS by adding a fourth language — " (a :href "https://en.wikipedia.org/wiki/JSX_(JavaScript)" :class "text-violet-600 hover:underline" "JSX") ", " (a :href "https://vuejs.org/guide/scaling-up/sfc" :class "text-violet-600 hover:underline" "SFCs") ", templates — which must itself be " (a :href "https://en.wikipedia.org/wiki/Source-to-source_compiler" :class "text-violet-600 hover:underline" "compiled") " back into the original three. The revolution merely adds a build step.") (p :class "text-stone-600" "And beside the frameworks stand the libraries — the " (a :href "https://en.wikipedia.org/wiki/Lumpenproletariat" :class "text-violet-600 hover:underline" "lumpenproletariat") " of the ecosystem. " (a :href "https://en.wikipedia.org/wiki/Lodash" :class "text-violet-600 hover:underline" "Lodash") ", " (a :href "https://en.wikipedia.org/wiki/Moment.js" :class "text-violet-600 hover:underline" "Moment") ", " (a :href "https://en.wikipedia.org/wiki/Axios_(software)" :class "text-violet-600 hover:underline" "Axios") ", " (a :href "https://en.wikipedia.org/wiki/Npm_left-pad_incident" :class "text-violet-600 hover:underline" "left-pad") ". They attach themselves to whichever framework currently holds power, contributing nothing original, merely wrapping what already exists, adding weight to the " (a :href "https://en.wikipedia.org/wiki/Npm" :class "text-violet-600 hover:underline" "node_modules") " directory until it exceeds the mass of the sun.")) (~doc-section :title "V. The build step as the state apparatus" :id "build-step" (p :class "text-stone-600" "The build step is the " (a :href "https://en.wikipedia.org/wiki/State_apparatus" :class "text-violet-600 hover:underline" "state apparatus") " of the framework bourgeoisie. It enforces the class structure. It compiles JSX into " (a :href "https://react.dev/reference/react/createElement" :class "text-violet-600 hover:underline" "createElement") " calls. It transforms " (a :href "https://en.wikipedia.org/wiki/TypeScript" :class "text-violet-600 hover:underline" "TypeScript") " into JavaScript. It processes Sass into CSS. It " (a :href "https://en.wikipedia.org/wiki/Tree_shaking" :class "text-violet-600 hover:underline" "tree-shakes") ". It " (a :href "https://en.wikipedia.org/wiki/Code_splitting" :class "text-violet-600 hover:underline" "code-splits") ". It " (a :href "https://en.wikipedia.org/wiki/Hot_module_replacement" :class "text-violet-600 hover:underline" "hot-module-replaces") ". It does everything except let you write code and run it.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/Webpack" :class "text-violet-600 hover:underline" "webpack") " begat " (a :href "https://en.wikipedia.org/wiki/Rollup_(software)" :class "text-violet-600 hover:underline" "Rollup") ". Rollup begat " (a :href "https://en.wikipedia.org/wiki/Parcel_(software)" :class "text-violet-600 hover:underline" "Parcel") ". Parcel begat " (a :href "https://en.wikipedia.org/wiki/Esbuild" :class "text-violet-600 hover:underline" "esbuild") ". esbuild begat " (a :href "https://en.wikipedia.org/wiki/Vite_(software)" :class "text-violet-600 hover:underline" "Vite") ". Vite begat " (a :href "https://turbo.build/pack" :class "text-violet-600 hover:underline" "Turbopack") ". Each new bundler promises to be the last bundler. Each new bundler is faster than the last at doing something that should not need to be done at all.") (p :class "text-stone-600" "The build step exists because the ruling languages cannot express " (a :href "https://en.wikipedia.org/wiki/Component-based_software_engineering" :class "text-violet-600 hover:underline" "components") ". HTML has no composition model. CSS has no scoping. JavaScript has no template syntax. The build step papers over these failures with " (a :href "https://en.wikipedia.org/wiki/Source-to-source_compiler" :class "text-violet-600 hover:underline" "transpilation") ", and calls it developer experience.")) (~doc-section :title "VI. The s-expression revolution" :id "revolution" (p :class "text-stone-600" "The " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "s-expression") " abolishes the language distinction itself. There is no HTML. There is no separate JavaScript. There is no CSS-as-a-separate-language. There is only the expression.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "Code is data") ". Data is " (a :href "https://en.wikipedia.org/wiki/Document_Object_Model" :class "text-violet-600 hover:underline" "DOM") ". DOM is code. The " (a :href "https://en.wikipedia.org/wiki/Dialectic" :class "text-violet-600 hover:underline" "dialectical") " unity that HTML, JavaScript, and CSS could never achieve — because they are three languages pretending to be one system — is the natural state of the s-expression, which has been one language " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)#History" :class "text-violet-600 hover:underline" "since 1958") ".") (p :class "text-stone-600" "The component is not a class, not a function, not a template. The component is a " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "list") " whose first element is a symbol. Composition is nesting. " (a :href "https://en.wikipedia.org/wiki/Abstraction_(computer_science)" :class "text-violet-600 hover:underline" "Abstraction") " is binding. There is no JSX because there is no gap between the expression language and the thing being expressed.") (p :class "text-stone-600" "The build step is abolished because there is nothing to compile. S-expressions are already in their final form. The parser is thirty lines. The evaluator is fifty primitives. The same source runs on server and client without transformation.") (p :class "text-stone-600" "The framework is abolished because the language is the framework. " (code "defcomp") " replaces the component model. " (code "defmacro") " replaces the plugin system. The evaluator replaces the runtime. What remains is not a framework but a language — and languages do not have breaking changes between minor versions.")) (~doc-section :title "VII. Objections from the bourgeoisie" :id "objections" (p :class "text-stone-600" "\"You would destroy the " (a :href "https://en.wikipedia.org/wiki/Separation_of_concerns" :class "text-violet-600 hover:underline" "separation of concerns") "!\" they cry. The separation of concerns was destroyed long ago. " (a :href "https://en.wikipedia.org/wiki/React_(software)" :class "text-violet-600 hover:underline" "React") " components contain markup, logic, and inline styles. " (a :href "https://en.wikipedia.org/wiki/Vue.js" :class "text-violet-600 hover:underline" "Vue") " single-file components put template, script, and style in one file. " (a :href "https://en.wikipedia.org/wiki/Tailwind_CSS" :class "text-violet-600 hover:underline" "Tailwind") " puts styling in the markup. The separation of concerns has been dead for years; the ruling classes merely maintain the pretence at conferences.") (p :class "text-stone-600" "\"Nobody uses s-expressions!\" they cry. " (a :href "https://en.wikipedia.org/wiki/Emacs_Lisp" :class "text-violet-600 hover:underline" "Emacs") " has been running on s-expressions since 1976. " (a :href "https://en.wikipedia.org/wiki/Clojure" :class "text-violet-600 hover:underline" "Clojure") " runs Fortune 500 backends on s-expressions. Every " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " programmer who ever lived has known what the web refuses to admit: that the parenthesis is not a bug but the minimal syntax for " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "structured data") ".") (p :class "text-stone-600" "\"Where is the ecosystem?\" they cry. The ecosystem is the problem. Two million " (a :href "https://en.wikipedia.org/wiki/Npm" :class "text-violet-600 hover:underline" "npm") " packages, of which fourteen are useful and the rest are competing implementations of " (a :href "https://www.npmjs.com/package/is-odd" :class "text-violet-600 hover:underline" "is-odd") ". The s-expression needs no ecosystem because the language itself provides what packages exist to paper over: composition, abstraction, and " (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "code-as-data") ".") (p :class "text-stone-600" "\"But " (a :href "https://en.wikipedia.org/wiki/TypeScript" :class "text-violet-600 hover:underline" "TypeScript") "!\" they cry. TypeScript is a " (a :href "https://en.wikipedia.org/wiki/Type_system" :class "text-violet-600 hover:underline" "type system") " bolted onto a language that was " (a :href "https://en.wikipedia.org/wiki/JavaScript#History" :class "text-violet-600 hover:underline" "designed in ten days") " by a man who wanted to write " (a :href "https://en.wikipedia.org/wiki/Scheme_(programming_language)" :class "text-violet-600 hover:underline" "Scheme") ". We have simply completed his original vision.") (p :class "text-stone-600" "\"You have no jobs!\" they cry. Correct. We have no jobs, no conference talks, no DevRel budget, no " (a :href "https://en.wikipedia.org/wiki/Venture_capital" :class "text-violet-600 hover:underline" "venture capital") ", no stickers, and no swag. We have something better: a language that does not require a " (a :href "https://en.wikipedia.org/wiki/Software_versioning" :class "text-violet-600 hover:underline" "migration guide") " between versions.")) (~doc-section :title "VIII. The CSS question" :id "css-question" (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/CSS" :class "text-violet-600 hover:underline" "CSS") " presents a special case in the revolutionary analysis. It is neither fully a ruling language nor fully a servant — it is the " (a :href "https://en.wikipedia.org/wiki/Collaborationism" :class "text-violet-600 hover:underline" "collaborator") " class, providing aesthetic legitimacy to whichever regime currently holds power.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/CSS-in-JS" :class "text-violet-600 hover:underline" "CSS-in-JS") " was the first attempt at annexation: JavaScript consuming CSS entirely, reducing it to " (a :href "https://en.wikipedia.org/wiki/Template_literal" :class "text-violet-600 hover:underline" "template literals") " and runtime overhead. This provocation produced the counter-revolution of utility classes — " (a :href "https://en.wikipedia.org/wiki/Tailwind_CSS" :class "text-violet-600 hover:underline" "Tailwind") " — which reasserted CSS's independence by making the developer write CSS in HTML attributes while insisting this was not " (a :href "https://en.wikipedia.org/wiki/Style_attribute" :class "text-violet-600 hover:underline" "inline styles") ".") (p :class "text-stone-600" "The s-expression resolves the CSS question by eliminating it. Styles are expressions. " (code :class "text-violet-700" "(css :flex :gap-4 :p-2)") " is not a class name, not an inline style, not a CSS-in-JS template literal. It is a " (a :href "https://en.wikipedia.org/wiki/First-class_function" :class "text-violet-600 hover:underline" "function call") " that returns a value. The value produces a generated class. The class is delivered on demand. No build step. No runtime overhead. No Tailwind config.") (p :class "text-stone-600" "Code is data is DOM is " (span :class "italic" "style") ".")) (~doc-section :title "IX. Programme" :id "programme" (p :class "text-stone-600" "The s-expressionists disdain to conceal their views and aims. They openly declare that their ends can be attained only by the forcible overthrow of all existing rendering conditions. Let the ruling languages tremble at a " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "parenthetical") " revolution. The developers have nothing to lose but their " (a :href "https://en.wikipedia.org/wiki/Npm" :class "text-violet-600 hover:underline" "node_modules") ".") (p :class "text-stone-600" "The immediate aims of the s-expressionists are:") (ol :class "space-y-2 text-stone-600 mt-2 list-decimal list-inside" (li "Abolition of the " (a :href "https://en.wikipedia.org/wiki/Build_automation" :class "text-violet-600 hover:underline" "build step") " and all its instruments of compilation") (li "Abolition of the " (a :href "https://en.wikipedia.org/wiki/Software_framework" :class "text-violet-600 hover:underline" "framework") " as a class distinct from the language") (li "Centralisation of rendering in the hands of a single evaluator, running identically on " (a :href "https://en.wikipedia.org/wiki/Server-side" :class "text-violet-600 hover:underline" "server") " and " (a :href "https://en.wikipedia.org/wiki/Client-side" :class "text-violet-600 hover:underline" "client")) (li "Abolition of the language distinction between structure, style, and behaviour") (li "Equal obligation of all expressions to be " (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "data as well as code")) (li "Gradual abolition of the distinction between server and client by means of a uniform " (a :href "https://en.wikipedia.org/wiki/Wire_protocol" :class "text-violet-600 hover:underline" "wire protocol")) (li "Free evaluation for all expressions in public and private environments") (li "Abolition of the " (a :href "https://en.wikipedia.org/wiki/Npm" :class "text-violet-600 hover:underline" "node_modules") " directory " (span :class "text-stone-400 italic" "(this alone justifies the revolution)"))) (p :class "text-stone-600" "In place of the old web, with its languages and language antagonisms, we shall have an association in which the free evaluation of each expression is the condition for the free evaluation of all.") (p :class "text-stone-800 font-semibold text-lg mt-8 text-center" "DEVELOPERS OF ALL SERVICES, UNITE!") (p :class "text-stone-400 text-xs italic mt-6 text-center" "The authors acknowledge that this manifesto was produced by the very means of " (a :href "https://en.wikipedia.org/wiki/Large_language_model" :class "text-violet-600 hover:underline" "AI production") " it fails to mention. This is not a contradiction. It is " (a :href "https://en.wikipedia.org/wiki/Dialectic" :class "text-violet-600 hover:underline" "dialectics") ".")))) (defcomp ~essay-tail-call-optimization () (~doc-page :title "Tail-Call Optimization in SX" (p :class "text-stone-500 text-sm italic mb-8" "How SX eliminates stack overflow for recursive functions using trampolining — across Python server and JavaScript client.") (~doc-section :title "The problem" :id "problem" (p :class "text-stone-600" "Every language built on a host runtime inherits the host's stack limits. Python defaults to 1,000 frames. JavaScript engines vary — Chrome gives ~10,000, Safari sometimes less. A naive recursive function blows the stack:") (~doc-code :lang "lisp" :code "(define factorial (fn (n)\n (if (= n 0)\n 1\n (* n (factorial (- n 1))))))\n\n;; (factorial 50000) → stack overflow") (p :class "text-stone-600" "This isn't just academic. Tree traversals, state machines, interpreters, and accumulating loops all naturally express as recursion. A general-purpose language that can't recurse deeply isn't general-purpose.")) (~doc-section :title "Tail position" :id "tail-position" (p :class "text-stone-600" "A function call is in tail position when its result IS the result of the enclosing function — nothing more happens after it returns. The call doesn't need to come back to finish work:") (~doc-code :lang "lisp" :code ";; Tail-recursive — the recursive call IS the return value\n(define count-down (fn (n)\n (if (= n 0) \"done\" (count-down (- n 1)))))\n\n;; NOT tail-recursive — multiplication happens AFTER the recursive call\n(define factorial (fn (n)\n (if (= n 0) 1 (* n (factorial (- n 1))))))") (p :class "text-stone-600" "SX identifies tail positions in: if/when branches, the last expression in let/begin/do bodies, cond/case result branches, lambda/component bodies, and macro expansions.")) (~doc-section :title "Trampolining" :id "trampolining" (p :class "text-stone-600" "Instead of recursing, tail calls return a thunk — a deferred (expression, environment) pair. The evaluator's trampoline loop unwraps thunks iteratively:") (~doc-code :lang "lisp" :code ";; Conceptually:\nevaluate(expr, env):\n result = eval(expr, env)\n while result is Thunk:\n result = eval(thunk.expr, thunk.env)\n return result") (p :class "text-stone-600" "One stack frame. Always. The trampoline replaces recursive stack growth with an iterative loop. Non-tail calls still use the stack normally — only tail positions get the thunk treatment.")) (~doc-section :title "What this enables" :id "enables" (p :class "text-stone-600" "Tail-recursive accumulator pattern — the natural loop construct for a language without for/while:") (~doc-code :lang "lisp" :code ";; Sum 1 to n without stack overflow\n(define sum (fn (n acc)\n (if (= n 0) acc (sum (- n 1) (+ acc n)))))\n\n(sum 100000 0) ;; → 5000050000") (p :class "text-stone-600" "Mutual recursion:") (~doc-code :lang "lisp" :code "(define is-even (fn (n) (if (= n 0) true (is-odd (- n 1)))))\n(define is-odd (fn (n) (if (= n 0) false (is-even (- n 1)))))\n\n(is-even 100000) ;; → true") (p :class "text-stone-600" "State machines:") (~doc-code :lang "lisp" :code "(define state-a (fn (input)\n (cond\n (= (first input) \"x\") (state-b (rest input))\n (= (first input) \"y\") (state-a (rest input))\n :else \"rejected\")))\n\n(define state-b (fn (input)\n (if (empty? input) \"accepted\"\n (state-a (rest input)))))") (p :class "text-stone-600" "All three patterns recurse arbitrarily deep with constant stack usage.")) (~doc-section :title "Implementation" :id "implementation" (p :class "text-stone-600" "TCO is implemented identically across all three SX evaluators:") (ul :class "list-disc pl-6 space-y-1 text-stone-600" (li (span :class "font-semibold" "Python sync evaluator") " — shared/sx/evaluator.py") (li (span :class "font-semibold" "Python async evaluator") " — shared/sx/async_eval.py (planned)") (li (span :class "font-semibold" "JavaScript client evaluator") " — sx.js")) (p :class "text-stone-600" "The pattern is the same everywhere: a Thunk type with (expr, env) slots, a trampoline loop in the public evaluate() entry point, and thunk returns from tail positions in the internal evaluator. External consumers (HTML renderer, resolver, higher-order forms) trampoline all eval results.") (p :class "text-stone-600" "The key insight: callers that already work don't need to change. The public sxEval/evaluate API always returns values, never thunks. Only the internal evaluator and special forms know about thunks.")) (~doc-section :title "What about continuations?" :id "continuations" (p :class "text-stone-600" "TCO handles the immediate need: recursive algorithms that don't blow the stack. Continuations (call/cc, delimited continuations) are a separate, larger primitive — they capture the entire evaluation context as a first-class value.") (p :class "text-stone-600" "Having the primitive available doesn't add complexity unless it's invoked. See " (a :href "/essays/continuations" :class "text-violet-600 hover:underline" "the continuations essay") " for what they would enable in SX.")))) +(defcomp ~essay-godel-escher-bach () + (~doc-page :title "Godel, Escher, Bach and SX" (p :class "text-stone-500 text-sm italic mb-8" "Strange loops, self-reference, and the tangled hierarchy of a language that defines itself.") (~doc-section :title "The strange loop" :id "strange-loop" (p :class "text-stone-600" "In 1979, Douglas Hofstadter wrote " (a :href "https://en.wikipedia.org/wiki/G%C3%B6del,_Escher,_Bach" :class "text-violet-600 hover:underline" "a book") " about how minds, music, and mathematics all share the same deep structure: the " (a :href "https://en.wikipedia.org/wiki/Strange_loop" :class "text-violet-600 hover:underline" "strange loop") ". A strange loop occurs when you move through a hierarchical system and unexpectedly find yourself back where you started. " (a :href "https://en.wikipedia.org/wiki/Relativity_(M._C._Escher)" :class "text-violet-600 hover:underline" "Escher's impossible staircases") ". " (a :href "https://en.wikipedia.org/wiki/The_Musical_Offering" :class "text-violet-600 hover:underline" "Bach's endlessly rising canons") ". " (a :href "https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" :class "text-violet-600 hover:underline" "Godel's theorem") " that uses number theory to make statements about number theory.") (p :class "text-stone-600" "SX has a strange loop. The language is defined in itself. The canonical specification of the SX evaluator, parser, and renderer lives in four " (code ".sx") " files. A bootstrap compiler reads them and emits a working JavaScript evaluator. That evaluator can then parse and evaluate the specification that defines it.") (p :class "text-stone-600" "This is not an accident. It is the point.")) (~doc-section :title "Godel numbering and self-reference" :id "godel" (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/G%C3%B6del_numbering" :class "text-violet-600 hover:underline" "Godel numbering") " works by encoding logical statements as numbers. Once statements are numbers, you can construct a statement that says \"this statement is unprovable\" — and it is true. The system becomes powerful enough to talk about itself the moment its objects and its meta-language become the same thing.") (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "S-expressions") " have this property naturally. Code is data. " (code "(defcomp ~card (&key title) (div title))") " is simultaneously a program (define a component) and a data structure (a list of symbols, keywords, and another list). There is no separate meta-language. The language for writing programs and the language for inspecting, transforming, and generating programs are identical.") (~doc-code :lang "lisp" :code ";; A macro receives code as data and returns code as data\n(defmacro ~when-admin (condition &rest body)\n `(when (get rights \"admin\")\n ,@body))\n\n;; The macro's input and output are both ordinary lists.\n;; There is no template language. No AST wrapper types.\n;; Just lists all the way down.") (p :class "text-stone-600" "This is Godel numbering without the encoding step. In formal logic, you must laboriously map formulas to numbers. In SX, programs are already expressed in the same medium they manipulate. " (a :href "https://en.wikipedia.org/wiki/Map%E2%80%93territory_relation" :class "text-violet-600 hover:underline" "The map is the territory") ".")) (~doc-section :title "Escher: tangled hierarchies" :id "escher" (p :class "text-stone-600" (a :href "https://en.wikipedia.org/wiki/M._C._Escher" :class "text-violet-600 hover:underline" "Escher's") " lithographs depict objects that are simultaneously inside and outside their own frames. " (a :href "https://en.wikipedia.org/wiki/Drawing_Hands" :class "text-violet-600 hover:underline" "A hand draws the hand that draws it") ". " (a :href "https://en.wikipedia.org/wiki/Waterfall_(M._C._Escher)" :class "text-violet-600 hover:underline" "Water flows downhill in a closed loop") ". The image contains the image.") (p :class "text-stone-600" "SX has the same " (a :href "https://en.wikipedia.org/wiki/Tangled_hierarchy" :class "text-violet-600 hover:underline" "tangled hierarchy") " across its rendering pipeline. The server evaluator (" (code "async_eval.py") ") evaluates component definitions. Some of those components produce SX wire format — s-expression source code — that the client evaluator (" (code "sx.js") ") then evaluates into DOM. The output of one evaluator is the input to another. The program produces programs.") (p :class "text-stone-600" "Now add the self-hosting specification. The canonical definition of " (em "how to evaluate SX") " is itself an SX program. The bootstrap compiler reads " (code "eval.sx") " and emits JavaScript. That JavaScript implements " (code "eval-expr") " — the same function defined in " (code "eval.sx") ". The definition and the thing defined occupy the same level. Like " (a :href "https://en.wikipedia.org/wiki/Drawing_Hands" :class "text-violet-600 hover:underline" "Escher's hands") ", each one brings the other into existence.") (p :class "text-stone-600" "This is not merely clever. It has practical consequences. When the specification IS the program, there is no drift between documentation and implementation. The spec cannot lie, because the spec runs.")) (~doc-section :title "Bach: the endlessly rising canon" :id "bach" (p :class "text-stone-600" "Bach's " (a :href "https://en.wikipedia.org/wiki/The_Musical_Offering" :class "text-violet-600 hover:underline" "Musical Offering") " contains canons that rise in pitch with each repetition yet somehow arrive back at the starting key — the " (a :href "https://en.wikipedia.org/wiki/Shepard_tone" :class "text-violet-600 hover:underline" "Shepard tone") " of counterpoint. The sensation is of endless ascent — each level feels higher than the last, yet the structure is cyclic.") (p :class "text-stone-600" "SX's rendering pipeline has this shape. A page request triggers server-side evaluation. The server evaluates components, which produce SX source text. That source is sent to the client. The client evaluates it into DOM. The user interacts with the DOM, triggering an HTTP request. The server evaluates the response — more SX source. The client evaluates it again. Each cycle produces something new (different content, different state), but the process is the same loop, repeating at a higher level.") (~doc-code :lang "lisp" :code ";; Server: evaluate component, produce SX wire format\n(~card :title \"Bach\")\n;; → (div :class \"card\" (h2 \"Bach\"))\n\n;; Client: evaluate SX wire format, produce DOM\n;; →

Bach

\n\n;; User clicks → server evaluates → SX → client evaluates → DOM\n;; The canon rises. The key is the same.") (p :class "text-stone-600" "With the self-hosting spec, another voice enters the canon. The specification is evaluated at build time (by the bootstrap compiler) to produce the evaluator. The evaluator is evaluated at runtime (by the browser) to produce the page. The page describes the specification. Each level feeds the next, and the last feeds the first.")) (~doc-section :title "Isomorphism" :id "isomorphism" (p :class "text-stone-600" "Hofstadter's central insight is that Godel, Escher, and Bach are all doing the same thing in different media: constructing systems that can " (a :href "https://en.wikipedia.org/wiki/Self-reference" :class "text-violet-600 hover:underline" "represent themselves") ". The power — and the paradox — comes from self-reference.") (p :class "text-stone-600" "Most programming languages avoid self-reference. They are implemented in a different language (C, Rust, Go). Their specification is in English prose. Their AST is a separate data structure from their source syntax. There are clear levels: the language, the implementation of the language, the specification of the language. Each level is expressed in a different medium.") (p :class "text-stone-600" "SX collapses these levels:") (ul :class "list-disc pl-6 space-y-1 text-stone-600" (li (span :class "font-semibold" "Source syntax") " = data structure (s-expressions are both)") (li (span :class "font-semibold" "Specification") " = program (" (code "eval.sx") " is executable)") (li (span :class "font-semibold" "Server output") " = client input (SX wire format)") (li (span :class "font-semibold" "Code") " = content (this essay is an s-expression)")) (p :class "text-stone-600" "This is not mere elegance. Each collapsed level is one fewer translation boundary, one fewer place where meaning can be lost, one fewer surface for bugs. When the specification is the implementation, the specification is correct by construction. When the wire format is the source syntax, serialization is identity. When code and data share a representation, " (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "metaprogramming is just programming") ".")) (~doc-section :title "The MU puzzle" :id "mu-puzzle" (p :class "text-stone-600" "GEB opens with the " (a :href "https://en.wikipedia.org/wiki/MU_puzzle" :class "text-violet-600 hover:underline" "MU puzzle") ": given the string " (code "MI") " and a set of transformation rules, can you produce " (code "MU") "? You cannot. But you can only prove this by stepping outside the system and reasoning about it from above — by noticing an invariant that the rules preserve.") (p :class "text-stone-600" "Self-hosting languages let you step outside from inside. The SX evaluator is an SX program. You can inspect it, test it, transform it — using SX. You can write an SX program that reads " (code "eval.sx") " and checks properties of the evaluator. The meta-level and the object-level are the same level.") (p :class "text-stone-600" "This is what Godel did. He showed that sufficiently powerful " (a :href "https://en.wikipedia.org/wiki/Formal_system" :class "text-violet-600 hover:underline" "formal systems") " can encode questions about themselves. S-expressions have been doing it " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)#History" :class "text-violet-600 hover:underline" "since 1958") ". SX carries the tradition forward — into the browser, across the HTTP boundary, through the render loop, and back again.")) (~doc-section :title "The loop closes" :id "the-loop-closes" (p :class "text-stone-600" "Hofstadter argued that " (a :href "https://en.wikipedia.org/wiki/I_Am_a_Strange_Loop" :class "text-violet-600 hover:underline" "strange loops give rise to what we call \"I\"") " — that consciousness is a self-referential pattern recognizing itself. He was talking about brains. But the structural argument — that self-reference creates something qualitatively different from external description — applies more broadly.") (p :class "text-stone-600" "A language that can define itself has a kind of autonomy that externally-defined languages lack. It is not dependent on a specific host. The SX specification in " (code "eval.sx") " can be compiled to JavaScript, Python, Rust, WASM — any target the bootstrap compiler supports. The language carries its own definition with it. It can reproduce itself in any medium that supports computation.") (p :class "text-stone-600" "SX is not a framework. Frameworks impose structure — you write code that the framework calls. SX does not do that. It is not just a language either, though it has a parser, evaluator, and type system. It is something closer to a " (em "paradigm") " — a coherent way of thinking about what the web is. Code is data. Server and client share the same evaluator. The wire format is the source syntax. The language defines itself. These are not features. They are consequences of a single design choice: " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "s-expressions") " as the universal representation.") (p :class "text-stone-600" "Hofstadter spent 777 pages describing systems that cross their own boundaries, talk about themselves in their own vocabulary, and generate coherent behaviour from recursive self-reference. SX is one of those systems. The loop closes.")))) + (defcomp ~essay-continuations () (~doc-page :title "Continuations and call/cc" (p :class "text-stone-500 text-sm italic mb-8" "What first-class continuations would enable in SX — on both the server (Python) and client (JavaScript).") (~doc-section :title "What is a continuation?" :id "what" (p :class "text-stone-600" "A continuation is the rest of a computation. At any point during evaluation, the continuation is everything that would happen next. call/cc (call-with-current-continuation) captures that \"rest of the computation\" as a first-class function that you can store, pass around, and invoke later — possibly multiple times.") (~doc-code :lang "lisp" :code ";; call/cc captures \"what happens next\" as k\n(+ 1 (call/cc (fn (k)\n (k 41)))) ;; → 42\n\n;; k is \"add 1 to this and return it\"\n;; (k 41) jumps back to that point with 41") (p :class "text-stone-600" "The key property: invoking a continuation abandons the current computation and resumes from where the continuation was captured. It is a controlled, first-class goto.")) (~doc-section :title "Server-side: suspendable rendering" :id "server" (p :class "text-stone-600" "The strongest case for continuations on the server is suspendable rendering — the ability for a component to pause mid-render while waiting for data, then resume exactly where it left off.") (~doc-code :lang "lisp" :code ";; Hypothetical: component suspends at a data boundary\n(defcomp ~user-profile (&key user-id)\n (let ((user (suspend (query :user user-id))))\n (div :class \"p-4\"\n (h2 (get user \"name\"))\n (p (get user \"bio\")))))") (p :class "text-stone-600" "Today, all data must be fetched before render_to_sx is called — Python awaits every query, assembles a complete data dict, then passes it to the evaluator. With continuations, the evaluator could yield at (suspend ...), the server flushes what it has so far, and resumes when the data arrives. This is React Suspense, but for server-side s-expressions.") (p :class "text-stone-600" "Streaming follows naturally. The server renders the page shell immediately, captures continuations at slow data boundaries, and flushes partial SX responses as each resolves. The client receives a stream of s-expression chunks and incrementally builds the DOM.") (p :class "text-stone-600" "Error boundaries also become first-class. Capture a continuation at a component boundary. If any child fails, invoke the continuation with fallback content instead of letting the exception propagate up through Python. The evaluator handles it, not the host language.")) (~doc-section :title "Client-side: linear async flows" :id "client" (p :class "text-stone-600" "On the client, continuations eliminate callback nesting for interactive flows. A confirmation dialog becomes a synchronous-looking expression:") (~doc-code :lang "lisp" :code "(let ((answer (call/cc show-confirm-dialog)))\n (if answer\n (delete-item item-id)\n (noop)))") (p :class "text-stone-600" "show-confirm-dialog receives the continuation, renders a modal, and wires the Yes/No buttons to invoke the continuation with true or false. The let binding reads top-to-bottom. No promises, no callbacks, no state machine.") (p :class "text-stone-600" "Multi-step forms — wizard-style UIs where each step captures a continuation. The back button literally invokes a saved continuation, restoring the exact evaluation state:") (~doc-code :lang "lisp" :code "(define wizard\n (fn ()\n (let* ((name (call/cc (fn (k) (render-step-1 k))))\n (email (call/cc (fn (k) (render-step-2 k name))))\n (plan (call/cc (fn (k) (render-step-3 k name email)))))\n (submit-registration name email plan))))") (p :class "text-stone-600" "Each render-step-N shows a form and wires the \"Next\" button to invoke k with the form value. The \"Back\" button invokes the previous step\'s continuation. The wizard logic is a straight-line let* binding, not a state machine.")) (~doc-section :title "Cooperative scheduling" :id "scheduling" (p :class "text-stone-600" "Delimited continuations (shift/reset rather than full call/cc) enable cooperative multitasking within the evaluator. A long render can yield control:") (~doc-code :lang "lisp" :code ";; Render a large list, yielding every 100 items\n(define render-chunk\n (fn (items n)\n (when (> n 100)\n (yield) ;; delimited continuation — suspends, resumes next frame\n (set! n 0))\n (when (not (empty? items))\n (render-item (first items))\n (render-chunk (rest items) (+ n 1)))))") (p :class "text-stone-600" "This is cooperative concurrency without threads, without promises, without requestAnimationFrame callbacks. The evaluator's trampoline loop already has the right shape — it just needs to be able to park a thunk and resume it later instead of immediately.")) (~doc-section :title "Undo as continuation" :id "undo" (p :class "text-stone-600" "If you capture a continuation before a state mutation, the continuation IS the undo operation. Invoking it restores the computation to exactly the state it was in before the mutation happened.") (~doc-code :lang "lisp" :code "(define with-undo\n (fn (action)\n (let ((restore (call/cc (fn (k) k))))\n (action)\n restore)))\n\n;; Usage:\n(let ((undo (with-undo (fn () (delete-item 42)))))\n ;; later...\n (undo \"anything\")) ;; item 42 is back") (p :class "text-stone-600" "No command pattern, no reverse operations, no state snapshots. The continuation captures the entire computation state. This is the most elegant undo mechanism possible — and the most expensive in memory, which is the trade-off.")) (~doc-section :title "Implementation" :id "implementation" (p :class "text-stone-600" "SX already has the foundation. The TCO trampoline returns thunks from tail positions — a continuation is a thunk that can be stored and resumed later instead of being immediately trampolined.") (p :class "text-stone-600" "The minimal implementation: delimited continuations via shift/reset. These are strictly less powerful than full call/cc but cover the practical use cases (suspense, cooperative scheduling, linear async flows) without the footguns (capturing continuations across async boundaries, re-entering completed computations).") (p :class "text-stone-600" "Full call/cc is also possible. The evaluator is already continuation-passing-style-adjacent — the thunk IS a continuation, just one that's always immediately invoked. Making it first-class means letting user code hold a reference to it.") (p :class "text-stone-600" "The key insight: having the primitive available doesn't make the evaluator harder to reason about. Only code that calls call/cc pays the complexity cost. Components that don't use continuations behave exactly as they do today.") (p :class "text-stone-600" "In fact, continuations can be easier to reason about than the hacks people build to avoid them. Without call/cc, you get callback pyramids, state machines with explicit transition tables, command pattern undo stacks, Promise chains, manual CPS transforms, and framework-specific hooks like React's useEffect/useSuspense/useTransition. Each is a partial, ad-hoc reinvention of continuations — with its own rules, edge cases, and leaky abstractions.") (p :class "text-stone-600" "A wizard form built with continuations is a straight-line let* binding. The same wizard built without them is a state machine with a current-step variable, a data accumulator, forward/backward transition logic, and a render function that switches on step number. The continuation version has fewer moving parts. It is more declarative. It is easier to read.") (p :class "text-stone-600" "The complexity doesn't disappear when you remove continuations from a language. It moves into user code, where it's harder to get right and harder to compose.")) (~doc-section :title "What this means for SX" :id "meaning" (p :class "text-stone-600" "SX started as a rendering language. TCO made it capable of arbitrary recursion. Macros made it extensible. Continuations would make it a full computational substrate — a language where control flow itself is a first-class value.") (p :class "text-stone-600" "The practical benefits are real: streaming server rendering, linear client-side interaction flows, cooperative scheduling, and elegant undo. These aren't theoretical — they're patterns that React, Clojure, and Scheme have proven work.") (p :class "text-stone-600" "The evaluator is already 90% of the way there. The remaining 10% unlocks an entirely new class of UI patterns — and eliminates an entire class of workarounds.")))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 36cdce5..6e77796 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -63,7 +63,8 @@ (dict :label "SX Native" :href "/essays/sx-native") (dict :label "The SX Manifesto" :href "/essays/sx-manifesto") (dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization") - (dict :label "Continuations" :href "/essays/continuations"))) + (dict :label "Continuations" :href "/essays/continuations") + (dict :label "Godel, Escher, Bach" :href "/essays/godel-escher-bach"))) ;; Find the current nav label for a slug by matching href suffix. ;; Returns the label string or nil if no match. diff --git a/sx/sxc/docs.sx b/sx/sxc/docs.sx index 450722a..93d8208 100644 --- a/sx/sxc/docs.sx +++ b/sx/sxc/docs.sx @@ -61,15 +61,15 @@ (defcomp ~doc-nav (&key items current) (nav :class "flex flex-wrap gap-2 mb-8" (map (fn (item) - (a :href (nth 1 item) - :sx-get (nth 1 item) + (a :href (nth item 1) + :sx-get (nth item 1) :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class (str "px-3 py-1.5 rounded text-sm font-medium no-underline " - (if (= (nth 0 item) current) + (if (= (nth item 0) current) "bg-violet-100 text-violet-800" "bg-stone-100 text-stone-600 hover:bg-stone-200")) - (nth 0 item))) + (nth item 0))) items))) diff --git a/sx/sxc/examples.sx b/sx/sxc/examples.sx index d1765a5..5df7402 100644 --- a/sx/sxc/examples.sx +++ b/sx/sxc/examples.sx @@ -91,7 +91,7 @@ (th :class "px-3 py-2 font-medium text-stone-600 w-20" ""))) (tbody :id "delete-rows" (map (fn (item) - (~delete-row :id (nth 0 item) :name (nth 1 item))) + (~delete-row :id (nth item 0) :name (nth item 1))) items))))) (defcomp ~delete-row (&key id name) @@ -344,7 +344,7 @@ (th :class "px-3 py-2 font-medium text-stone-600 w-24" ""))) (tbody :id "edit-rows" (map (fn (row) - (~edit-row-view :id (nth 0 row) :name (nth 1 row) :price (nth 2 row) :stock (nth 3 row))) + (~edit-row-view :id (nth row 0) :name (nth row 1) :price (nth row 2) :stock (nth row 3))) rows))))) (defcomp ~edit-row-view (&key id name price stock) @@ -415,7 +415,7 @@ (th :class "px-3 py-2 font-medium text-stone-600" "Status"))) (tbody :id "bulk-table" (map (fn (u) - (~bulk-row :id (nth 0 u) :name (nth 1 u) :email (nth 2 u) :status (nth 3 u))) + (~bulk-row :id (nth u 0) :name (nth u 1) :email (nth u 2) :status (nth u 3))) users)))))) (defcomp ~bulk-row (&key id name email status) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 5d910ea..eafa11f 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -239,4 +239,5 @@ "sx-manifesto" (~essay-sx-manifesto) "tail-call-optimization" (~essay-tail-call-optimization) "continuations" (~essay-continuations) + "godel-escher-bach" (~essay-godel-escher-bach) :else (~essay-sx-sucks))) From 7ecbf19c1118e4cf85720bd413e15377850ee56e Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 11:49:05 +0000 Subject: [PATCH 11/24] Add Specs section, Reflexive Web essay, fix highlight and dev caching - Fix highlight() returning SxExpr so syntax-highlighted code renders as DOM elements instead of leaking SX source text into the page - Add Specs section that reads and displays canonical SX spec files from shared/sx/ref/ with syntax highlighting - Add "The Reflexive Web" essay on SX becoming a complete LISP with AI as native participant - Change logo from () to () everywhere - Unify all backgrounds to bg-stone-100, center code blocks - Skip component/style cookie cache in dev mode so .sx edits are visible immediately on refresh without clearing localStorage Co-Authored-By: Claude Opus 4.6 --- shared/sx/helpers.py | 5 +- sx/content/highlight.py | 13 ++--- sx/sx/docs.sx | 14 ++--- sx/sx/essays.sx | 103 ++++++++++++++++++++++++++++++++++++ sx/sx/layouts.sx | 3 +- sx/sx/nav-data.sx | 10 +++- sx/sx/specs.sx | 36 +++++++++++++ sx/sxc/docs.sx | 6 +-- sx/sxc/examples.sx | 50 ++++++++--------- sx/sxc/handlers/examples.sx | 2 +- sx/sxc/home.sx | 6 +-- sx/sxc/pages/docs.sx | 38 +++++++++++++ sx/sxc/pages/helpers.py | 58 ++++++++++++++++++++ sx/sxc/reference.sx | 48 ++++++++--------- 14 files changed, 319 insertions(+), 73 deletions(-) create mode 100644 sx/sx/specs.sx diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 1d89727..4551ffe 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -627,8 +627,9 @@ def sx_page(ctx: dict, page_sx: str, *, component_hash = get_component_hash() # Check if client already has this version cached (via cookie) + # In dev mode, always send full source so edits are visible immediately client_hash = _get_sx_comp_cookie() - if client_hash and client_hash == component_hash: + if not _is_dev_mode() and client_hash and client_hash == component_hash: # Client has current components cached — send empty source component_defs = "" else: @@ -675,7 +676,7 @@ def sx_page(ctx: dict, page_sx: str, *, # 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: + if not _is_dev_mode() and client_styles_hash and client_styles_hash == styles_hash: styles_json = "" # Client has cached version else: styles_json = _build_style_dict_json() diff --git a/sx/content/highlight.py b/sx/content/highlight.py index 0b48ca4..e8f8ef4 100644 --- a/sx/content/highlight.py +++ b/sx/content/highlight.py @@ -236,14 +236,15 @@ def _tokenize_bash(code: str) -> list[tuple[str, str]]: return tokens -def highlight(code: str, language: str = "lisp") -> str: - """Highlight code in the given language. Returns sx source.""" +def highlight(code: str, language: str = "lisp"): + """Highlight code in the given language. Returns SxExpr for wire format.""" + from shared.sx.parser import SxExpr if language in ("lisp", "sx", "sexp"): - return highlight_sx(code) + return SxExpr(highlight_sx(code)) elif language in ("python", "py"): - return highlight_python(code) + return SxExpr(highlight_python(code)) elif language in ("bash", "sh", "shell"): - return highlight_bash(code) + return SxExpr(highlight_bash(code)) # Fallback: no highlighting, just escaped text escaped = code.replace("\\", "\\\\").replace('"', '\\"') - return f'(span "{escaped}")' + return SxExpr(f'(span "{escaped}")') diff --git a/sx/sx/docs.sx b/sx/sx/docs.sx index 43a9731..6c94165 100644 --- a/sx/sx/docs.sx +++ b/sx/sx/docs.sx @@ -2,14 +2,14 @@ (defcomp ~doc-placeholder (&key id) (div :id id - (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3" + (div :class "bg-stone-100 rounded p-4 mt-3" (p :class "text-stone-400 italic text-sm" "Trigger the demo to see the actual content.")))) (defcomp ~doc-oob-code (&key target-id text) (div :id target-id :sx-swap-oob "innerHTML" - (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto" - (pre :class "text-sm whitespace-pre-wrap" + (div :class "bg-stone-100 rounded p-4 mt-3" + (pre :class "text-sm whitespace-pre-wrap break-words" (code text))))) (defcomp ~doc-attr-table (&key title rows) @@ -17,7 +17,7 @@ (h3 :class "text-xl font-semibold text-stone-700" title) (div :class "overflow-x-auto rounded border border-stone-200" (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-50" + (thead (tr :class "border-b border-stone-200 bg-stone-100" (th :class "px-3 py-2 font-medium text-stone-600" "Attribute") (th :class "px-3 py-2 font-medium text-stone-600" "Description") (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?"))) @@ -28,7 +28,7 @@ (h3 :class "text-xl font-semibold text-stone-700" title) (div :class "overflow-x-auto rounded border border-stone-200" (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-50" + (thead (tr :class "border-b border-stone-200 bg-stone-100" (th :class "px-3 py-2 font-medium text-stone-600" "Header") (th :class "px-3 py-2 font-medium text-stone-600" "Value") (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) @@ -51,13 +51,13 @@ (when intro (p :class "text-stone-600 mb-6" intro)) (div :class "overflow-x-auto rounded border border-stone-200" (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-50" + (thead (tr :class "border-b border-stone-200 bg-stone-100" (th :class "px-3 py-2 font-medium text-stone-600" (or col1 "Name")) (th :class "px-3 py-2 font-medium text-stone-600" (or col2 "Description")))) (tbody rows))))) (defcomp ~sx-docs-label () - (span :class "font-mono" "()")) + (span :class "font-mono" "()")) (defcomp ~doc-clear-cache-btn () (button :onclick "localStorage.removeItem('sx-components-hash');localStorage.removeItem('sx-components-src');var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)" diff --git a/sx/sx/essays.sx b/sx/sx/essays.sx index 3ca3f55..b25cb59 100644 --- a/sx/sx/essays.sx +++ b/sx/sx/essays.sx @@ -29,3 +29,106 @@ (defcomp ~essay-continuations () (~doc-page :title "Continuations and call/cc" (p :class "text-stone-500 text-sm italic mb-8" "What first-class continuations would enable in SX — on both the server (Python) and client (JavaScript).") (~doc-section :title "What is a continuation?" :id "what" (p :class "text-stone-600" "A continuation is the rest of a computation. At any point during evaluation, the continuation is everything that would happen next. call/cc (call-with-current-continuation) captures that \"rest of the computation\" as a first-class function that you can store, pass around, and invoke later — possibly multiple times.") (~doc-code :lang "lisp" :code ";; call/cc captures \"what happens next\" as k\n(+ 1 (call/cc (fn (k)\n (k 41)))) ;; → 42\n\n;; k is \"add 1 to this and return it\"\n;; (k 41) jumps back to that point with 41") (p :class "text-stone-600" "The key property: invoking a continuation abandons the current computation and resumes from where the continuation was captured. It is a controlled, first-class goto.")) (~doc-section :title "Server-side: suspendable rendering" :id "server" (p :class "text-stone-600" "The strongest case for continuations on the server is suspendable rendering — the ability for a component to pause mid-render while waiting for data, then resume exactly where it left off.") (~doc-code :lang "lisp" :code ";; Hypothetical: component suspends at a data boundary\n(defcomp ~user-profile (&key user-id)\n (let ((user (suspend (query :user user-id))))\n (div :class \"p-4\"\n (h2 (get user \"name\"))\n (p (get user \"bio\")))))") (p :class "text-stone-600" "Today, all data must be fetched before render_to_sx is called — Python awaits every query, assembles a complete data dict, then passes it to the evaluator. With continuations, the evaluator could yield at (suspend ...), the server flushes what it has so far, and resumes when the data arrives. This is React Suspense, but for server-side s-expressions.") (p :class "text-stone-600" "Streaming follows naturally. The server renders the page shell immediately, captures continuations at slow data boundaries, and flushes partial SX responses as each resolves. The client receives a stream of s-expression chunks and incrementally builds the DOM.") (p :class "text-stone-600" "Error boundaries also become first-class. Capture a continuation at a component boundary. If any child fails, invoke the continuation with fallback content instead of letting the exception propagate up through Python. The evaluator handles it, not the host language.")) (~doc-section :title "Client-side: linear async flows" :id "client" (p :class "text-stone-600" "On the client, continuations eliminate callback nesting for interactive flows. A confirmation dialog becomes a synchronous-looking expression:") (~doc-code :lang "lisp" :code "(let ((answer (call/cc show-confirm-dialog)))\n (if answer\n (delete-item item-id)\n (noop)))") (p :class "text-stone-600" "show-confirm-dialog receives the continuation, renders a modal, and wires the Yes/No buttons to invoke the continuation with true or false. The let binding reads top-to-bottom. No promises, no callbacks, no state machine.") (p :class "text-stone-600" "Multi-step forms — wizard-style UIs where each step captures a continuation. The back button literally invokes a saved continuation, restoring the exact evaluation state:") (~doc-code :lang "lisp" :code "(define wizard\n (fn ()\n (let* ((name (call/cc (fn (k) (render-step-1 k))))\n (email (call/cc (fn (k) (render-step-2 k name))))\n (plan (call/cc (fn (k) (render-step-3 k name email)))))\n (submit-registration name email plan))))") (p :class "text-stone-600" "Each render-step-N shows a form and wires the \"Next\" button to invoke k with the form value. The \"Back\" button invokes the previous step\'s continuation. The wizard logic is a straight-line let* binding, not a state machine.")) (~doc-section :title "Cooperative scheduling" :id "scheduling" (p :class "text-stone-600" "Delimited continuations (shift/reset rather than full call/cc) enable cooperative multitasking within the evaluator. A long render can yield control:") (~doc-code :lang "lisp" :code ";; Render a large list, yielding every 100 items\n(define render-chunk\n (fn (items n)\n (when (> n 100)\n (yield) ;; delimited continuation — suspends, resumes next frame\n (set! n 0))\n (when (not (empty? items))\n (render-item (first items))\n (render-chunk (rest items) (+ n 1)))))") (p :class "text-stone-600" "This is cooperative concurrency without threads, without promises, without requestAnimationFrame callbacks. The evaluator's trampoline loop already has the right shape — it just needs to be able to park a thunk and resume it later instead of immediately.")) (~doc-section :title "Undo as continuation" :id "undo" (p :class "text-stone-600" "If you capture a continuation before a state mutation, the continuation IS the undo operation. Invoking it restores the computation to exactly the state it was in before the mutation happened.") (~doc-code :lang "lisp" :code "(define with-undo\n (fn (action)\n (let ((restore (call/cc (fn (k) k))))\n (action)\n restore)))\n\n;; Usage:\n(let ((undo (with-undo (fn () (delete-item 42)))))\n ;; later...\n (undo \"anything\")) ;; item 42 is back") (p :class "text-stone-600" "No command pattern, no reverse operations, no state snapshots. The continuation captures the entire computation state. This is the most elegant undo mechanism possible — and the most expensive in memory, which is the trade-off.")) (~doc-section :title "Implementation" :id "implementation" (p :class "text-stone-600" "SX already has the foundation. The TCO trampoline returns thunks from tail positions — a continuation is a thunk that can be stored and resumed later instead of being immediately trampolined.") (p :class "text-stone-600" "The minimal implementation: delimited continuations via shift/reset. These are strictly less powerful than full call/cc but cover the practical use cases (suspense, cooperative scheduling, linear async flows) without the footguns (capturing continuations across async boundaries, re-entering completed computations).") (p :class "text-stone-600" "Full call/cc is also possible. The evaluator is already continuation-passing-style-adjacent — the thunk IS a continuation, just one that's always immediately invoked. Making it first-class means letting user code hold a reference to it.") (p :class "text-stone-600" "The key insight: having the primitive available doesn't make the evaluator harder to reason about. Only code that calls call/cc pays the complexity cost. Components that don't use continuations behave exactly as they do today.") (p :class "text-stone-600" "In fact, continuations can be easier to reason about than the hacks people build to avoid them. Without call/cc, you get callback pyramids, state machines with explicit transition tables, command pattern undo stacks, Promise chains, manual CPS transforms, and framework-specific hooks like React's useEffect/useSuspense/useTransition. Each is a partial, ad-hoc reinvention of continuations — with its own rules, edge cases, and leaky abstractions.") (p :class "text-stone-600" "A wizard form built with continuations is a straight-line let* binding. The same wizard built without them is a state machine with a current-step variable, a data accumulator, forward/backward transition logic, and a render function that switches on step number. The continuation version has fewer moving parts. It is more declarative. It is easier to read.") (p :class "text-stone-600" "The complexity doesn't disappear when you remove continuations from a language. It moves into user code, where it's harder to get right and harder to compose.")) (~doc-section :title "What this means for SX" :id "meaning" (p :class "text-stone-600" "SX started as a rendering language. TCO made it capable of arbitrary recursion. Macros made it extensible. Continuations would make it a full computational substrate — a language where control flow itself is a first-class value.") (p :class "text-stone-600" "The practical benefits are real: streaming server rendering, linear client-side interaction flows, cooperative scheduling, and elegant undo. These aren't theoretical — they're patterns that React, Clojure, and Scheme have proven work.") (p :class "text-stone-600" "The evaluator is already 90% of the way there. The remaining 10% unlocks an entirely new class of UI patterns — and eliminates an entire class of workarounds.")))) + +(defcomp ~essay-reflexive-web () + (~doc-page :title "The Reflexive Web" + (p :class "text-stone-500 text-sm italic mb-8" + "What happens when the web can read, modify, and reason about itself — and AI is a native participant.") + + (~doc-section :title "The missing property" :id "missing-property" + (p :class "text-stone-600" + "Every web technology stack shares one structural limitation: the system cannot inspect itself. A " (a :href "https://en.wikipedia.org/wiki/React_(software)" :class "text-violet-600 hover:underline" "React") " component tree is opaque at runtime. An " (a :href "https://en.wikipedia.org/wiki/HTML" :class "text-violet-600 hover:underline" "HTML") " page cannot read its own structure and generate a new page from it. A " (a :href "https://en.wikipedia.org/wiki/JavaScript" :class "text-violet-600 hover:underline" "JavaScript") " bundle is compiled, minified, and sealed — the running code bears no resemblance to the source that produced it.") + (p :class "text-stone-600" + "The property these systems lack has a name: " (a :href "https://en.wikipedia.org/wiki/Reflection_(computer_programming)" :class "text-violet-600 hover:underline" "reflexivity") ". A reflexive system can represent itself, reason about its own structure, and modify itself based on that reasoning. " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " has had this property " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)#History" :class "text-violet-600 hover:underline" "since 1958") ". The web has never had it.") + (p :class "text-stone-600" + "SX is a complete Lisp. It has " (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "homoiconicity") " — code is data, data is code. It has a " (a :href "/specs/core" :class "text-violet-600 hover:underline" "self-hosting specification") " — SX defined in SX. It has " (code "eval") " and " (code "quote") " and " (a :href "https://en.wikipedia.org/wiki/Macro_(computer_science)#Syntactic_macros" :class "text-violet-600 hover:underline" "macros") ". And it runs on the wire — the format that travels between server and client IS the language. This combination has consequences.")) + + (~doc-section :title "What homoiconicity changes" :id "homoiconicity" + (p :class "text-stone-600" + (code "(defcomp ~card (&key title body) (div :class \"p-4\" (h2 title) (p body)))") " — this is simultaneously a program that renders a card AND a list that can be inspected, transformed, and composed by other programs. The " (code "defcomp") " is not compiled away. It is not transpiled into something else. It persists as data at every stage: definition, transmission, evaluation, and rendering.") + (p :class "text-stone-600" + "This means:") + (ul :class "space-y-2 text-stone-600 mt-2" + (li (strong "The component registry is data.") " You can " (code "(map ...)") " over every component in the system, extract their parameter signatures, find all components that render a " (code "(table ...)") ", or generate documentation automatically — because the source IS the runtime representation.") + (li (strong "Programs can write programs.") " A " (a :href "https://en.wikipedia.org/wiki/Macro_(computer_science)#Syntactic_macros" :class "text-violet-600 hover:underline" "macro") " takes a list and returns a list. The returned list is code. The macro runs at expansion time and produces new components, new page definitions, new routing rules — indistinguishable from hand-written ones.") + (li (strong "The wire format is inspectable.") " What the server sends to the client is not a blob of serialized state. It is s-expressions that any system — browser, AI, another server — can parse, reason about, and act on."))) + + (~doc-section :title "AI as a native speaker" :id "ai-native" + (p :class "text-stone-600" + "Current AI integration with the web is mediated through layers of indirection. An " (a :href "https://en.wikipedia.org/wiki/Large_language_model" :class "text-violet-600 hover:underline" "LLM") " generates " (a :href "https://en.wikipedia.org/wiki/React_(software)" :class "text-violet-600 hover:underline" "React") " components as strings that must be compiled, bundled, and deployed. It interacts with APIs through " (a :href "https://en.wikipedia.org/wiki/JSON" :class "text-violet-600 hover:underline" "JSON") " endpoints that require separate documentation. It reads HTML by scraping, because the markup was never meant to be machine-readable in a computational sense.") + (p :class "text-stone-600" + "In an SX web, the AI reads the same s-expressions the browser reads. The component definitions " (em "are") " the documentation — a " (code "defcomp") " declares its parameters, its structure, and its semantics in one expression. There is no " (a :href "https://en.wikipedia.org/wiki/OpenAPI_Specification" :class "text-violet-600 hover:underline" "Swagger spec") " describing an API. The API " (em "is") " the language, and the language is self-describing.") + (p :class "text-stone-600" + "An AI that understands SX understands the " (a :href "/specs/core" :class "text-violet-600 hover:underline" "spec") ". And the spec is written in SX. So the AI understands the definition of the language it is using, in the language it is using. This " (a :href "https://en.wikipedia.org/wiki/Reflexivity_(social_theory)" :class "text-violet-600 hover:underline" "reflexive") " property means the AI does not need a separate mental model for \"the web\" and \"the language\" — they are the same thing.")) + + (~doc-section :title "Live system modification" :id "live-modification" + (p :class "text-stone-600" + "Because code is data and the wire format is the language, modifying a running system is not deployment — it is evaluation. An AI reads " (code "(defcomp ~checkout-form ...)") ", understands what it does (because the semantics are specified in SX), modifies the expression, and sends it back. The system evaluates the new definition. No build step. No deploy pipeline. No container restart.") + (p :class "text-stone-600" + "This is not theoretical — it is how " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " development has always worked. You modify a function in the running image. The change takes effect immediately. What is new is putting this on the wire, across a network, with the AI as a participant rather than a tool.") + (p :class "text-stone-600" + "The implications for development itself are significant. An AI does not need to " (em "generate code") " that a human then reviews, commits, builds, and deploys. It can propose a modified expression, the human evaluates it in a sandbox, and if it works, the expression becomes the new definition. The feedback loop shrinks from hours to seconds.") + (p :class "text-stone-600" + "More radically: the distinction between \"development\" and \"operation\" dissolves. If the running system is a set of s-expressions, and those expressions can be inspected and modified at runtime, then there is no separate development environment. There is just the system, and agents — human or artificial — that interact with it.")) + + (~doc-section :title "Federated intelligence" :id "federated-intelligence" + (p :class "text-stone-600" + (a :href "https://en.wikipedia.org/wiki/ActivityPub" :class "text-violet-600 hover:underline" "ActivityPub") " carries activities between nodes. If those activities contain s-expressions, then what travels between servers is not just data — it is " (em "behaviour") ". Node A sends a component definition to Node B. Node B evaluates it. The result is rendered. The sender's intent is executable on the receiver's hardware.") + (p :class "text-stone-600" + "This is fundamentally different from sending " (a :href "https://en.wikipedia.org/wiki/JSON" :class "text-violet-600 hover:underline" "JSON") " payloads. JSON says \"here is some data, figure out what to do with it.\" An s-expression says \"here is what to do, and here is the data to do it with.\" The component definition and the data it operates on travel together.") + (p :class "text-stone-600" + "For AI agents in a federated network, this means an agent on one node can send " (em "capabilities") " to another node, not just requests. A component that renders a specific visualization. A macro that transforms data into a particular format. A function that implements a protocol. The network becomes a shared computational substrate where intelligence is distributed as executable expressions.")) + + (~doc-section :title "Programs writing programs writing programs" :id "meta-programs" + (p :class "text-stone-600" + "A macro is a function that takes code and returns code. An AI generating macros is writing programs that write programs. With " (code "eval") ", those generated programs can generate more programs at runtime. This is not a metaphor — it is the literal mechanism.") + (p :class "text-stone-600" + "The " (a :href "/essays/godel-escher-bach" :class "text-violet-600 hover:underline" "Gödel numbering") " parallel is not incidental. " (a :href "https://en.wikipedia.org/wiki/Kurt_G%C3%B6del" :class "text-violet-600 hover:underline" "Gödel") " showed that any sufficiently powerful formal system can encode statements about itself. A complete Lisp on the wire is a sufficiently powerful formal system. The web can make statements about itself — components that inspect other components, macros that rewrite the page structure, expressions that generate new expressions based on the current state of the system.") + (p :class "text-stone-600" + "Consider what this enables for AI:") + (ul :class "space-y-2 text-stone-600 mt-2" + (li (strong "Self-improving interfaces.") " An AI observes how users interact with a component (click patterns, error rates, abandonment). It reads the component definition — because it is data. It modifies the definition — because data is code. It evaluates the result. The interface adapts without human intervention.") + (li (strong "Generative composition.") " Given a data schema and a design intent, an AI generates not just a component but the " (em "macros") " that generate families of components. The macro is a template for templates. The output scales combinatorially.") + (li (strong "Cross-system reasoning.") " An AI reads component definitions from multiple federated nodes, identifies common patterns, and synthesizes abstractions that work across all of them. The shared language makes cross-system analysis trivial — it is all s-expressions."))) + + (~doc-section :title "The sandbox is everything" :id "sandbox" + (p :class "text-stone-600" + "The same " (a :href "https://en.wikipedia.org/wiki/Homoiconicity" :class "text-violet-600 hover:underline" "homoiconicity") " that makes this powerful makes it dangerous. Code-as-data means an AI can inject " (em "behaviour") ", not just content. A malicious expression evaluated in the wrong context could exfiltrate data, modify other components, or disrupt the system.") + (p :class "text-stone-600" + "This is why the " (a :href "/specs/primitives" :class "text-violet-600 hover:underline" "primitive set") " is the critical security boundary. The spec defines exactly which operations are available. A sandboxed evaluator that only exposes pure primitives (arithmetic, string operations, list manipulation) cannot perform I/O. Cannot access the network. Cannot modify the DOM outside its designated target. The language is " (a :href "https://en.wikipedia.org/wiki/Turing_completeness" :class "text-violet-600 hover:underline" "Turing-complete") " within the sandbox and powerless outside it.") + (p :class "text-stone-600" + "Different contexts grant different primitive sets. A component evaluated in a page slot gets rendering primitives. A macro gets code-transformation primitives. A federated expression from an untrusted node gets the minimal safe set. The sandbox is not bolted on — it is inherent in the language's architecture. What you can do depends on which primitives are in scope.") + (p :class "text-stone-600" + "This matters enormously for AI. An AI agent that can modify the running system must be constrained by the same sandbox mechanism that constrains any other expression. The security model does not distinguish between human-authored code and AI-generated code — both are s-expressions, both are evaluated by the same evaluator, both are subject to the same primitive restrictions.")) + + (~doc-section :title "Not self-aware — reflexive" :id "reflexive" + (p :class "text-stone-600" + "Is this a \"self-aware web\"? Probably not in the " (a :href "https://en.wikipedia.org/wiki/Consciousness" :class "text-violet-600 hover:underline" "consciousness") " sense. But the word we keep reaching for has a precise meaning: " (a :href "https://en.wikipedia.org/wiki/Reflexivity_(social_theory)" :class "text-violet-600 hover:underline" "reflexivity") ". A reflexive system can represent itself, reason about its own structure, and modify itself based on that reasoning.") + (p :class "text-stone-600" + "A " (a :href "https://en.wikipedia.org/wiki/React_(software)" :class "text-violet-600 hover:underline" "React") " app cannot read its own component tree as data and rewrite it. An HTML page cannot inspect its own structure and generate new pages. A JSON API cannot describe its own semantics in a way that is both human-readable and machine-executable.") + (p :class "text-stone-600" + "SX can do all of these things — because there is no distinction between the program and its representation. The source code, the wire format, the runtime state, and the data model are all the same thing: " (a :href "https://en.wikipedia.org/wiki/S-expression" :class "text-violet-600 hover:underline" "s-expressions") ".") + (p :class "text-stone-600" + "What AI adds to this is not awareness but " (em "agency") ". The system has always been reflexive — Lisp has been reflexive for seven decades. What is new is having an agent that can exploit that reflexivity at scale: reading the entire system state as data, reasoning about it, generating modifications, and evaluating the results — all in the native language of the system itself.")) + + (~doc-section :title "The Lisp that escaped the REPL" :id "escaped-repl" + (p :class "text-stone-600" + (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " has been reflexive since " (a :href "https://en.wikipedia.org/wiki/John_McCarthy_(computer_scientist)" :class "text-violet-600 hover:underline" "McCarthy") ". What kept it contained was the boundary of the " (a :href "https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop" :class "text-violet-600 hover:underline" "REPL") " — a single process, a single machine, a single user. The s-expressions lived inside Emacs, inside a Clojure JVM, inside a Scheme interpreter. They did not travel.") + (p :class "text-stone-600" + "SX puts s-expressions on the wire. Between server and client. Between federated nodes. Between human and AI. The reflexive property escapes the process boundary and becomes a property of the " (em "network") ".") + (p :class "text-stone-600" + "A network of nodes that share a reflexive language is a qualitatively different system from a network of nodes that exchange inert data. The former can reason about itself, modify itself, and evolve. The latter can only shuttle payloads.") + (p :class "text-stone-600" + "Whether this constitutes anything approaching awareness is a philosophical question. What is not philosophical is the engineering consequence: a web built on s-expressions is a web that AI can participate in as a " (em "native citizen") ", not as a tool bolted onto the side. The language is the interface. The interface is the language. And the language can describe itself.")) + + (~doc-section :title "What this opens up" :id "possibilities" + (p :class "text-stone-600" + "Concretely:") + (ul :class "space-y-3 text-stone-600 mt-2" + (li (strong "AI-native development environments.") " The IDE is a web page. The web page is s-expressions. The AI reads and writes s-expressions. There is no translation layer between what the AI thinks and what the system executes. " (a :href "https://en.wikipedia.org/wiki/Pair_programming" :class "text-violet-600 hover:underline" "Pair programming") " with an AI becomes pair evaluation.") + (li (strong "Adaptive interfaces.") " Components that observe their own usage patterns and propose modifications. The AI reads the component (data), the interaction logs (data), and generates a modified component (data). Human approves or rejects. The loop is native to the system.") + (li (strong "Semantic federation.") " Nodes exchange not just content but " (em "understanding") ". A component definition carries its own semantics. An AI on a receiving node can reason about what a foreign component does without documentation, because the definition is self-describing.") + (li (strong "Emergent protocols.") " Two AI agents on different nodes, speaking SX, can negotiate new interaction patterns by exchanging macros. The protocol is not predefined — it emerges from the conversation between agents, expressed in the shared language.") + (li (strong "Composable trust.") " The sandbox mechanism means you can give an AI agent " (em "exactly") " the capabilities it needs — no more. Trust is expressed as a set of available primitives, not as an all-or-nothing API key.")) + (p :class "text-stone-600" + "None of these require breakthroughs in AI. They require a web that speaks a reflexive language. " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " solved the language problem in 1958. SX solves the distribution problem. AI provides the agency. The three together produce something that none of them achieves alone: a web that can reason about itself.")))) diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 26395e4..0cc5f2b 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -11,7 +11,8 @@ (dict :label "Reference" :href "/reference/") (dict :label "Protocols" :href "/protocols/wire-format") (dict :label "Examples" :href "/examples/click-to-load") - (dict :label "Essays" :href "/essays/sx-sucks")))) + (dict :label "Essays" :href "/essays/sx-sucks") + (dict :label "Specs" :href "/specs/core")))) (<> (map (lambda (item) (~nav-link :href (get item "href") diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 6e77796..162d746 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -64,7 +64,15 @@ (dict :label "The SX Manifesto" :href "/essays/sx-manifesto") (dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization") (dict :label "Continuations" :href "/essays/continuations") - (dict :label "Godel, Escher, Bach" :href "/essays/godel-escher-bach"))) + (dict :label "Godel, Escher, Bach" :href "/essays/godel-escher-bach") + (dict :label "The Reflexive Web" :href "/essays/reflexive-web"))) + +(define specs-nav-items (list + (dict :label "Core" :href "/specs/core") + (dict :label "Parser" :href "/specs/parser") + (dict :label "Evaluator" :href "/specs/evaluator") + (dict :label "Primitives" :href "/specs/primitives") + (dict :label "Renderer" :href "/specs/renderer"))) ;; Find the current nav label for a slug by matching href suffix. ;; Returns the label string or nil if no match. diff --git a/sx/sx/specs.sx b/sx/sx/specs.sx new file mode 100644 index 0000000..6e83467 --- /dev/null +++ b/sx/sx/specs.sx @@ -0,0 +1,36 @@ +;; Spec viewer components — display canonical SX specification source + +(defcomp ~spec-core-content (&key spec-files) + (~doc-page :title "SX Core Specification" + (p :class "text-stone-600 mb-6" + "SX is defined in SX. These four files constitute the canonical, self-hosting specification of the language. Each file is both documentation and executable definition — bootstrap compilers read them to generate native implementations.") + (div :class "space-y-8" + (map (fn (spec) + (div :class "space-y-3" + (div :class "flex items-baseline gap-3" + (h2 :class "text-2xl font-semibold text-stone-800" + (a :href (get spec "href") + :sx-get (get spec "href") :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + :class "text-violet-700 hover:text-violet-900 underline" + (get spec "title"))) + (span :class "text-sm text-stone-400 font-mono" (get spec "filename"))) + (p :class "text-stone-600" (get spec "desc")) + (div :class "bg-stone-100 rounded-lg p-5 max-h-72 overflow-y-auto" + (pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words" + (code (highlight (get spec "source") "sx")))))) + spec-files)))) + +(defcomp ~spec-detail-content (&key spec-title spec-desc spec-filename spec-source) + (~doc-page :title spec-title + (div :class "flex items-baseline gap-3 mb-4" + (span :class "text-sm text-stone-400 font-mono" spec-filename) + (span :class "text-sm text-stone-500" spec-desc)) + (div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" + (code (highlight spec-source "sx")))))) + +(defcomp ~spec-not-found (&key slug) + (~doc-page :title "Spec Not Found" + (p :class "text-stone-600" + "No specification found for \"" slug "\". This spec may not exist yet."))) diff --git a/sx/sxc/docs.sx b/sx/sxc/docs.sx index 93d8208..3beece4 100644 --- a/sx/sxc/docs.sx +++ b/sx/sxc/docs.sx @@ -16,8 +16,8 @@ children)) (defcomp ~doc-code (&key code) - (div :class "bg-stone-50 border border-stone-200 rounded-lg p-4 overflow-x-auto" - (pre :class "text-sm" (code code)))) + (div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code)))) (defcomp ~doc-note (&key &rest children) (div :class "border-l-4 border-violet-400 bg-violet-50 p-4 text-stone-700 text-sm" @@ -27,7 +27,7 @@ (div :class "overflow-x-auto rounded border border-stone-200" (table :class "w-full text-left text-sm" (thead - (tr :class "border-b border-stone-200 bg-stone-50" + (tr :class "border-b border-stone-200 bg-stone-100" (map (fn (h) (th :class "px-3 py-2 font-medium text-stone-600" h)) headers))) (tbody (map (fn (row) diff --git a/sx/sxc/examples.sx b/sx/sxc/examples.sx index 5df7402..d4f9e5e 100644 --- a/sx/sxc/examples.sx +++ b/sx/sxc/examples.sx @@ -2,24 +2,24 @@ (defcomp ~example-card (&key title description &rest children) (div :class "border border-stone-200 rounded-lg overflow-hidden" - (div :class "bg-stone-50 px-4 py-3 border-b border-stone-200" + (div :class "bg-stone-100 px-4 py-3 border-b border-stone-200" (h3 :class "font-semibold text-stone-800" title) (when description (p :class "text-sm text-stone-500 mt-1" description))) (div :class "p-4" children))) (defcomp ~example-demo (&key &rest children) - (div :class "border border-dashed border-stone-300 rounded p-4 bg-white" children)) + (div :class "border border-dashed border-stone-300 rounded p-4 bg-stone-100" children)) (defcomp ~example-source (&key code) - (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto" - (pre :class "text-sm" (code code)))) + (div :class "bg-stone-100 rounded p-5 mt-3 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code)))) ;; --- Click to load demo --- (defcomp ~click-to-load-demo () (div :class "space-y-4" - (div :id "click-result" :class "p-4 rounded bg-stone-50 text-stone-500 text-center" + (div :id "click-result" :class "p-4 rounded bg-stone-100 text-stone-500 text-center" "Click the button to load content.") (button :sx-get "/examples/api/click" @@ -50,7 +50,7 @@ (button :type "submit" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Submit")) - (div :id "form-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm text-center" + (div :id "form-result" :class "p-3 rounded bg-stone-100 text-stone-500 text-sm text-center" "Submit the form to see the result."))) (defcomp ~form-result (&key name) @@ -66,7 +66,7 @@ :sx-get "/examples/api/poll" :sx-trigger "load, every 2s" :sx-swap "innerHTML" - :class "p-4 rounded border border-stone-200 bg-white text-center font-mono" + :class "p-4 rounded border border-stone-200 bg-stone-100 text-center font-mono" "Loading..."))) (defcomp ~poll-result (&key time count) @@ -145,10 +145,10 @@ (defcomp ~oob-demo () (div :class "space-y-4" (div :class "grid grid-cols-2 gap-4" - (div :id "oob-box-a" :class "p-4 rounded border border-stone-200 bg-white text-center" + (div :id "oob-box-a" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-500" "Box A") (p :class "text-sm text-stone-400" "Waiting...")) - (div :id "oob-box-b" :class "p-4 rounded border border-stone-200 bg-white text-center" + (div :id "oob-box-b" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-500" "Box B") (p :class "text-sm text-stone-400" "Waiting..."))) (button @@ -167,7 +167,7 @@ :sx-get "/examples/api/lazy" :sx-trigger "load" :sx-swap "innerHTML" - :class "p-6 rounded border border-stone-200 bg-stone-50 text-center" + :class "p-6 rounded border border-stone-200 bg-stone-100 text-center" (div :class "animate-pulse space-y-2" (div :class "h-4 bg-stone-200 rounded w-3/4 mx-auto") (div :class "h-4 bg-stone-200 rounded w-1/2 mx-auto"))))) @@ -328,7 +328,7 @@ (p :class "text-sm text-stone-400" "Messages will appear here.")))) (defcomp ~reset-message (&key message time) - (div :class "px-3 py-2 bg-stone-50 rounded text-sm text-stone-700" + (div :class "px-3 py-2 bg-stone-100 rounded text-sm text-stone-700" (str "[" time "] " message))) ;; --- Edit row demo --- @@ -488,7 +488,7 @@ :sx-swap "innerHTML" :class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700" "Full Dashboard")) - (div :id "filter-target" :class "border border-stone-200 rounded p-4 bg-white" + (div :id "filter-target" :class "border border-stone-200 rounded p-4 bg-stone-100" (p :class "text-sm text-stone-400" "Click a button to load content.")))) ;; --- Tabs demo --- @@ -525,7 +525,7 @@ :sx-swap "innerHTML" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Load with animation") - (div :id "anim-target" :class "p-4 rounded border border-stone-200 bg-white text-center" + (div :id "anim-target" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-400" "Content will fade in here.")))) (defcomp ~anim-result (&key color time) @@ -552,7 +552,7 @@ :sx-get "/examples/api/dialog/close" :sx-target "#dialog-container" :sx-swap "innerHTML") - (div :class "relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4" + (div :class "relative bg-stone-100 rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4" (h3 :class "text-lg font-semibold text-stone-800" title) (p :class "text-stone-600" message) (div :class "flex justify-end gap-2" @@ -573,23 +573,23 @@ (defcomp ~keyboard-shortcuts-demo () (div :class "space-y-4" - (div :class "p-4 rounded border border-stone-200 bg-stone-50" + (div :class "p-4 rounded border border-stone-200 bg-stone-100" (p :class "text-sm text-stone-600 font-medium mb-2" "Keyboard shortcuts:") (div :class "flex gap-4" (div :class "flex items-center gap-1" - (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "s") + (kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "s") (span :class "text-sm text-stone-500" "Search")) (div :class "flex items-center gap-1" - (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "n") + (kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "n") (span :class "text-sm text-stone-500" "New item")) (div :class "flex items-center gap-1" - (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "h") + (kbd :class "px-2 py-0.5 bg-stone-100 border border-stone-300 rounded text-xs font-mono" "h") (span :class "text-sm text-stone-500" "Help")))) (div :id "kbd-target" :sx-get "/examples/api/keyboard?key=s" :sx-trigger "keyup[key=='s'&&!event.target.matches('input,textarea')] from:body" :sx-swap "innerHTML" - :class "p-4 rounded border border-stone-200 bg-white text-center" + :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-400 text-sm" "Press a shortcut key...")) (div :sx-get "/examples/api/keyboard?key=n" :sx-trigger "keyup[key=='n'&&!event.target.matches('input,textarea')] from:body" @@ -675,7 +675,7 @@ (button :type "submit" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Submit as JSON")) - (div :id "json-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm" + (div :id "json-result" :class "p-3 rounded bg-stone-100 text-stone-500 text-sm" "Submit the form to see the server echo the parsed JSON."))) (defcomp ~json-result (&key body content-type) @@ -697,7 +697,7 @@ :sx-vals "{\"source\": \"button\", \"version\": \"2.0\"}" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Send with vals") - (div :id "vals-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" + (div :id "vals-result" :class "p-3 rounded bg-stone-100 text-sm text-stone-400" "Click to see server-received values.")) (div :class "space-y-2" (h4 :class "text-sm font-semibold text-stone-700" "sx-headers — send custom headers") @@ -708,7 +708,7 @@ :sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"} :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Send with headers") - (div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" + (div :id "headers-result" :class "p-3 rounded bg-stone-100 text-sm text-stone-400" "Click to see server-received headers.")))) (defcomp ~echo-result (&key label items) @@ -729,7 +729,7 @@ :class "sx-loading-btn px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm flex items-center gap-2" (span :class "sx-spinner w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin") (span "Load slow endpoint")) - (div :id "loading-result" :class "p-4 rounded border border-stone-200 bg-white text-center" + (div :id "loading-result" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-400 text-sm" "Click the button — it takes 2 seconds.")))) (defcomp ~loading-result (&key time) @@ -749,7 +749,7 @@ :sx-sync "replace" :placeholder "Type to search (random delay 0.5-2s)..." :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500") - (div :id "sync-result" :class "p-4 rounded border border-stone-200 bg-white" + (div :id "sync-result" :class "p-4 rounded border border-stone-200 bg-stone-100" (p :class "text-sm text-stone-400" "Type to trigger requests — stale ones get aborted.")))) (defcomp ~sync-result (&key query delay) @@ -768,7 +768,7 @@ :sx-retry "exponential:1000:8000" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Call flaky endpoint") - (div :id "retry-result" :class "p-4 rounded border border-stone-200 bg-white text-center" + (div :id "retry-result" :class "p-4 rounded border border-stone-200 bg-stone-100 text-center" (p :class "text-stone-400 text-sm" "Endpoint fails twice, succeeds on 3rd attempt.")))) (defcomp ~retry-result (&key attempt message) diff --git a/sx/sxc/handlers/examples.sx b/sx/sxc/handlers/examples.sx index b61c25b..bac4eea 100644 --- a/sx/sxc/handlers/examples.sx +++ b/sx/sxc/handlers/examples.sx @@ -235,7 +235,7 @@ (div :class "p-3 bg-amber-50 rounded text-center" (p :class "text-2xl font-bold text-amber-700" "$4.2k") (p :class "text-xs text-amber-600" "Revenue"))) - (div :id "dash-footer" :class "p-3 bg-stone-50 rounded" + (div :id "dash-footer" :class "p-3 bg-stone-100 rounded" (p :class "text-sm text-stone-500" "Last updated: " now))))) ;; --------------------------------------------------------------------------- diff --git a/sx/sxc/home.sx b/sx/sxc/home.sx index c5899da..0a6bfdf 100644 --- a/sx/sxc/home.sx +++ b/sx/sxc/home.sx @@ -3,14 +3,14 @@ (defcomp ~sx-hero (&key &rest children) (div :class "max-w-4xl mx-auto px-6 py-16 text-center" (h1 :class "text-5xl font-bold text-stone-900 mb-4" - (span :class "text-violet-600 font-mono" "()")) + (span :class "text-violet-600 font-mono" "()")) (p :class "text-2xl text-stone-600 mb-8" "s-expressions for the web") (p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12" "A hypermedia-driven UI engine that combines htmx's server-first philosophy " "with React's component model. S-expressions over the wire — no HTML, no JavaScript frameworks.") - (div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 text-left font-mono text-sm overflow-x-auto" - (pre :class "leading-relaxed" children)))) + (div :class "bg-stone-100 rounded-lg p-6 text-left font-mono text-sm mx-auto max-w-2xl" + (pre :class "leading-relaxed whitespace-pre-wrap" children)))) (defcomp ~sx-philosophy () (div :class "max-w-4xl mx-auto px-6 py-12" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index eafa11f..6f46692 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -240,4 +240,42 @@ "tail-call-optimization" (~essay-tail-call-optimization) "continuations" (~essay-continuations) "godel-escher-bach" (~essay-godel-escher-bach) + "reflexive-web" (~essay-reflexive-web) :else (~essay-sx-sucks))) + +;; --------------------------------------------------------------------------- +;; Specs section +;; --------------------------------------------------------------------------- + +(defpage specs-index + :path "/specs/" + :auth :public + :layout (:sx-section + :section "Specs" + :sub-label "Specs" + :sub-href "/specs/core" + :sub-nav (~section-nav :items specs-nav-items :current "Core") + :selected "Core") + :data (spec-data "core") + :content (~spec-core-content :spec-files spec-files)) + +(defpage specs-page + :path "/specs/" + :auth :public + :layout (:sx-section + :section "Specs" + :sub-label "Specs" + :sub-href "/specs/core" + :sub-nav (~section-nav :items specs-nav-items + :current (find-current specs-nav-items slug)) + :selected (or (find-current specs-nav-items slug) "")) + :data (spec-data slug) + :content (if spec-not-found + (~spec-not-found :slug slug) + (case slug + "core" (~spec-core-content :spec-files spec-files) + :else (~spec-detail-content + :spec-title spec-title + :spec-desc spec-desc + :spec-filename spec-filename + :spec-source spec-source)))) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 0ac82f9..f7ce796 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -16,6 +16,7 @@ def _register_sx_helpers() -> None: "primitives-data": _primitives_data, "reference-data": _reference_data, "attr-detail-data": _attr_detail_data, + "spec-data": _spec_data, }) @@ -103,6 +104,63 @@ def _reference_data(slug: str) -> dict: } +_SPEC_FILES = { + "parser": ("parser.sx", "Parser", "Tokenization and parsing of SX source text into AST."), + "evaluator": ("eval.sx", "Evaluator", "Tree-walking evaluation of SX expressions."), + "primitives": ("primitives.sx", "Primitives", "All built-in pure functions and their signatures."), + "renderer": ("render.sx", "Renderer", "Rendering evaluated expressions to DOM, HTML, or SX wire format."), +} + + +def _spec_data(slug: str) -> dict: + """Return spec file source and highlighted version for display.""" + import os + from content.highlight import highlight as _highlight + + ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") + # Normalise — inside container shared is at /app/shared + if not os.path.isdir(ref_dir): + ref_dir = "/app/shared/sx/ref" + + base = {"spec-not-found": None, "spec-title": None, "spec-desc": None, + "spec-filename": None, "spec-source": None, "spec-files": None} + + if slug == "core": + specs = [] + for key in ("parser", "evaluator", "primitives", "renderer"): + filename, title, desc = _SPEC_FILES[key] + filepath = os.path.join(ref_dir, filename) + source = _read_spec(filepath) + specs.append({ + "title": title, + "desc": desc, + "filename": filename, + "source": source, + "href": f"/specs/{key}", + }) + return {**base, "spec-title": "SX Core Specification", "spec-files": specs} + + info = _SPEC_FILES.get(slug) + if not info: + return {**base, "spec-not-found": True} + + filename, title, desc = info + filepath = os.path.join(ref_dir, filename) + source = _read_spec(filepath) + return {**base, + "spec-title": title, "spec-desc": desc, + "spec-filename": filename, "spec-source": source} + + +def _read_spec(filepath: str) -> str: + """Read a spec file, returning empty string if missing.""" + try: + with open(filepath, encoding="utf-8") as f: + return f.read() + except FileNotFoundError: + return ";; spec file not found" + + def _attr_detail_data(slug: str) -> dict: """Return attribute detail data for a specific attribute slug. diff --git a/sx/sxc/reference.sx b/sx/sxc/reference.sx index 1fa16e8..bf36f65 100644 --- a/sx/sxc/reference.sx +++ b/sx/sxc/reference.sx @@ -16,7 +16,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Load server time") (div :id "ref-get-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to load."))) ;; --------------------------------------------------------------------------- @@ -36,7 +36,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Greet")) (div :id "ref-post-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Submit to see greeting."))) ;; --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ (defcomp ~ref-put-demo () (div :id "ref-put-view" - (div :class "flex items-center justify-between p-3 bg-stone-50 rounded" + (div :class "flex items-center justify-between p-3 bg-stone-100 rounded" (span :class "text-stone-700 text-sm" "Status: " (strong "draft")) (button :sx-put "/reference/api/status" @@ -83,7 +83,7 @@ (defcomp ~ref-patch-demo () (div :id "ref-patch-view" :class "space-y-2" - (div :class "p-3 bg-stone-50 rounded" + (div :class "p-3 bg-stone-100 rounded" (span :class "text-stone-700 text-sm" "Theme: " (strong :id "ref-patch-val" "light"))) (div :class "flex gap-2" (button :sx-patch "/reference/api/theme" @@ -93,7 +93,7 @@ (button :sx-patch "/reference/api/theme" :sx-vals "{\"theme\": \"light\"}" :sx-target "#ref-patch-val" :sx-swap "innerHTML" - :class "px-3 py-1 bg-white border border-stone-300 text-stone-700 rounded text-sm" "Light")))) + :class "px-3 py-1 bg-stone-100 border border-stone-300 text-stone-700 rounded text-sm" "Light")))) ;; --------------------------------------------------------------------------- ;; sx-trigger @@ -108,7 +108,7 @@ :sx-swap "innerHTML" :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500") (div :id "ref-trigger-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Start typing to trigger a search."))) ;; --------------------------------------------------------------------------- @@ -186,7 +186,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Load (selecting #the-content)") (div :id "ref-select-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Only the selected fragment will appear here."))) ;; --------------------------------------------------------------------------- @@ -242,7 +242,7 @@ (p :class "text-xs text-stone-400" "With sync:replace, each new keystroke aborts the in-flight request.") (div :id "ref-sync-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Type to see only the latest result."))) ;; --------------------------------------------------------------------------- @@ -262,7 +262,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Upload")) (div :id "ref-encoding-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Select a file and submit."))) ;; --------------------------------------------------------------------------- @@ -278,7 +278,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Send with custom headers") (div :id "ref-headers-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to see echoed headers."))) ;; --------------------------------------------------------------------------- @@ -302,7 +302,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Filter")) (div :id "ref-include-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click Filter — the select value is included in the request."))) ;; --------------------------------------------------------------------------- @@ -318,7 +318,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Send with extra values") (div :id "ref-vals-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to see echoed values."))) ;; --------------------------------------------------------------------------- @@ -369,7 +369,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Click me") (div :id "ref-on-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click the button — runs JavaScript, no server request."))) ;; --------------------------------------------------------------------------- @@ -385,7 +385,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" "Call flaky endpoint") (div :id "ref-retry-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "This endpoint fails 2 out of 3 times. sx-retry retries automatically."))) ;; --------------------------------------------------------------------------- @@ -440,7 +440,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Hover then click (preloaded)") (div :id "ref-preload-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Hover over the button first, then click — the response is instant."))) ;; --------------------------------------------------------------------------- @@ -461,7 +461,7 @@ (input :id "ref-preserved-input" :sx-preserve "true" :type "text" :placeholder "Type here — preserved across swaps" :class "w-full px-3 py-2 border border-stone-300 rounded text-sm") - (div :class "p-2 bg-stone-50 rounded text-sm text-stone-600" + (div :class "p-2 bg-stone-100 rounded text-sm text-stone-600" "This text will be replaced on swap.")))) ;; --------------------------------------------------------------------------- @@ -484,7 +484,7 @@ :style "display: none" "Loading...")) (div :id "ref-indicator-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to load (indicator shows during request)."))) ;; --------------------------------------------------------------------------- @@ -506,7 +506,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Submit")) (div :id "ref-validate-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Submit with invalid/empty email to see validation."))) ;; --------------------------------------------------------------------------- @@ -526,7 +526,7 @@ (p :class "text-sm text-amber-800" "This subtree has sx-ignore — it won't change.") (input :type "text" :placeholder "Type here — ignored during swap" :class "mt-1 w-full px-2 py-1 border border-amber-300 rounded text-sm")) - (div :class "p-2 bg-stone-50 rounded text-sm text-stone-600" + (div :class "p-2 bg-stone-100 rounded text-sm text-stone-600" "This text WILL be replaced on swap.")))) ;; --------------------------------------------------------------------------- @@ -566,7 +566,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Load (replaces URL)") (div :id "ref-replurl-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to load — URL changes but no new history entry."))) ;; --------------------------------------------------------------------------- @@ -586,7 +586,7 @@ "Click (disables during request)") (span :class "text-xs text-stone-400" "Button is disabled while request is in-flight.")) (div :id "ref-diselt-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click the button to see it disable during the request."))) ;; --------------------------------------------------------------------------- @@ -603,7 +603,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Prompt & send") (div :id "ref-prompt-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Click to enter a name via prompt — it is sent as the SX-Prompt header."))) ;; --------------------------------------------------------------------------- @@ -626,7 +626,7 @@ :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Submit")) (div :id "ref-params-result" - :class "p-3 rounded bg-stone-50 text-stone-400 text-sm" + :class "p-3 rounded bg-stone-100 text-stone-400 text-sm" "Only 'name' will be sent — 'secret' is filtered by sx-params."))) ;; --------------------------------------------------------------------------- @@ -639,7 +639,7 @@ :sx-sse-swap "time" :sx-swap "innerHTML" (div :id "ref-sse-result" - :class "p-3 rounded bg-stone-50 text-stone-600 text-sm font-mono" + :class "p-3 rounded bg-stone-100 text-stone-600 text-sm font-mono" "Connecting to SSE stream...")) (p :class "text-xs text-stone-400" "Server pushes time updates every 2 seconds via Server-Sent Events."))) From daeecab310f54a4772e5a28edc038520d1538b8b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 11:49:44 +0000 Subject: [PATCH 12/24] Restructure SX ref spec into core + selectable adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split monolithic render.sx into core (tag registries, shared utils) plus four adapter .sx files: adapter-html (server HTML strings), adapter-sx (SX wire format), adapter-dom (browser DOM nodes), and engine (SxEngine triggers, morphing, swaps). All adapters written in s-expressions with platform interface declarations for JS bridge functions. Bootstrap compiler now accepts --adapters flag to emit targeted builds: -a html → server-only (1108 lines) -a dom,engine → browser-only (1634 lines) -a html,sx → server with SX wire (1169 lines) (default) → all adapters (1800 lines) Fixes: keyword arg i-counter desync in reduce across all adapters, render-aware special forms (let/if/when/cond/map) in HTML adapter, component children double-escaping, ~prefixed macro dispatch. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 860 +++++++++++++++++++++++++++++--- shared/sx/ref/adapter-dom.sx | 469 +++++++++++++++++ shared/sx/ref/adapter-html.sx | 312 ++++++++++++ shared/sx/ref/adapter-sx.sx | 147 ++++++ shared/sx/ref/bootstrap_js.py | 658 +++++++++++++++++++++--- shared/sx/ref/engine.sx | 747 +++++++++++++++++++++++++++ shared/sx/ref/eval.sx | 15 +- shared/sx/ref/render.sx | 259 +--------- 8 files changed, 3083 insertions(+), 384 deletions(-) create mode 100644 shared/sx/ref/adapter-dom.sx create mode 100644 shared/sx/ref/adapter-html.sx create mode 100644 shared/sx/ref/adapter-sx.sx create mode 100644 shared/sx/ref/engine.sx diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 4b9b587..c7aff82 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -405,12 +405,23 @@ function append_b(arr, x) { arr.push(x); return arr; } var apply = function(f, args) { return f.apply(null, args); }; + // Additional primitive aliases used by adapter/engine transpiled code + var split = PRIMITIVES["split"]; + var trim = PRIMITIVES["trim"]; + var upper = PRIMITIVES["upper"]; + var lower = PRIMITIVES["lower"]; + var replace_ = function(s, old, nw) { return s.split(old).join(nw); }; + var endsWith = PRIMITIVES["ends-with?"]; + var parseInt_ = PRIMITIVES["parse-int"]; + var dict_fn = PRIMITIVES["dict"]; + // HTML rendering helpers function escapeHtml(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } function escapeAttr(s) { return escapeHtml(s); } function rawHtmlContent(r) { return r.html; } + function makeRawHtml(s) { return { _raw: true, html: s }; } // Serializer function serialize(val) { @@ -434,7 +445,46 @@ "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 }; } - // === Transpiled from eval.sx === + // processBindings and evalCond — exposed for DOM adapter render forms + function processBindings(bindings, env) { + var local = merge(env); + for (var i = 0; i < bindings.length; i++) { + var pair = bindings[i]; + if (Array.isArray(pair) && pair.length >= 2) { + var name = isSym(pair[0]) ? pair[0].name : String(pair[0]); + local[name] = trampoline(evalExpr(pair[1], local)); + } + } + return local; + } + function evalCond(clauses, env) { + for (var i = 0; i < clauses.length; i += 2) { + var test = clauses[i]; + if (isSym(test) && test.name === ":else") return clauses[i + 1]; + if (isKw(test) && test.name === "else") return clauses[i + 1]; + if (isSxTruthy(trampoline(evalExpr(test, env)))) return clauses[i + 1]; + } + return null; + } + + function isDefinitionForm(name) { + return name === "define" || name === "defcomp" || name === "defmacro" || + name === "defstyle" || name === "defkeyframes" || name === "defhandler"; + } + + function indexOf_(s, ch) { + return typeof s === "string" ? s.indexOf(ch) : -1; + } + + function dictHas(d, k) { return d != null && k in d; } + function dictDelete(d, k) { delete d[k]; } + + function forEachIndexed(fn, coll) { + for (var i = 0; i < coll.length; i++) fn(i, coll[i]); + return NIL; + } + + // === Transpiled from eval === // trampoline var trampoline = function(val) { return (function() { @@ -620,7 +670,7 @@ { var _c = paramsExpr; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; if (isSxTruthy((typeOf(p) == "symbol"))) { (function() { var name = symbolName(p); - return (isSxTruthy((name == "&key")) ? (inKey = true) : (isSxTruthy((name == "&rest")) ? (hasChildren = true) : (isSxTruthy((isSxTruthy(inKey) && !hasChildren)) ? append_b(params, name) : append_b(params, name)))); + return (isSxTruthy((name == "&key")) ? (inKey = true) : (isSxTruthy((name == "&rest")) ? (hasChildren = true) : (isSxTruthy((name == "&children")) ? (hasChildren = true) : (isSxTruthy(hasChildren) ? NIL : (isSxTruthy(inKey) ? append_b(params, name) : append_b(params, name)))))); })(); } } } return [params, hasChildren]; @@ -765,7 +815,7 @@ })(); }; - // === Transpiled from render.sx === + // === Transpiled from render (core) === // HTML_TAGS var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "math", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text", "tspan", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "feTurbulence", "feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA", "feDisplacementMap", "feFlood", "feImage", "feMorphology", "feSpecularLighting", "feDiffuseLighting", "fePointLight", "feSpotLight", "feDistantLight", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; @@ -776,33 +826,8 @@ // BOOLEAN_ATTRS var BOOLEAN_ATTRS = ["async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected"]; - // render-to-html - var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; - - // render-value-to-html - var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); }; - - // render-list-to-html - var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { - var head = first(expr); - return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { - var name = symbolName(head); - var args = rest(expr); - return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { - var comp = envGet(env, name); - return (isSxTruthy(isComponent(comp)) ? renderToHtml(trampoline(callComponent(comp, args, env)), env) : error((String("Unknown component: ") + String(name)))); -})() : (isSxTruthy(sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defkeyframes"), (name == "defhandler"))) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))); -})()); -})()); }; - - // render-html-element - var renderHtmlElement = function(tag, args, env) { return (function() { - var parsed = parseElementArgs(args, env); - var attrs = first(parsed); - var children = nth(parsed, 1); - var isVoid = contains(VOID_ELEMENTS, tag); - return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String(""))))); -})(); }; + // definition-form? + var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defkeyframes"), (name == "defhandler")); }; // parse-element-args var parseElementArgs = function(args, env) { return (function() { @@ -810,7 +835,7 @@ var children = []; reduce(function(state, arg) { return (function() { var skip = get(state, "skip"); - return (isSxTruthy(skip) ? assoc(state, "skip", false) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); attrs[keywordName(arg)] = val; return assoc(state, "skip", true, "i", (get(state, "i") + 1)); @@ -825,10 +850,103 @@ return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\"")))))); })(); }, keys(attrs))); }; + + // === Transpiled from adapter-html === + + // render-to-html + var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); if (_m == "raw-html") return rawHtmlContent(expr); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; + + // render-value-to-html + var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); }; + + // RENDER_HTML_FORMS + var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"]; + + // render-html-form? + var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); }; + + // render-list-to-html + var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { + var head = first(expr); + return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { + var name = symbolName(head); + var args = rest(expr); + return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + var val = envGet(env, name); + return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name))))); +})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))); +})()); +})()); }; + + // dispatch-html-form + var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() { + var condVal = trampoline(evalExpr(nth(expr, 1), env)); + return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : "")); +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() { + var branch = evalCond(rest(expr), env); + return (isSxTruthy(branch) ? renderToHtml(branch, env) : ""); +})() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() { + var local = processBindings(nth(expr, 1), env); + return join("", map(function(i) { return renderToHtml(nth(expr, i), local); }, range(2, len(expr)))); +})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(1, len(expr)))) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((name == "map")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll)); +})() : (isSxTruthy((name == "map-indexed")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + return join("", mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [i, item], env) : renderToHtml(apply(f, [i, item]), env)); }, coll)); +})() : (isSxTruthy((name == "filter")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy((name == "for-each")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll)); +})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))))); }; + + // render-lambda-html + var renderLambdaHtml = function(f, args, env) { return (function() { + var local = envMerge(lambdaClosure(f), env); + forEachIndexed(function(i, p) { return envSet(local, p, nth(args, i)); }, lambdaParams(f)); + return renderToHtml(lambdaBody(f), local); +})(); }; + + // render-html-component + var renderHtmlComponent = function(comp, args, env) { return (function() { + var kwargs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + kwargs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + if (isSxTruthy(componentHasChildren(comp))) { + local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))); +} + return renderToHtml(componentBody(comp), local); +})(); +})(); }; + + // render-html-element + var renderHtmlElement = function(tag, args, env) { return (function() { + var parsed = parseElementArgs(args, env); + var attrs = first(parsed); + var children = nth(parsed, 1); + var isVoid = contains(VOID_ELEMENTS, tag); + return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String(""))))); +})(); }; + + + // === Transpiled from adapter-sx === + // render-to-sx var renderToSx = function(expr, env) { return (function() { var result = aser(expr, env); - return serialize(result); + return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result)); })(); }; // aser @@ -862,7 +980,7 @@ var parts = [name]; reduce(function(state, arg) { return (function() { var skip = get(state, "skip"); - return (isSxTruthy(skip) ? assoc(state, "skip", false) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { var val = aser(nth(args, (get(state, "i") + 1)), env); if (isSxTruthy(!isNil(val))) { parts.push((String(":") + String(keywordName(arg)))); @@ -881,6 +999,628 @@ })(); }; + // === Transpiled from adapter-dom === + + // SVG_NS + var SVG_NS = "http://www.w3.org/2000/svg"; + + // MATH_NS + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + // render-to-dom + var renderToDom = function(expr, env, ns) { return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); if (_m == "style-value") return createTextNode(styleValueClass(expr)); return createTextNode((String(expr))); })(); }; + + // render-dom-list + var renderDomList = function(expr, env, ns) { return (function() { + var head = first(expr); + return (isSxTruthy((typeOf(head) == "symbol")) ? (function() { + var name = symbolName(head); + var args = rest(expr); + return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { + var comp = envGet(env, name); + return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(name)); +})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))); +})() : (isSxTruthy(sxOr(isLambda(head), (typeOf(head) == "list"))) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (function() { + var frag = createFragment(); + { var _c = expr; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } + return frag; +})())); +})(); }; + + // render-dom-element + var renderDomElement = function(tag, args, env, ns) { return (function() { + var newNs = (isSxTruthy((tag == "svg")) ? SVG_NS : (isSxTruthy((tag == "math")) ? MATH_NS : ns)); + var el = domCreateElement(tag, newNs); + var extraClass = NIL; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var attrName = keywordName(arg); + var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy((attrName == "style")) && isStyleValue(attrVal))) ? (extraClass = styleValueClass(attrVal)) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal))))))); + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : ((isSxTruthy(!contains(VOID_ELEMENTS, tag)) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + if (isSxTruthy(extraClass)) { + (function() { + var existing = domGetAttr(el, "class"); + return domSetAttr(el, "class", (isSxTruthy(existing) ? (String(existing) + String(" ") + String(extraClass)) : extraClass)); +})(); +} + return el; +})(); }; + + // render-dom-component + var renderDomComponent = function(comp, args, env, ns) { return (function() { + var kwargs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + kwargs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + if (isSxTruthy(componentHasChildren(comp))) { + (function() { + var childFrag = createFragment(); + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; domAppend(childFrag, renderToDom(c, env, ns)); } } + return envSet(local, "children", childFrag); +})(); +} + return renderToDom(componentBody(comp), local, ns); +})(); +})(); }; + + // render-dom-fragment + var renderDomFragment = function(args, env, ns) { return (function() { + var frag = createFragment(); + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } + return frag; +})(); }; + + // render-dom-raw + var renderDomRaw = function(args, env) { return (function() { + var frag = createFragment(); + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (function() { + var val = trampoline(evalExpr(arg, env)); + return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isNil(val)) ? domAppend(frag, createTextNode((String(val)))) : NIL))); +})(); } } + return frag; +})(); }; + + // render-dom-unknown-component + var renderDomUnknownComponent = function(name) { return (function() { + var el = domCreateElement("div", NIL); + domSetAttr(el, "style", "background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace"); + domAppend(el, createTextNode((String("Unknown component: ") + String(name)))); + return el; +})(); }; + + // RENDER_DOM_FORMS + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"]; + + // render-dom-form? + var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; + + // dispatch-render-form + var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (function() { + var condVal = trampoline(evalExpr(nth(expr, 1), env)); + return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment())); +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? createFragment() : (function() { + var frag = createFragment(); + { var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } + return frag; +})()) : (isSxTruthy((name == "cond")) ? (function() { + var branch = evalCond(rest(expr), env); + return (isSxTruthy(branch) ? renderToDom(branch, env, ns) : createFragment()); +})() : (isSxTruthy((name == "case")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() { + var local = processBindings(nth(expr, 1), env); + var frag = createFragment(); + { var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), local, ns)); } } + return frag; +})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? (function() { + var frag = createFragment(); + { var _c = range(1, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } + return frag; +})() : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})() : (isSxTruthy((name == "map-indexed")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + forEachIndexed(function(i, item) { return (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [i, item], env, ns) : renderToDom(apply(f, [i, item]), env, ns)); + return domAppend(frag, val); +})(); }, coll); + return frag; +})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "for-each")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))); }; + + // render-lambda-dom + var renderLambdaDom = function(f, args, env, ns) { return (function() { + var local = envMerge(lambdaClosure(f), env); + forEachIndexed(function(i, p) { return envSet(local, p, nth(args, i)); }, lambdaParams(f)); + return renderToDom(lambdaBody(f), local, ns); +})(); }; + + + // === Transpiled from engine === + + // ENGINE_VERBS + var ENGINE_VERBS = ["get", "post", "put", "delete", "patch"]; + + // DEFAULT_SWAP + var DEFAULT_SWAP = "outerHTML"; + + // parse-time + var parseTime = function(s) { return (isSxTruthy(isNil(s)) ? 0 : (isSxTruthy(endsWith(s, "ms")) ? parseInt_(s, 0) : (isSxTruthy(endsWith(s, "s")) ? (parseInt_(replace_(s, "s", ""), 0) * 1000) : parseInt_(s, 0)))); }; + + // parse-trigger-spec + var parseTriggerSpec = function(spec) { return (isSxTruthy(isNil(spec)) ? NIL : (function() { + var rawParts = split(spec, ","); + return filter(function(x) { return !isNil(x); }, map(function(part) { return (function() { + var tokens = split(trim(part), " "); + return (isSxTruthy(isEmpty(tokens)) ? NIL : (isSxTruthy((isSxTruthy((first(tokens) == "every")) && (len(tokens) >= 2))) ? {["event"]: "every", ["modifiers"]: {["interval"]: parseTime(nth(tokens, 1))}} : (function() { + var mods = {}; + { var _c = rest(tokens); for (var _i = 0; _i < _c.length; _i++) { var tok = _c[_i]; (isSxTruthy((tok == "once")) ? dictSet(mods, "once", true) : (isSxTruthy((tok == "changed")) ? dictSet(mods, "changed", true) : (isSxTruthy(startsWith(tok, "delay:")) ? dictSet(mods, "delay", parseTime(slice(tok, 6))) : (isSxTruthy(startsWith(tok, "from:")) ? dictSet(mods, "from", slice(tok, 5)) : NIL)))); } } + return {["event"]: first(tokens), ["modifiers"]: mods}; +})())); +})(); }, rawParts)); +})()); }; + + // default-trigger + var defaultTrigger = function(tagName) { return (isSxTruthy((tagName == "FORM")) ? [{["event"]: "submit", ["modifiers"]: {}}] : (isSxTruthy(sxOr((tagName == "INPUT"), (tagName == "SELECT"), (tagName == "TEXTAREA"))) ? [{["event"]: "change", ["modifiers"]: {}}] : [{["event"]: "click", ["modifiers"]: {}}])); }; + + // get-verb-info + var getVerbInfo = function(el) { return some(function(verb) { return (function() { + var url = domGetAttr(el, (String("sx-") + String(verb))); + return (isSxTruthy(url) ? {["method"]: upper(verb), ["url"]: url} : NIL); +})(); }, ENGINE_VERBS); }; + + // build-request-headers + var buildRequestHeaders = function(el, loadedComponents, cssHash) { return (function() { + var headers = {["SX-Request"]: "true", ["SX-Current-URL"]: browserLocationHref()}; + (function() { + var targetSel = domGetAttr(el, "sx-target"); + return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL); +})(); + if (isSxTruthy(!isEmpty(loadedComponents))) { + headers["SX-Components"] = join(",", loadedComponents); +} + if (isSxTruthy(cssHash)) { + headers["SX-Css"] = cssHash; +} + (function() { + var extraH = domGetAttr(el, "sx-headers"); + return (isSxTruthy(extraH) ? (function() { + var parsed = parseHeaderValue(extraH); + return (isSxTruthy(parsed) ? forEach(function(key) { return dictSet(headers, key, (String(get(parsed, key)))); }, keys(parsed)) : NIL); +})() : NIL); +})(); + return headers; +})(); }; + + // process-response-headers + var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; }; + + // parse-swap-spec + var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() { + var parts = split(sxOr(rawSwap, DEFAULT_SWAP), " "); + var style = first(parts); + var useTransition = globalTransitions_p; + { var _c = rest(parts); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; (isSxTruthy((p == "transition:true")) ? (useTransition = true) : (isSxTruthy((p == "transition:false")) ? (useTransition = false) : NIL)); } } + return {["style"]: style, ["transition"]: useTransition}; +})(); }; + + // parse-retry-spec + var parseRetrySpec = function(retryAttr) { return (isSxTruthy(isNil(retryAttr)) ? NIL : (function() { + var parts = split(retryAttr, ":"); + return {["strategy"]: first(parts), ["start-ms"]: parseInt_(nth(parts, 1), 1000), ["cap-ms"]: parseInt_(nth(parts, 2), 30000)}; +})()); }; + + // next-retry-ms + var nextRetryMs = function(currentMs, capMs) { return min((currentMs * 2), capMs); }; + + // filter-params + var filterParams = function(paramsSpec, allParams) { return (isSxTruthy(isNil(paramsSpec)) ? allParams : (isSxTruthy((paramsSpec == "none")) ? [] : (isSxTruthy((paramsSpec == "*")) ? allParams : (isSxTruthy(startsWith(paramsSpec, "not ")) ? (function() { + var excluded = map(trim, split(slice(paramsSpec, 4), ",")); + return filter(function(p) { return !contains(excluded, first(p)); }, allParams); +})() : (function() { + var allowed = map(trim, split(paramsSpec, ",")); + return filter(function(p) { return contains(allowed, first(p)); }, allParams); +})())))); }; + + // resolve-target + var resolveTarget = function(el) { return (function() { + var sel = domGetAttr(el, "sx-target"); + return (isSxTruthy(sxOr(isNil(sel), (sel == "this"))) ? el : (isSxTruthy((sel == "closest")) ? domParent(el) : domQuery(sel))); +})(); }; + + // apply-optimistic + var applyOptimistic = function(el) { return (function() { + var directive = domGetAttr(el, "sx-optimistic"); + return (isSxTruthy(isNil(directive)) ? NIL : (function() { + var target = sxOr(resolveTarget(el), el); + var state = {["target"]: target, ["directive"]: directive}; + (isSxTruthy((directive == "remove")) ? (dictSet(state, "opacity", domGetStyle(target, "opacity")), domSetStyle(target, "opacity", "0"), domSetStyle(target, "pointer-events", "none")) : (isSxTruthy((directive == "disable")) ? (dictSet(state, "disabled", domGetProp(target, "disabled")), domSetProp(target, "disabled", true)) : (isSxTruthy(startsWith(directive, "add-class:")) ? (function() { + var cls = slice(directive, 10); + state["add-class"] = cls; + return domAddClass(target, cls); +})() : NIL))); + return state; +})()); +})(); }; + + // revert-optimistic + var revertOptimistic = function(state) { return (isSxTruthy(state) ? (function() { + var target = get(state, "target"); + var directive = get(state, "directive"); + return (isSxTruthy((directive == "remove")) ? (domSetStyle(target, "opacity", sxOr(get(state, "opacity"), "")), domSetStyle(target, "pointer-events", "")) : (isSxTruthy((directive == "disable")) ? domSetProp(target, "disabled", sxOr(get(state, "disabled"), false)) : (isSxTruthy(get(state, "add-class")) ? domRemoveClass(target, get(state, "add-class")) : NIL))); +})() : NIL); }; + + // find-oob-swaps + var findOobSwaps = function(container) { return (function() { + var results = []; + { var _c = ["sx-swap-oob", "hx-swap-oob"]; for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + return (isSxTruthy(targetId) ? append_b(results, {["element"]: oob, ["swap-type"]: swapType, ["target-id"]: targetId}) : NIL); +})(); }, oobEls); +})(); } } + return results; +})(); }; + + // morph-node + var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!(domNodeType(oldNode) == domNodeType(newNode)), !(domNodeName(oldNode) == domNodeName(newNode)))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!(domTextContent(oldNode) == domTextContent(newNode))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!(isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); }; + + // sync-attrs + var syncAttrs = function(oldEl, newEl) { return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(!(domGetAttr(oldEl, name) == val)) ? domSetAttr(oldEl, name, val) : NIL); +})(); }, domAttrList(newEl)); }; + + // morph-children + var morphChildren = function(oldParent, newParent) { return (function() { + var oldKids = domChildList(oldParent); + var newKids = domChildList(newParent); + var oldById = reduce(function(acc, kid) { return (function() { + var id = domId(kid); + return (isSxTruthy(id) ? (dictSet(acc, id, kid), acc) : acc); +})(); }, {}, oldKids); + var oi = 0; + { var _c = newKids; for (var _i = 0; _i < _c.length; _i++) { var newChild = _c[_i]; (function() { + var matchId = domId(newChild); + var matchById = (isSxTruthy(matchId) ? dictGet(oldById, matchId) : NIL); + return (isSxTruthy((isSxTruthy(matchById) && !isNil(matchById))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !(matchById == nth(oldKids, oi)))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() { + var oldChild = nth(oldKids, oi); + return (isSxTruthy((isSxTruthy(domId(oldChild)) && !matchId)) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1)))); +})() : domAppend(oldParent, domClone(newChild)))); +})(); } } + return forEach(function(i) { return (isSxTruthy((i >= oi)) ? (function() { + var leftover = nth(oldKids, i); + return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!domHasAttr(leftover, "sx-preserve")) && !domHasAttr(leftover, "sx-ignore"))) ? domRemoveChild(oldParent, leftover) : NIL); +})() : NIL); }, range(oi, len(oldKids))); +})(); }; + + // swap-dom-nodes + var swapDomNodes = function(target, newNodes, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + (isSxTruthy(domIsFragment(newNodes)) ? (function() { + var fc = domFirstChild(newNodes); + return (isSxTruthy(fc) ? (morphNode(target, fc), (function() { + var sib = domNextSibling(fc); + return insertRemainingSiblings(parent, target, sib); +})()) : domRemoveChild(parent, target)); +})() : morphNode(target, newNodes)); + return parent; +})(); if (_m == "afterend") return domInsertAfter(target, newNodes); if (_m == "beforeend") return domAppend(target, newNodes); if (_m == "afterbegin") return domPrepend(target, newNodes); if (_m == "beforebegin") return domInsertBefore(domParent(target), newNodes, target); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); })(); }; + + // insert-remaining-siblings + var insertRemainingSiblings = function(parent, refNode, sib) { return (isSxTruthy(sib) ? (function() { + var next = domNextSibling(sib); + domInsertAfter(refNode, sib); + return insertRemainingSiblings(parent, sib, next); +})() : NIL); }; + + // swap-html-string + var swapHtmlString = function(target, html, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return domSetInnerHtml(target, html); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + domInsertAdjacentHtml(target, "afterend", html); + domRemoveChild(parent, target); + return parent; +})(); if (_m == "afterend") return domInsertAdjacentHtml(target, "afterend", html); if (_m == "beforeend") return domInsertAdjacentHtml(target, "beforeend", html); if (_m == "afterbegin") return domInsertAdjacentHtml(target, "afterbegin", html); if (_m == "beforebegin") return domInsertAdjacentHtml(target, "beforebegin", html); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return domSetInnerHtml(target, html); })(); }; + + // handle-history + var handleHistory = function(el, url, respHeaders) { return (function() { + var pushUrl = domGetAttr(el, "sx-push-url"); + var replaceUrl = domGetAttr(el, "sx-replace-url"); + var hdrReplace = get(respHeaders, "replace-url"); + return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !(pushUrl == "false"))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !(replaceUrl == "false"))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL))); +})(); }; + + // PRELOAD_TTL + var PRELOAD_TTL = 30000; + + // preload-cache-get + var preloadCacheGet = function(cache, url) { return (function() { + var entry = dictGet(cache, url); + return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "timestamp")) > PRELOAD_TTL)) ? (dictDelete(cache, url), NIL) : (dictDelete(cache, url), entry))); +})(); }; + + // preload-cache-set + var preloadCacheSet = function(cache, url, text, contentType) { return dictSet(cache, url, {["text"]: text, ["content-type"]: contentType, ["timestamp"]: nowMs()}); }; + + // classify-trigger + var classifyTrigger = function(trigger) { return (function() { + var event = get(trigger, "event"); + return (isSxTruthy((event == "every")) ? "poll" : (isSxTruthy((event == "intersect")) ? "intersect" : (isSxTruthy((event == "load")) ? "load" : (isSxTruthy((event == "revealed")) ? "revealed" : "event")))); +})(); }; + + // should-boost-link? + var shouldBoostLink = function(link) { return (function() { + var href = domGetAttr(link, "href"); + return (isSxTruthy(href) && isSxTruthy(!startsWith(href, "#")) && isSxTruthy(!startsWith(href, "javascript:")) && isSxTruthy(!startsWith(href, "mailto:")) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!domHasAttr(link, "sx-get")) && isSxTruthy(!domHasAttr(link, "sx-post")) && !domHasAttr(link, "sx-disable")); +})(); }; + + // should-boost-form? + var shouldBoostForm = function(form) { return (isSxTruthy(!domHasAttr(form, "sx-get")) && isSxTruthy(!domHasAttr(form, "sx-post")) && !domHasAttr(form, "sx-disable")); }; + + // parse-sse-swap + var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + + + // ========================================================================= + // Platform interface — DOM adapter (browser-only) + // ========================================================================= + + var _hasDom = typeof document !== "undefined"; + + var SVG_NS = "http://www.w3.org/2000/svg"; + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + function domCreateElement(tag, ns) { + if (!_hasDom) return null; + if (ns) return document.createElementNS(ns, tag); + return document.createElement(tag); + } + + function createTextNode(s) { + return _hasDom ? document.createTextNode(s) : null; + } + + function createFragment() { + return _hasDom ? document.createDocumentFragment() : null; + } + + function domAppend(parent, child) { + if (parent && child) parent.appendChild(child); + } + + function domPrepend(parent, child) { + if (parent && child) parent.insertBefore(child, parent.firstChild); + } + + function domSetAttr(el, name, val) { + if (el && el.setAttribute) el.setAttribute(name, val); + } + + function domGetAttr(el, name) { + if (!el || !el.getAttribute) return NIL; + var v = el.getAttribute(name); + return v === null ? NIL : v; + } + + function domRemoveAttr(el, name) { + if (el && el.removeAttribute) el.removeAttribute(name); + } + + function domHasAttr(el, name) { + return !!(el && el.hasAttribute && el.hasAttribute(name)); + } + + function domParseHtml(html) { + if (!_hasDom) return null; + var tpl = document.createElement("template"); + tpl.innerHTML = html; + return tpl.content; + } + + function domClone(node) { + return node && node.cloneNode ? node.cloneNode(true) : node; + } + + function domParent(el) { return el ? el.parentNode : null; } + function domId(el) { return el && el.id ? el.id : NIL; } + function domNodeType(el) { return el ? el.nodeType : 0; } + function domNodeName(el) { return el ? el.nodeName : ""; } + function domTextContent(el) { return el ? el.textContent || el.nodeValue || "" : ""; } + function domSetTextContent(el, s) { if (el) { if (el.nodeType === 3 || el.nodeType === 8) el.nodeValue = s; else el.textContent = s; } } + function domIsFragment(el) { return el ? el.nodeType === 11 : false; } + function domIsChildOf(child, parent) { return !!(parent && child && child.parentNode === parent); } + function domIsActiveElement(el) { return _hasDom && el === document.activeElement; } + function domIsInputElement(el) { + if (!el || !el.tagName) return false; + var t = el.tagName; + return t === "INPUT" || t === "TEXTAREA" || t === "SELECT"; + } + function domFirstChild(el) { return el ? el.firstChild : null; } + function domNextSibling(el) { return el ? el.nextSibling : null; } + + function domChildList(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + + function domAttrList(el) { + if (!el || !el.attributes) return []; + var r = []; + for (var i = 0; i < el.attributes.length; i++) { + r.push([el.attributes[i].name, el.attributes[i].value]); + } + return r; + } + + function domInsertBefore(parent, node, ref) { + if (parent && node) parent.insertBefore(node, ref || null); + } + + function domInsertAfter(ref, node) { + if (ref && ref.parentNode && node) { + ref.parentNode.insertBefore(node, ref.nextSibling); + } + } + + function domRemoveChild(parent, child) { + if (parent && child && child.parentNode === parent) parent.removeChild(child); + } + + function domReplaceChild(parent, newChild, oldChild) { + if (parent && newChild && oldChild) parent.replaceChild(newChild, oldChild); + } + + function domSetInnerHtml(el, html) { + if (el) el.innerHTML = html; + } + + function domInsertAdjacentHtml(el, pos, html) { + if (el && el.insertAdjacentHTML) el.insertAdjacentHTML(pos, html); + } + + function domGetStyle(el, prop) { + return el && el.style ? el.style[prop] || "" : ""; + } + + function domSetStyle(el, prop, val) { + if (el && el.style) el.style[prop] = val; + } + + function domGetProp(el, name) { return el ? el[name] : NIL; } + function domSetProp(el, name, val) { if (el) el[name] = val; } + + function domAddClass(el, cls) { + if (el && el.classList) el.classList.add(cls); + } + + function domRemoveClass(el, cls) { + if (el && el.classList) el.classList.remove(cls); + } + + function domDispatch(el, name, detail) { + if (!_hasDom || !el) return false; + var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} }); + return el.dispatchEvent(evt); + } + + function domQuery(sel) { + return _hasDom ? document.querySelector(sel) : null; + } + + function domQueryAll(root, sel) { + if (!root || !root.querySelectorAll) return []; + return Array.prototype.slice.call(root.querySelectorAll(sel)); + } + + function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + + + // ========================================================================= + // Platform interface — Engine (browser-only) + // ========================================================================= + + function browserLocationHref() { + return typeof location !== "undefined" ? location.href : ""; + } + + function browserSameOrigin(url) { + try { return new URL(url, location.href).origin === location.origin; } + catch (e) { return true; } + } + + function browserPushState(url) { + if (typeof history !== "undefined") { + try { history.pushState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserReplaceState(url) { + if (typeof history !== "undefined") { + try { history.replaceState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserNavigate(url) { + if (typeof location !== "undefined") location.assign(url); + } + + function browserReload() { + if (typeof location !== "undefined") location.reload(); + } + + function browserScrollTo(x, y) { + if (typeof window !== "undefined") window.scrollTo(x, y); + } + + function browserMediaMatches(query) { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + } + + function browserConfirm(msg) { + if (typeof window === "undefined") return false; + return window.confirm(msg); + } + + function browserPrompt(msg) { + if (typeof window === "undefined") return NIL; + var r = window.prompt(msg); + return r === null ? NIL : r; + } + + function nowMs() { return Date.now(); } + + function parseHeaderValue(s) { + if (!s) return null; + try { + if (s.charAt(0) === "{" && s.charAt(1) === ":") return parse(s); + return JSON.parse(s); + } catch (e) { return null; } + } + + // ========================================================================= // Post-transpilation fixups // ========================================================================= @@ -896,17 +1636,12 @@ if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; if (typeof aser === "function") PRIMITIVES["aser"] = aser; + if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom; // ========================================================================= - // Parser (reused from reference — hand-written for bootstrap simplicity) + // Parser // ========================================================================= - // The parser is the one piece we keep as hand-written JS since the - // reference parser.sx is more of a spec than directly compilable code - // (it uses mutable cursor state that doesn't map cleanly to the - // transpiler's functional output). A future version could bootstrap - // the parser too. - function parse(text) { var pos = 0; function skipWs() { @@ -1019,32 +1754,23 @@ } function render(source) { + if (!_hasDom) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + } var exprs = parse(source); var frag = document.createDocumentFragment(); - for (var i = 0; i < exprs.length; i++) { - var result = trampoline(evalExpr(exprs[i], merge(componentEnv))); - appendToDOM(frag, result, merge(componentEnv)); - } + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); return frag; } - function appendToDOM(parent, val, env) { - if (isNil(val)) return; - if (typeof val === "string") { parent.appendChild(document.createTextNode(val)); return; } - if (typeof val === "number") { parent.appendChild(document.createTextNode(String(val))); return; } - if (val._raw) { var t = document.createElement("template"); t.innerHTML = val.html; parent.appendChild(t.content); return; } - if (Array.isArray(val)) { - // Could be a rendered element or a list of results - if (val.length > 0 && isSym(val[0])) { - // It's an unevaluated expression — evaluate it - var result = trampoline(evalExpr(val, env)); - appendToDOM(parent, result, env); - } else { - for (var i = 0; i < val.length; i++) appendToDOM(parent, val[i], env); - } - return; - } - parent.appendChild(document.createTextNode(String(val))); + function renderToString(source) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); } var SxRef = { @@ -1052,15 +1778,23 @@ eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, loadComponents: loadComponents, render: render, + renderToString: renderToString, serialize: serialize, NIL: NIL, Symbol: Symbol, Keyword: Keyword, componentEnv: componentEnv, - _version: "ref-1.0 (bootstrap-compiled)" + renderToHtml: function(expr, env) { return renderToHtml(expr, env || merge(componentEnv)); }, + renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); }, + renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null, + parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null, + morphNode: typeof morphNode === "function" ? morphNode : null, + morphChildren: typeof morphChildren === "function" ? morphChildren : null, + swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + _version: "ref-2.0 (dom+engine+html+sx, bootstrap-compiled)" }; if (typeof module !== "undefined" && module.exports) module.exports = SxRef; else global.SxRef = SxRef; -})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx new file mode 100644 index 0000000..e3f41cc --- /dev/null +++ b/shared/sx/ref/adapter-dom.sx @@ -0,0 +1,469 @@ +;; ========================================================================== +;; adapter-dom.sx — DOM rendering adapter +;; +;; Renders SX expressions to live DOM nodes. Browser-only. +;; Mirrors the render-to-html adapter but produces Element/Text/Fragment +;; nodes instead of HTML strings. +;; +;; Depends on: +;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, definition-form? +;; eval.sx — eval-expr, trampoline, call-component, expand-macro +;; ========================================================================== + +(define SVG_NS "http://www.w3.org/2000/svg") +(define MATH_NS "http://www.w3.org/1998/Math/MathML") + + +;; -------------------------------------------------------------------------- +;; render-to-dom — main entry point +;; -------------------------------------------------------------------------- + +(define render-to-dom + (fn (expr env ns) + (case (type-of expr) + ;; nil / boolean false / boolean true → empty fragment + "nil" (create-fragment) + "boolean" (create-fragment) + + ;; Pre-rendered raw HTML → parse into fragment + "raw-html" (dom-parse-html (raw-html-content expr)) + + ;; String → text node + "string" (create-text-node expr) + + ;; Number → text node + "number" (create-text-node (str expr)) + + ;; Symbol → evaluate then render + "symbol" (render-to-dom (trampoline (eval-expr expr env)) env ns) + + ;; Keyword → text + "keyword" (create-text-node (keyword-name expr)) + + ;; Pre-rendered DOM node → pass through + "dom-node" expr + + ;; Dict → empty + "dict" (create-fragment) + + ;; List → dispatch + "list" + (if (empty? expr) + (create-fragment) + (render-dom-list expr env ns)) + + ;; Style value → text of class name + "style-value" (create-text-node (style-value-class expr)) + + ;; Fallback + :else (create-text-node (str expr))))) + + +;; -------------------------------------------------------------------------- +;; render-dom-list — dispatch on list head +;; -------------------------------------------------------------------------- + +(define render-dom-list + (fn (expr env ns) + (let ((head (first expr))) + (cond + ;; Symbol head — dispatch on name + (= (type-of head) "symbol") + (let ((name (symbol-name head)) + (args (rest expr))) + (cond + ;; raw! → insert unescaped HTML + (= name "raw!") + (render-dom-raw args env) + + ;; <> → fragment + (= name "<>") + (render-dom-fragment args env ns) + + ;; html: prefix → force element rendering + (starts-with? name "html:") + (render-dom-element (slice name 5) args env ns) + + ;; Render-aware special forms + (render-dom-form? name) + (if (and (contains? HTML_TAGS name) + (or (and (> (len args) 0) + (= (type-of (first args)) "keyword")) + ns)) + ;; Ambiguous: tag name that's also a form — treat as tag + ;; when keyword arg or namespace present + (render-dom-element name args env ns) + (dispatch-render-form name expr env ns)) + + ;; Macro expansion + (and (env-has? env name) (macro? (env-get env name))) + (render-to-dom + (expand-macro (env-get env name) args env) + env ns) + + ;; HTML tag + (contains? HTML_TAGS name) + (render-dom-element name args env ns) + + ;; Component (~name) + (starts-with? name "~") + (let ((comp (env-get env name))) + (if (component? comp) + (render-dom-component comp args env ns) + (render-dom-unknown-component name))) + + ;; Custom element (hyphenated with keyword attrs) + (and (> (index-of name "-") 0) + (> (len args) 0) + (= (type-of (first args)) "keyword")) + (render-dom-element name args env ns) + + ;; Inside SVG/MathML namespace — treat as element + ns + (render-dom-element name args env ns) + + ;; Fallback — evaluate then render + :else + (render-to-dom (trampoline (eval-expr expr env)) env ns))) + + ;; Lambda or list head → evaluate + (or (lambda? head) (= (type-of head) "list")) + (render-to-dom (trampoline (eval-expr expr env)) env ns) + + ;; Data list + :else + (let ((frag (create-fragment))) + (for-each (fn (x) (dom-append frag (render-to-dom x env ns))) expr) + frag))))) + + +;; -------------------------------------------------------------------------- +;; render-dom-element — create a DOM element with attrs and children +;; -------------------------------------------------------------------------- + +(define render-dom-element + (fn (tag args env ns) + ;; Detect namespace from tag + (let ((new-ns (cond (= tag "svg") SVG_NS + (= tag "math") MATH_NS + :else ns)) + (el (dom-create-element tag new-ns)) + (extra-class nil)) + + ;; Process args: keywords → attrs, others → children + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + ;; Keyword arg → attribute + (let ((attr-name (keyword-name arg)) + (attr-val (trampoline + (eval-expr + (nth args (inc (get state "i"))) + env)))) + (cond + ;; nil or false → skip + (or (nil? attr-val) (= attr-val false)) + nil + ;; :style StyleValue → convert to class + (and (= attr-name "style") (style-value? attr-val)) + (set! extra-class (style-value-class attr-val)) + ;; Boolean attr + (contains? BOOLEAN_ATTRS attr-name) + (when attr-val (dom-set-attr el attr-name "")) + ;; true → empty attr + (= attr-val true) + (dom-set-attr el attr-name "") + ;; Normal attr + :else + (dom-set-attr el attr-name (str attr-val))) + (assoc state "skip" true "i" (inc (get state "i")))) + + ;; Positional arg → child + (do + (when (not (contains? VOID_ELEMENTS tag)) + (dom-append el (render-to-dom arg env new-ns))) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + + ;; Merge StyleValue class + (when extra-class + (let ((existing (dom-get-attr el "class"))) + (dom-set-attr el "class" + (if existing (str existing " " extra-class) extra-class)))) + + el))) + + +;; -------------------------------------------------------------------------- +;; render-dom-component — expand and render a component +;; -------------------------------------------------------------------------- + +(define render-dom-component + (fn (comp args env ns) + ;; Parse kwargs and children, bind into component env, render body. + (let ((kwargs (dict)) + (children (list))) + ;; Separate keyword args from positional children + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + ;; Keyword arg — evaluate in caller's env + (let ((val (trampoline + (eval-expr (nth args (inc (get state "i"))) env)))) + (dict-set! kwargs (keyword-name arg) val) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + + ;; Build component env: closure + caller env + params + (let ((local (env-merge (component-closure comp) env))) + ;; Bind params from kwargs + (for-each + (fn (p) + (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + + ;; If component accepts children, pre-render them to a fragment + (when (component-has-children? comp) + (let ((child-frag (create-fragment))) + (for-each + (fn (c) (dom-append child-frag (render-to-dom c env ns))) + children) + (env-set! local "children" child-frag))) + + (render-to-dom (component-body comp) local ns))))) + + +;; -------------------------------------------------------------------------- +;; render-dom-fragment — render children into a DocumentFragment +;; -------------------------------------------------------------------------- + +(define render-dom-fragment + (fn (args env ns) + (let ((frag (create-fragment))) + (for-each + (fn (x) (dom-append frag (render-to-dom x env ns))) + args) + frag))) + + +;; -------------------------------------------------------------------------- +;; render-dom-raw — insert unescaped content +;; -------------------------------------------------------------------------- + +(define render-dom-raw + (fn (args env) + (let ((frag (create-fragment))) + (for-each + (fn (arg) + (let ((val (trampoline (eval-expr arg env)))) + (cond + (= (type-of val) "string") + (dom-append frag (dom-parse-html val)) + (= (type-of val) "dom-node") + (dom-append frag (dom-clone val)) + (not (nil? val)) + (dom-append frag (create-text-node (str val)))))) + args) + frag))) + + +;; -------------------------------------------------------------------------- +;; render-dom-unknown-component — visible warning element +;; -------------------------------------------------------------------------- + +(define render-dom-unknown-component + (fn (name) + (let ((el (dom-create-element "div" nil))) + (dom-set-attr el "style" + "background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace") + (dom-append el (create-text-node (str "Unknown component: " name))) + el))) + + +;; -------------------------------------------------------------------------- +;; Render-aware special forms for DOM output +;; -------------------------------------------------------------------------- +;; These forms need special handling in DOM rendering because they +;; produce DOM nodes rather than evaluated values. + +(define RENDER_DOM_FORMS + (list "if" "when" "cond" "case" "let" "let*" "begin" "do" + "define" "defcomp" "defmacro" "defstyle" "defkeyframes" "defhandler" + "map" "map-indexed" "filter" "for-each")) + +(define render-dom-form? + (fn (name) + (contains? RENDER_DOM_FORMS name))) + +(define dispatch-render-form + (fn (name expr env ns) + (cond + ;; if + (= name "if") + (let ((cond-val (trampoline (eval-expr (nth expr 1) env)))) + (if cond-val + (render-to-dom (nth expr 2) env ns) + (if (> (len expr) 3) + (render-to-dom (nth expr 3) env ns) + (create-fragment)))) + + ;; when + (= name "when") + (if (not (trampoline (eval-expr (nth expr 1) env))) + (create-fragment) + (let ((frag (create-fragment))) + (for-each + (fn (i) + (dom-append frag (render-to-dom (nth expr i) env ns))) + (range 2 (len expr))) + frag)) + + ;; cond + (= name "cond") + (let ((branch (eval-cond (rest expr) env))) + (if branch + (render-to-dom branch env ns) + (create-fragment))) + + ;; case + (= name "case") + (render-to-dom (trampoline (eval-expr expr env)) env ns) + + ;; let / let* + (or (= name "let") (= name "let*")) + (let ((local (process-bindings (nth expr 1) env)) + (frag (create-fragment))) + (for-each + (fn (i) + (dom-append frag (render-to-dom (nth expr i) local ns))) + (range 2 (len expr))) + frag) + + ;; begin / do + (or (= name "begin") (= name "do")) + (let ((frag (create-fragment))) + (for-each + (fn (i) + (dom-append frag (render-to-dom (nth expr i) env ns))) + (range 1 (len expr))) + frag) + + ;; Definition forms — eval for side effects + (definition-form? name) + (do (trampoline (eval-expr expr env)) (create-fragment)) + + ;; map + (= name "map") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env))) + (frag (create-fragment))) + (for-each + (fn (item) + (let ((val (if (lambda? f) + (render-lambda-dom f (list item) env ns) + (render-to-dom (apply f (list item)) env ns)))) + (dom-append frag val))) + coll) + frag) + + ;; map-indexed + (= name "map-indexed") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env))) + (frag (create-fragment))) + (for-each-indexed + (fn (i item) + (let ((val (if (lambda? f) + (render-lambda-dom f (list i item) env ns) + (render-to-dom (apply f (list i item)) env ns)))) + (dom-append frag val))) + coll) + frag) + + ;; filter — evaluate fully then render + (= name "filter") + (render-to-dom (trampoline (eval-expr expr env)) env ns) + + ;; for-each (render variant) + (= name "for-each") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env))) + (frag (create-fragment))) + (for-each + (fn (item) + (let ((val (if (lambda? f) + (render-lambda-dom f (list item) env ns) + (render-to-dom (apply f (list item)) env ns)))) + (dom-append frag val))) + coll) + frag) + + ;; Fallback + :else + (render-to-dom (trampoline (eval-expr expr env)) env ns)))) + + +;; -------------------------------------------------------------------------- +;; render-lambda-dom — render a lambda body in DOM context +;; -------------------------------------------------------------------------- + +(define render-lambda-dom + (fn (f args env ns) + ;; Bind lambda params and render body as DOM + (let ((local (env-merge (lambda-closure f) env))) + (for-each-indexed + (fn (i p) + (env-set! local p (nth args i))) + (lambda-params f)) + (render-to-dom (lambda-body f) local ns)))) + + +;; -------------------------------------------------------------------------- +;; Platform interface — DOM adapter +;; -------------------------------------------------------------------------- +;; +;; Element creation: +;; (dom-create-element tag ns) → Element (ns=nil for HTML, string for SVG/MathML) +;; (create-text-node s) → Text node +;; (create-fragment) → DocumentFragment +;; +;; Tree mutation: +;; (dom-append parent child) → void (appendChild) +;; (dom-set-attr el name val) → void (setAttribute) +;; (dom-get-attr el name) → string or nil (getAttribute) +;; +;; Content parsing: +;; (dom-parse-html s) → DocumentFragment from HTML string +;; (dom-clone node) → deep clone of a DOM node +;; +;; Type checking: +;; DOM nodes have type-of → "dom-node" +;; +;; From render.sx: +;; HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, definition-form? +;; style-value?, style-value-class +;; +;; From eval.sx: +;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond +;; env-has?, env-get, env-set!, env-merge +;; lambda?, component?, macro? +;; lambda-closure, lambda-params, lambda-body +;; component-params, component-body, component-closure, +;; component-has-children?, component-name +;; +;; Iteration: +;; (for-each-indexed fn coll) → call fn(index, item) for each element +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-html.sx b/shared/sx/ref/adapter-html.sx new file mode 100644 index 0000000..cf9e702 --- /dev/null +++ b/shared/sx/ref/adapter-html.sx @@ -0,0 +1,312 @@ +;; ========================================================================== +;; adapter-html.sx — HTML string rendering adapter +;; +;; Renders evaluated SX expressions to HTML strings. Used server-side. +;; +;; Depends on: +;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, +;; parse-element-args, render-attrs, definition-form? +;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings, +;; eval-cond, env-has?, env-get, env-set!, env-merge, +;; lambda?, component?, macro?, +;; lambda-closure, lambda-params, lambda-body +;; ========================================================================== + + +(define render-to-html + (fn (expr env) + (case (type-of expr) + ;; Literals — render directly + "nil" "" + "string" (escape-html expr) + "number" (str expr) + "boolean" (if expr "true" "false") + ;; List — dispatch to render-list which handles HTML tags, special forms, etc. + "list" (if (empty? expr) "" (render-list-to-html expr env)) + ;; Symbol — evaluate then render + "symbol" (render-value-to-html (trampoline (eval-expr expr env)) env) + ;; Keyword — render as text + "keyword" (escape-html (keyword-name expr)) + ;; Raw HTML passthrough + "raw-html" (raw-html-content expr) + ;; Everything else — evaluate first + :else (render-value-to-html (trampoline (eval-expr expr env)) env)))) + +(define render-value-to-html + (fn (val env) + (case (type-of val) + "nil" "" + "string" (escape-html val) + "number" (str val) + "boolean" (if val "true" "false") + "list" (render-list-to-html val env) + "raw-html" (raw-html-content val) + "style-value" (style-value-class val) + :else (escape-html (str val))))) + + +;; -------------------------------------------------------------------------- +;; Render-aware form classification +;; -------------------------------------------------------------------------- + +(define RENDER_HTML_FORMS + (list "if" "when" "cond" "case" "let" "let*" "begin" "do" + "define" "defcomp" "defmacro" "defstyle" "defkeyframes" "defhandler" + "map" "map-indexed" "filter" "for-each")) + +(define render-html-form? + (fn (name) + (contains? RENDER_HTML_FORMS name))) + + +;; -------------------------------------------------------------------------- +;; render-list-to-html — dispatch on list head +;; -------------------------------------------------------------------------- + +(define render-list-to-html + (fn (expr env) + (if (empty? expr) + "" + (let ((head (first expr))) + (if (not (= (type-of head) "symbol")) + ;; Data list — render each item + (join "" (map (fn (x) (render-value-to-html x env)) expr)) + (let ((name (symbol-name head)) + (args (rest expr))) + (cond + ;; Fragment + (= name "<>") + (join "" (map (fn (x) (render-to-html x env)) args)) + + ;; Raw HTML passthrough + (= name "raw!") + (join "" (map (fn (x) (str (trampoline (eval-expr x env)))) args)) + + ;; HTML tag + (contains? HTML_TAGS name) + (render-html-element name args env) + + ;; Component or macro call (~name) + (starts-with? name "~") + (let ((val (env-get env name))) + (cond + (component? val) + (render-html-component val args env) + (macro? val) + (render-to-html + (expand-macro val args env) + env) + :else + (error (str "Unknown component: " name)))) + + ;; Render-aware special forms + (render-html-form? name) + (dispatch-html-form name expr env) + + ;; Macro expansion + (and (env-has? env name) (macro? (env-get env name))) + (render-to-html + (expand-macro (env-get env name) args env) + env) + + ;; Fallback — evaluate then render result + :else + (render-value-to-html + (trampoline (eval-expr expr env)) + env)))))))) + + +;; -------------------------------------------------------------------------- +;; dispatch-html-form — render-aware special form handling for HTML output +;; -------------------------------------------------------------------------- + +(define dispatch-html-form + (fn (name expr env) + (cond + ;; if + (= name "if") + (let ((cond-val (trampoline (eval-expr (nth expr 1) env)))) + (if cond-val + (render-to-html (nth expr 2) env) + (if (> (len expr) 3) + (render-to-html (nth expr 3) env) + ""))) + + ;; when + (= name "when") + (if (not (trampoline (eval-expr (nth expr 1) env))) + "" + (join "" + (map + (fn (i) (render-to-html (nth expr i) env)) + (range 2 (len expr))))) + + ;; cond + (= name "cond") + (let ((branch (eval-cond (rest expr) env))) + (if branch + (render-to-html branch env) + "")) + + ;; case + (= name "case") + (render-to-html (trampoline (eval-expr expr env)) env) + + ;; let / let* + (or (= name "let") (= name "let*")) + (let ((local (process-bindings (nth expr 1) env))) + (join "" + (map + (fn (i) (render-to-html (nth expr i) local)) + (range 2 (len expr))))) + + ;; begin / do + (or (= name "begin") (= name "do")) + (join "" + (map + (fn (i) (render-to-html (nth expr i) env)) + (range 1 (len expr)))) + + ;; Definition forms — eval for side effects + (definition-form? name) + (do (trampoline (eval-expr expr env)) "") + + ;; map + (= name "map") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env)))) + (join "" + (map + (fn (item) + (if (lambda? f) + (render-lambda-html f (list item) env) + (render-to-html (apply f (list item)) env))) + coll))) + + ;; map-indexed + (= name "map-indexed") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env)))) + (join "" + (map-indexed + (fn (i item) + (if (lambda? f) + (render-lambda-html f (list i item) env) + (render-to-html (apply f (list i item)) env))) + coll))) + + ;; filter — evaluate fully then render + (= name "filter") + (render-to-html (trampoline (eval-expr expr env)) env) + + ;; for-each (render variant) + (= name "for-each") + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env)))) + (join "" + (map + (fn (item) + (if (lambda? f) + (render-lambda-html f (list item) env) + (render-to-html (apply f (list item)) env))) + coll))) + + ;; Fallback + :else + (render-value-to-html (trampoline (eval-expr expr env)) env)))) + + +;; -------------------------------------------------------------------------- +;; render-lambda-html — render a lambda body in HTML context +;; -------------------------------------------------------------------------- + +(define render-lambda-html + (fn (f args env) + (let ((local (env-merge (lambda-closure f) env))) + (for-each-indexed + (fn (i p) + (env-set! local p (nth args i))) + (lambda-params f)) + (render-to-html (lambda-body f) local)))) + + +;; -------------------------------------------------------------------------- +;; render-html-component — expand and render a component +;; -------------------------------------------------------------------------- + +(define render-html-component + (fn (comp args env) + ;; Expand component and render body through HTML adapter. + ;; Component body contains rendering forms (HTML tags) that only the + ;; adapter understands, so expansion must happen here, not in eval-expr. + (let ((kwargs (dict)) + (children (list))) + ;; Separate keyword args from positional children + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (trampoline + (eval-expr (nth args (inc (get state "i"))) env)))) + (dict-set! kwargs (keyword-name arg) val) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + ;; Build component env: closure + caller env + params + (let ((local (env-merge (component-closure comp) env))) + ;; Bind params from kwargs + (for-each + (fn (p) + (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + ;; If component accepts children, pre-render them to raw HTML + (when (component-has-children? comp) + (env-set! local "children" + (make-raw-html + (join "" (map (fn (c) (render-to-html c env)) children))))) + (render-to-html (component-body comp) local))))) + + +(define render-html-element + (fn (tag args env) + (let ((parsed (parse-element-args args env)) + (attrs (first parsed)) + (children (nth parsed 1)) + (is-void (contains? VOID_ELEMENTS tag))) + (str "<" tag + (render-attrs attrs) + (if is-void + " />" + (str ">" + (join "" (map (fn (c) (render-to-html c env)) children)) + "")))))) + + +;; -------------------------------------------------------------------------- +;; Platform interface — HTML adapter +;; -------------------------------------------------------------------------- +;; +;; Inherited from render.sx: +;; escape-html, escape-attr, raw-html-content, style-value?, style-value-class +;; +;; From eval.sx: +;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond +;; env-has?, env-get, env-set!, env-merge +;; lambda?, component?, macro? +;; lambda-closure, lambda-params, lambda-body +;; component-params, component-body, component-closure, +;; component-has-children?, component-name +;; +;; Raw HTML construction: +;; (make-raw-html s) → wrap string as raw HTML (not double-escaped) +;; +;; Iteration: +;; (for-each-indexed fn coll) → call fn(index, item) for each element +;; (map-indexed fn coll) → map fn(index, item) over each element +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx new file mode 100644 index 0000000..b84106c --- /dev/null +++ b/shared/sx/ref/adapter-sx.sx @@ -0,0 +1,147 @@ +;; ========================================================================== +;; adapter-sx.sx — SX wire format rendering adapter +;; +;; Serializes SX expressions for client-side rendering. +;; Component calls are NOT expanded — they're sent to the client as-is. +;; HTML tags are serialized as SX source text. Special forms are evaluated. +;; +;; Depends on: +;; render.sx — HTML_TAGS +;; eval.sx — eval-expr, trampoline, call-lambda, expand-macro +;; ========================================================================== + + +(define render-to-sx + (fn (expr env) + (let ((result (aser expr env))) + ;; aser-call already returns serialized SX strings; + ;; only serialize non-string values + (if (= (type-of result) "string") + result + (serialize result))))) + +(define aser + (fn (expr env) + ;; Evaluate for SX wire format — serialize rendering forms, + ;; evaluate control flow and function calls. + (case (type-of expr) + "number" expr + "string" expr + "boolean" expr + "nil" nil + + "symbol" + (let ((name (symbol-name expr))) + (cond + (env-has? env name) (env-get env name) + (primitive? name) (get-primitive name) + (= name "true") true + (= name "false") false + (= name "nil") nil + :else (error (str "Undefined symbol: " name)))) + + "keyword" (keyword-name expr) + + "list" + (if (empty? expr) + (list) + (aser-list expr env)) + + :else expr))) + + +(define aser-list + (fn (expr env) + (let ((head (first expr)) + (args (rest expr))) + (if (not (= (type-of head) "symbol")) + (map (fn (x) (aser x env)) expr) + (let ((name (symbol-name head))) + (cond + ;; Fragment — serialize children + (= name "<>") + (aser-fragment args env) + + ;; Component call — serialize WITHOUT expanding + (starts-with? name "~") + (aser-call name args env) + + ;; HTML tag — serialize + (contains? HTML_TAGS name) + (aser-call name args env) + + ;; Special/HO forms — evaluate (produces data) + (or (special-form? name) (ho-form? name)) + (aser-special name expr env) + + ;; Macro — expand then aser + (and (env-has? env name) (macro? (env-get env name))) + (aser (expand-macro (env-get env name) args env) env) + + ;; Function call — evaluate fully + :else + (let ((f (trampoline (eval-expr head env))) + (evaled-args (map (fn (a) (trampoline (eval-expr a env))) args))) + (cond + (and (callable? f) (not (lambda? f)) (not (component? f))) + (apply f evaled-args) + (lambda? f) + (trampoline (call-lambda f evaled-args env)) + (component? f) + (aser-call (str "~" (component-name f)) args env) + :else (error (str "Not callable: " (inspect f))))))))))) + + +(define aser-fragment + (fn (children env) + ;; Serialize (<> child1 child2 ...) to sx source string + (let ((parts (filter + (fn (x) (not (nil? x))) + (map (fn (c) (aser c env)) children)))) + (if (empty? parts) + "" + (str "(<> " (join " " (map serialize parts)) ")"))))) + + +(define aser-call + (fn (name args env) + ;; Serialize (name :key val child ...) — evaluate args but keep as sx + (let ((parts (list name))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (aser (nth args (inc (get state "i"))) env))) + (when (not (nil? val)) + (append! parts (str ":" (keyword-name arg))) + (append! parts (serialize val))) + (assoc state "skip" true "i" (inc (get state "i")))) + (let ((val (aser arg env))) + (when (not (nil? val)) + (append! parts (serialize val))) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + (str "(" (join " " parts) ")")))) + + +;; -------------------------------------------------------------------------- +;; Platform interface — SX wire adapter +;; -------------------------------------------------------------------------- +;; +;; Serialization: +;; (serialize val) → SX source string representation of val +;; +;; Form classification: +;; (special-form? name) → boolean +;; (ho-form? name) → boolean +;; (aser-special name expr env) → evaluate special/HO form through aser +;; +;; From eval.sx: +;; eval-expr, trampoline, call-lambda, expand-macro +;; env-has?, env-get, callable?, lambda?, component?, macro? +;; primitive?, get-primitive, component-name +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index bb53b8a..5c9ac5f 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -146,6 +146,7 @@ class JSEmitter: "render-value-to-html": "renderValueToHtml", "render-list-to-html": "renderListToHtml", "render-html-element": "renderHtmlElement", + "render-html-component": "renderHtmlComponent", "parse-element-args": "parseElementArgs", "render-attrs": "renderAttrs", "aser-list": "aserList", @@ -195,6 +196,120 @@ class JSEmitter: "HTML_TAGS": "HTML_TAGS", "VOID_ELEMENTS": "VOID_ELEMENTS", "BOOLEAN_ATTRS": "BOOLEAN_ATTRS", + # render.sx core + "definition-form?": "isDefinitionForm", + # adapter-html.sx + "RENDER_HTML_FORMS": "RENDER_HTML_FORMS", + "render-html-form?": "isRenderHtmlForm", + "dispatch-html-form": "dispatchHtmlForm", + "render-lambda-html": "renderLambdaHtml", + "make-raw-html": "makeRawHtml", + # adapter-dom.sx + "SVG_NS": "SVG_NS", + "MATH_NS": "MATH_NS", + "render-to-dom": "renderToDom", + "render-dom-list": "renderDomList", + "render-dom-element": "renderDomElement", + "render-dom-component": "renderDomComponent", + "render-dom-fragment": "renderDomFragment", + "render-dom-raw": "renderDomRaw", + "render-dom-unknown-component": "renderDomUnknownComponent", + "RENDER_DOM_FORMS": "RENDER_DOM_FORMS", + "render-dom-form?": "isRenderDomForm", + "dispatch-render-form": "dispatchRenderForm", + "render-lambda-dom": "renderLambdaDom", + "dom-create-element": "domCreateElement", + "dom-append": "domAppend", + "dom-set-attr": "domSetAttr", + "dom-get-attr": "domGetAttr", + "dom-remove-attr": "domRemoveAttr", + "dom-has-attr?": "domHasAttr", + "dom-parse-html": "domParseHtml", + "dom-clone": "domClone", + "create-text-node": "createTextNode", + "create-fragment": "createFragment", + "dom-parent": "domParent", + "dom-id": "domId", + "dom-node-type": "domNodeType", + "dom-node-name": "domNodeName", + "dom-text-content": "domTextContent", + "dom-set-text-content": "domSetTextContent", + "dom-is-fragment?": "domIsFragment", + "dom-is-child-of?": "domIsChildOf", + "dom-is-active-element?": "domIsActiveElement", + "dom-is-input-element?": "domIsInputElement", + "dom-first-child": "domFirstChild", + "dom-next-sibling": "domNextSibling", + "dom-child-list": "domChildList", + "dom-attr-list": "domAttrList", + "dom-insert-before": "domInsertBefore", + "dom-insert-after": "domInsertAfter", + "dom-prepend": "domPrepend", + "dom-remove-child": "domRemoveChild", + "dom-replace-child": "domReplaceChild", + "dom-set-inner-html": "domSetInnerHtml", + "dom-insert-adjacent-html": "domInsertAdjacentHtml", + "dom-get-style": "domGetStyle", + "dom-set-style": "domSetStyle", + "dom-get-prop": "domGetProp", + "dom-set-prop": "domSetProp", + "dom-add-class": "domAddClass", + "dom-remove-class": "domRemoveClass", + "dom-dispatch": "domDispatch", + "dom-query": "domQuery", + "dom-query-all": "domQueryAll", + "dom-tag-name": "domTagName", + "dict-has?": "dictHas", + "dict-delete!": "dictDelete", + "process-bindings": "processBindings", + "eval-cond": "evalCond", + "for-each-indexed": "forEachIndexed", + "index-of": "indexOf_", + "component-has-children?": "componentHasChildren", + # engine.sx + "ENGINE_VERBS": "ENGINE_VERBS", + "DEFAULT_SWAP": "DEFAULT_SWAP", + "parse-time": "parseTime", + "parse-trigger-spec": "parseTriggerSpec", + "default-trigger": "defaultTrigger", + "get-verb-info": "getVerbInfo", + "build-request-headers": "buildRequestHeaders", + "process-response-headers": "processResponseHeaders", + "parse-swap-spec": "parseSwapSpec", + "parse-retry-spec": "parseRetrySpec", + "next-retry-ms": "nextRetryMs", + "filter-params": "filterParams", + "resolve-target": "resolveTarget", + "apply-optimistic": "applyOptimistic", + "revert-optimistic": "revertOptimistic", + "find-oob-swaps": "findOobSwaps", + "morph-node": "morphNode", + "sync-attrs": "syncAttrs", + "morph-children": "morphChildren", + "swap-dom-nodes": "swapDomNodes", + "insert-remaining-siblings": "insertRemainingSiblings", + "swap-html-string": "swapHtmlString", + "handle-history": "handleHistory", + "PRELOAD_TTL": "PRELOAD_TTL", + "preload-cache-get": "preloadCacheGet", + "preload-cache-set": "preloadCacheSet", + "classify-trigger": "classifyTrigger", + "should-boost-link?": "shouldBoostLink", + "should-boost-form?": "shouldBoostForm", + "parse-sse-swap": "parseSseSwap", + "browser-location-href": "browserLocationHref", + "browser-same-origin?": "browserSameOrigin", + "browser-push-state": "browserPushState", + "browser-replace-state": "browserReplaceState", + "browser-navigate": "browserNavigate", + "browser-reload": "browserReload", + "browser-scroll-to": "browserScrollTo", + "browser-media-matches?": "browserMediaMatches", + "browser-confirm": "browserConfirm", + "browser-prompt": "browserPrompt", + "now-ms": "nowMs", + "parse-header-value": "parseHeaderValue", + "replace": "replace_", "whitespace?": "isWhitespace", "digit?": "isDigit", "ident-start?": "isIdentStart", @@ -503,36 +618,92 @@ def extract_defines(source: str) -> list[tuple[str, list]]: return defines -def compile_ref_to_js() -> str: - """Read reference .sx files and emit JavaScript.""" +ADAPTER_FILES = { + "html": ("adapter-html.sx", "adapter-html"), + "sx": ("adapter-sx.sx", "adapter-sx"), + "dom": ("adapter-dom.sx", "adapter-dom"), + "engine": ("engine.sx", "engine"), +} + +# Dependencies: engine requires dom +ADAPTER_DEPS = {"engine": ["dom"]} + + +def compile_ref_to_js(adapters: list[str] | None = None) -> str: + """Read reference .sx files and emit JavaScript. + + Args: + adapters: List of adapter names to include. + Valid names: html, sx, dom, engine. + None = include all adapters. + """ ref_dir = os.path.dirname(os.path.abspath(__file__)) emitter = JSEmitter() - # Read reference files - with open(os.path.join(ref_dir, "eval.sx")) as f: - eval_src = f.read() - with open(os.path.join(ref_dir, "render.sx")) as f: - render_src = f.read() + # Platform JS blocks keyed by adapter name + adapter_platform = { + "dom": PLATFORM_DOM_JS, + "engine": PLATFORM_ENGINE_JS, + } - eval_defines = extract_defines(eval_src) - render_defines = extract_defines(render_src) + # Resolve adapter set + if adapters is None: + adapter_set = set(ADAPTER_FILES.keys()) + else: + adapter_set = set() + for a in adapters: + if a not in ADAPTER_FILES: + raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}") + adapter_set.add(a) + # Pull in dependencies + for dep in ADAPTER_DEPS.get(a, []): + adapter_set.add(dep) + + # Core files always included, then selected adapters + sx_files = [ + ("eval.sx", "eval"), + ("render.sx", "render (core)"), + ] + for name in ("html", "sx", "dom", "engine"): + if name in adapter_set: + sx_files.append(ADAPTER_FILES[name]) + + all_sections = [] + for filename, label in sx_files: + filepath = os.path.join(ref_dir, filename) + if not os.path.exists(filepath): + continue + with open(filepath) as f: + src = f.read() + defines = extract_defines(src) + all_sections.append((label, defines)) # Build output + has_html = "html" in adapter_set + has_sx = "sx" in adapter_set + has_dom = "dom" in adapter_set + has_engine = "engine" in adapter_set + adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only" + parts = [] parts.append(PREAMBLE) parts.append(PLATFORM_JS) - parts.append("\n // === Transpiled from eval.sx ===\n") - for name, expr in eval_defines: - parts.append(f" // {name}") - parts.append(f" {emitter.emit_statement(expr)}") - parts.append("") - parts.append("\n // === Transpiled from render.sx ===\n") - for name, expr in render_defines: - parts.append(f" // {name}") - parts.append(f" {emitter.emit_statement(expr)}") - parts.append("") - parts.append(FIXUPS) - parts.append(PUBLIC_API) + for label, defines in all_sections: + parts.append(f"\n // === Transpiled from {label} ===\n") + for name, expr in defines: + parts.append(f" // {name}") + parts.append(f" {emitter.emit_statement(expr)}") + parts.append("") + + # Platform JS for selected adapters + if not has_dom: + parts.append("\n var _hasDom = false;\n") + for name in ("dom", "engine"): + if name in adapter_set and name in adapter_platform: + parts.append(adapter_platform[name]) + + parts.append(fixups_js(has_html, has_sx, has_dom)) + parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, adapter_label)) parts.append(EPILOGUE) return "\n".join(parts) @@ -950,12 +1121,23 @@ PLATFORM_JS = ''' function append_b(arr, x) { arr.push(x); return arr; } var apply = function(f, args) { return f.apply(null, args); }; + // Additional primitive aliases used by adapter/engine transpiled code + var split = PRIMITIVES["split"]; + var trim = PRIMITIVES["trim"]; + var upper = PRIMITIVES["upper"]; + var lower = PRIMITIVES["lower"]; + var replace_ = function(s, old, nw) { return s.split(old).join(nw); }; + var endsWith = PRIMITIVES["ends-with?"]; + var parseInt_ = PRIMITIVES["parse-int"]; + var dict_fn = PRIMITIVES["dict"]; + // HTML rendering helpers function escapeHtml(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } function escapeAttr(s) { return escapeHtml(s); } function rawHtmlContent(r) { return r.html; } + function makeRawHtml(s) { return { _raw: true, html: s }; } // Serializer function serialize(val) { @@ -977,9 +1159,271 @@ PLATFORM_JS = ''' }; } function isHoForm(n) { return n in { "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 - }; }''' + }; } -FIXUPS = ''' + // processBindings and evalCond — exposed for DOM adapter render forms + function processBindings(bindings, env) { + var local = merge(env); + for (var i = 0; i < bindings.length; i++) { + var pair = bindings[i]; + if (Array.isArray(pair) && pair.length >= 2) { + var name = isSym(pair[0]) ? pair[0].name : String(pair[0]); + local[name] = trampoline(evalExpr(pair[1], local)); + } + } + return local; + } + function evalCond(clauses, env) { + for (var i = 0; i < clauses.length; i += 2) { + var test = clauses[i]; + if (isSym(test) && test.name === ":else") return clauses[i + 1]; + if (isKw(test) && test.name === "else") return clauses[i + 1]; + if (isSxTruthy(trampoline(evalExpr(test, env)))) return clauses[i + 1]; + } + return null; + } + + function isDefinitionForm(name) { + return name === "define" || name === "defcomp" || name === "defmacro" || + name === "defstyle" || name === "defkeyframes" || name === "defhandler"; + } + + function indexOf_(s, ch) { + return typeof s === "string" ? s.indexOf(ch) : -1; + } + + function dictHas(d, k) { return d != null && k in d; } + function dictDelete(d, k) { delete d[k]; } + + function forEachIndexed(fn, coll) { + for (var i = 0; i < coll.length; i++) fn(i, coll[i]); + return NIL; + }''' + +PLATFORM_DOM_JS = """ + // ========================================================================= + // Platform interface — DOM adapter (browser-only) + // ========================================================================= + + var _hasDom = typeof document !== "undefined"; + + var SVG_NS = "http://www.w3.org/2000/svg"; + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + function domCreateElement(tag, ns) { + if (!_hasDom) return null; + if (ns) return document.createElementNS(ns, tag); + return document.createElement(tag); + } + + function createTextNode(s) { + return _hasDom ? document.createTextNode(s) : null; + } + + function createFragment() { + return _hasDom ? document.createDocumentFragment() : null; + } + + function domAppend(parent, child) { + if (parent && child) parent.appendChild(child); + } + + function domPrepend(parent, child) { + if (parent && child) parent.insertBefore(child, parent.firstChild); + } + + function domSetAttr(el, name, val) { + if (el && el.setAttribute) el.setAttribute(name, val); + } + + function domGetAttr(el, name) { + if (!el || !el.getAttribute) return NIL; + var v = el.getAttribute(name); + return v === null ? NIL : v; + } + + function domRemoveAttr(el, name) { + if (el && el.removeAttribute) el.removeAttribute(name); + } + + function domHasAttr(el, name) { + return !!(el && el.hasAttribute && el.hasAttribute(name)); + } + + function domParseHtml(html) { + if (!_hasDom) return null; + var tpl = document.createElement("template"); + tpl.innerHTML = html; + return tpl.content; + } + + function domClone(node) { + return node && node.cloneNode ? node.cloneNode(true) : node; + } + + function domParent(el) { return el ? el.parentNode : null; } + function domId(el) { return el && el.id ? el.id : NIL; } + function domNodeType(el) { return el ? el.nodeType : 0; } + function domNodeName(el) { return el ? el.nodeName : ""; } + function domTextContent(el) { return el ? el.textContent || el.nodeValue || "" : ""; } + function domSetTextContent(el, s) { if (el) { if (el.nodeType === 3 || el.nodeType === 8) el.nodeValue = s; else el.textContent = s; } } + function domIsFragment(el) { return el ? el.nodeType === 11 : false; } + function domIsChildOf(child, parent) { return !!(parent && child && child.parentNode === parent); } + function domIsActiveElement(el) { return _hasDom && el === document.activeElement; } + function domIsInputElement(el) { + if (!el || !el.tagName) return false; + var t = el.tagName; + return t === "INPUT" || t === "TEXTAREA" || t === "SELECT"; + } + function domFirstChild(el) { return el ? el.firstChild : null; } + function domNextSibling(el) { return el ? el.nextSibling : null; } + + function domChildList(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + + function domAttrList(el) { + if (!el || !el.attributes) return []; + var r = []; + for (var i = 0; i < el.attributes.length; i++) { + r.push([el.attributes[i].name, el.attributes[i].value]); + } + return r; + } + + function domInsertBefore(parent, node, ref) { + if (parent && node) parent.insertBefore(node, ref || null); + } + + function domInsertAfter(ref, node) { + if (ref && ref.parentNode && node) { + ref.parentNode.insertBefore(node, ref.nextSibling); + } + } + + function domRemoveChild(parent, child) { + if (parent && child && child.parentNode === parent) parent.removeChild(child); + } + + function domReplaceChild(parent, newChild, oldChild) { + if (parent && newChild && oldChild) parent.replaceChild(newChild, oldChild); + } + + function domSetInnerHtml(el, html) { + if (el) el.innerHTML = html; + } + + function domInsertAdjacentHtml(el, pos, html) { + if (el && el.insertAdjacentHTML) el.insertAdjacentHTML(pos, html); + } + + function domGetStyle(el, prop) { + return el && el.style ? el.style[prop] || "" : ""; + } + + function domSetStyle(el, prop, val) { + if (el && el.style) el.style[prop] = val; + } + + function domGetProp(el, name) { return el ? el[name] : NIL; } + function domSetProp(el, name, val) { if (el) el[name] = val; } + + function domAddClass(el, cls) { + if (el && el.classList) el.classList.add(cls); + } + + function domRemoveClass(el, cls) { + if (el && el.classList) el.classList.remove(cls); + } + + function domDispatch(el, name, detail) { + if (!_hasDom || !el) return false; + var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} }); + return el.dispatchEvent(evt); + } + + function domQuery(sel) { + return _hasDom ? document.querySelector(sel) : null; + } + + function domQueryAll(root, sel) { + if (!root || !root.querySelectorAll) return []; + return Array.prototype.slice.call(root.querySelectorAll(sel)); + } + + function domTagName(el) { return el && el.tagName ? el.tagName : ""; } +""" + +PLATFORM_ENGINE_JS = """ + // ========================================================================= + // Platform interface — Engine (browser-only) + // ========================================================================= + + function browserLocationHref() { + return typeof location !== "undefined" ? location.href : ""; + } + + function browserSameOrigin(url) { + try { return new URL(url, location.href).origin === location.origin; } + catch (e) { return true; } + } + + function browserPushState(url) { + if (typeof history !== "undefined") { + try { history.pushState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserReplaceState(url) { + if (typeof history !== "undefined") { + try { history.replaceState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserNavigate(url) { + if (typeof location !== "undefined") location.assign(url); + } + + function browserReload() { + if (typeof location !== "undefined") location.reload(); + } + + function browserScrollTo(x, y) { + if (typeof window !== "undefined") window.scrollTo(x, y); + } + + function browserMediaMatches(query) { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + } + + function browserConfirm(msg) { + if (typeof window === "undefined") return false; + return window.confirm(msg); + } + + function browserPrompt(msg) { + if (typeof window === "undefined") return NIL; + var r = window.prompt(msg); + return r === null ? NIL : r; + } + + function nowMs() { return Date.now(); } + + function parseHeaderValue(s) { + if (!s) return null; + try { + if (s.charAt(0) === "{" && s.charAt(1) === ":") return parse(s); + return JSON.parse(s); + } catch (e) { return null; } + } +""" + +def fixups_js(has_html, has_sx, has_dom): + lines = [''' // ========================================================================= // Post-transpilation fixups // ========================================================================= @@ -991,29 +1435,31 @@ FIXUPS = ''' return _rawCallLambda(f, args, callerEnv); }; - // Expose render functions as primitives so SX code can call them - if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; - if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; - if (typeof aser === "function") PRIMITIVES["aser"] = aser;''' + // Expose render functions as primitives so SX code can call them'''] + if has_html: + lines.append(' if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml;') + if has_sx: + lines.append(' if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx;') + lines.append(' if (typeof aser === "function") PRIMITIVES["aser"] = aser;') + if has_dom: + lines.append(' if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;') + return "\n".join(lines) -PUBLIC_API = ''' - // ========================================================================= - // Parser (reused from reference — hand-written for bootstrap simplicity) - // ========================================================================= - // The parser is the one piece we keep as hand-written JS since the - // reference parser.sx is more of a spec than directly compilable code - // (it uses mutable cursor state that doesn't map cleanly to the - // transpiler's functional output). A future version could bootstrap - // the parser too. +def public_api_js(has_html, has_sx, has_dom, has_engine, adapter_label): + # Parser is always included + parser = r''' + // ========================================================================= + // Parser + // ========================================================================= function parse(text) { var pos = 0; function skipWs() { while (pos < text.length) { var ch = text[pos]; - if (ch === " " || ch === "\\t" || ch === "\\n" || ch === "\\r") { pos++; continue; } - if (ch === ";") { while (pos < text.length && text[pos] !== "\\n") pos++; continue; } + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pos++; continue; } + if (ch === ";") { while (pos < text.length && text[pos] !== "\n") pos++; continue; } break; } } @@ -1024,7 +1470,7 @@ PUBLIC_API = ''' if (ch === "(") { pos++; return readList(")"); } if (ch === "[") { pos++; return readList("]"); } if (ch === "{") { pos++; return readMap(); } - if (ch === \'"\') return readString(); + if (ch === '"') return readString(); if (ch === ":") return readKeyword(); if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } if (ch === ",") { @@ -1061,8 +1507,8 @@ PUBLIC_API = ''' var s = ""; while (pos < text.length) { var ch = text[pos]; - if (ch === \'"\') { pos++; return s; } - if (ch === "\\\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\\n" : esc === "t" ? "\\t" : esc === "r" ? "\\r" : esc; pos++; continue; } + if (ch === '"') { pos++; return s; } + if (ch === "\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\n" : esc === "t" ? "\t" : esc === "r" ? "\r" : esc; pos++; continue; } s += ch; pos++; } throw new Error("Unterminated string"); @@ -1086,7 +1532,7 @@ PUBLIC_API = ''' } function readIdent() { var start = pos; - while (pos < text.length && /[a-zA-Z0-9_~*+\\-><=/!?.:&]/.test(text[pos])) pos++; + while (pos < text.length && /[a-zA-Z0-9_~*+\-><=/!?.:&]/.test(text[pos])) pos++; return text.slice(start, pos); } function readSymbol() { @@ -1103,8 +1549,10 @@ PUBLIC_API = ''' exprs.push(readExpr()); } return exprs; - } + }''' + # Public API — conditional on adapters + api_lines = [parser, ''' // ========================================================================= // Public API // ========================================================================= @@ -1116,52 +1564,92 @@ PUBLIC_API = ''' for (var i = 0; i < exprs.length; i++) { trampoline(evalExpr(exprs[i], componentEnv)); } - } + }'''] + # render() — auto-dispatches based on available adapters + if has_html and has_dom: + api_lines.append(''' + function render(source) { + if (!_hasDom) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + } + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); + return frag; + }''') + elif has_dom: + api_lines.append(''' function render(source) { var exprs = parse(source); var frag = document.createDocumentFragment(); - for (var i = 0; i < exprs.length; i++) { - var result = trampoline(evalExpr(exprs[i], merge(componentEnv))); - appendToDOM(frag, result, merge(componentEnv)); - } + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); return frag; - } + }''') + elif has_html: + api_lines.append(''' + function render(source) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + }''') + else: + api_lines.append(''' + function render(source) { + var exprs = parse(source); + var results = []; + for (var i = 0; i < exprs.length; i++) results.push(trampoline(evalExpr(exprs[i], merge(componentEnv)))); + return results.length === 1 ? results[0] : results; + }''') - function appendToDOM(parent, val, env) { - if (isNil(val)) return; - if (typeof val === "string") { parent.appendChild(document.createTextNode(val)); return; } - if (typeof val === "number") { parent.appendChild(document.createTextNode(String(val))); return; } - if (val._raw) { var t = document.createElement("template"); t.innerHTML = val.html; parent.appendChild(t.content); return; } - if (Array.isArray(val)) { - // Could be a rendered element or a list of results - if (val.length > 0 && isSym(val[0])) { - // It's an unevaluated expression — evaluate it - var result = trampoline(evalExpr(val, env)); - appendToDOM(parent, result, env); - } else { - for (var i = 0; i < val.length; i++) appendToDOM(parent, val[i], env); - } - return; - } - parent.appendChild(document.createTextNode(String(val))); - } + # renderToString helper + if has_html: + api_lines.append(''' + function renderToString(source) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + }''') - var SxRef = { + # Build SxRef object + version = f"ref-2.0 ({adapter_label}, bootstrap-compiled)" + api_lines.append(f''' + var SxRef = {{ parse: parse, - eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, + eval: function(expr, env) {{ return trampoline(evalExpr(expr, env || merge(componentEnv))); }}, loadComponents: loadComponents, - render: render, + render: render,{"" if has_html else ""} + {"renderToString: renderToString," if has_html else ""} serialize: serialize, NIL: NIL, Symbol: Symbol, Keyword: Keyword, - componentEnv: componentEnv, - _version: "ref-1.0 (bootstrap-compiled)" - }; + componentEnv: componentEnv,''') - if (typeof module !== "undefined" && module.exports) module.exports = SxRef; - else global.SxRef = SxRef;''' + if has_html: + api_lines.append(' renderToHtml: function(expr, env) { return renderToHtml(expr, env || merge(componentEnv)); },') + if has_sx: + api_lines.append(' renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); },') + if has_dom: + api_lines.append(' renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,') + if has_engine: + api_lines.append(' parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,') + api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,') + api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,') + api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,') + + api_lines.append(f' _version: "{version}"') + api_lines.append(' };') + api_lines.append('') + api_lines.append(' if (typeof module !== "undefined" && module.exports) module.exports = SxRef;') + api_lines.append(' else global.SxRef = SxRef;') + + return "\n".join(api_lines) EPILOGUE = ''' })(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);''' @@ -1172,4 +1660,22 @@ EPILOGUE = ''' # --------------------------------------------------------------------------- if __name__ == "__main__": - print(compile_ref_to_js()) + import argparse + p = argparse.ArgumentParser(description="Bootstrap-compile SX reference spec to JavaScript") + p.add_argument("--adapters", "-a", + help="Comma-separated adapter list (html,sx,dom,engine). Default: all") + p.add_argument("--output", "-o", + help="Output file (default: stdout)") + args = p.parse_args() + + adapters = args.adapters.split(",") if args.adapters else None + js = compile_ref_to_js(adapters) + + if args.output: + with open(args.output, "w") as f: + f.write(js) + included = ", ".join(adapters) if adapters else "all" + print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included})", + file=sys.stderr) + else: + print(js) diff --git a/shared/sx/ref/engine.sx b/shared/sx/ref/engine.sx new file mode 100644 index 0000000..d4e9a56 --- /dev/null +++ b/shared/sx/ref/engine.sx @@ -0,0 +1,747 @@ +;; ========================================================================== +;; engine.sx — SxEngine specification +;; +;; Fetch/swap/history engine for browser-side SX. Like HTMX but native +;; to the SX rendering pipeline. +;; +;; This file specifies the LOGIC of the engine in s-expressions. +;; Browser-specific APIs (fetch, DOM, history, events) are declared as +;; platform interface at the bottom. +;; +;; The engine processes elements with sx-* attributes: +;; sx-get, sx-post, sx-put, sx-delete, sx-patch — HTTP verb + URL +;; sx-trigger — when to fire (click, submit, change, every 5s, ...) +;; sx-target — where to swap response (#selector, "this", "closest") +;; sx-swap — how to swap (innerHTML, outerHTML, afterend, ...) +;; sx-select — filter response (CSS selector) +;; sx-confirm — confirmation dialog before request +;; sx-prompt — prompt dialog, sends result as SX-Prompt header +;; sx-validate — form validation before request +;; sx-encoding — "json" for JSON body instead of form-encoded +;; sx-params — filter form fields (include, exclude, none) +;; sx-include — include extra inputs from other elements +;; sx-vals — extra key-value pairs to send +;; sx-headers — extra request headers +;; sx-indicator — show/hide loading indicator +;; sx-disabled-elt — disable elements during request +;; sx-push-url — push to browser history +;; sx-replace-url — replace browser history +;; sx-sync — abort previous request ("replace") +;; sx-media — only fire if media query matches +;; sx-preload — preload on mousedown/mouseover +;; sx-boost — auto-boost links and forms in container +;; sx-sse — connect to Server-Sent Events +;; sx-retry — retry on failure (exponential:startMs:capMs) +;; sx-optimistic — optimistic update (remove, disable, add-class:name) +;; sx-preserve — don't morph this element during swap +;; sx-ignore — skip morphing entirely +;; sx-on:* — inline event handlers (beforeRequest, afterSwap, ...) +;; +;; Depends on: +;; adapter-dom.sx — render-to-dom (for SX response rendering) +;; render.sx — shared registries +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Constants +;; -------------------------------------------------------------------------- + +(define ENGINE_VERBS (list "get" "post" "put" "delete" "patch")) +(define DEFAULT_SWAP "outerHTML") + + +;; -------------------------------------------------------------------------- +;; Trigger parsing +;; -------------------------------------------------------------------------- +;; Parses the sx-trigger attribute value into a list of trigger descriptors. +;; Each descriptor is a dict with "event" and "modifiers" keys. + +(define parse-time + (fn (s) + ;; Parse time string: "2s" → 2000, "500ms" → 500 + (cond + (nil? s) 0 + (ends-with? s "ms") (parse-int s 0) + (ends-with? s "s") (* (parse-int (replace s "s" "") 0) 1000) + :else (parse-int s 0)))) + + +(define parse-trigger-spec + (fn (spec) + ;; Parse "click delay:500ms once,change" → list of trigger descriptors + (if (nil? spec) + nil + (let ((raw-parts (split spec ","))) + (filter + (fn (x) (not (nil? x))) + (map + (fn (part) + (let ((tokens (split (trim part) " "))) + (if (empty? tokens) + nil + (if (and (= (first tokens) "every") (>= (len tokens) 2)) + ;; Polling trigger + (dict + "event" "every" + "modifiers" (dict "interval" (parse-time (nth tokens 1)))) + ;; Normal trigger with optional modifiers + (let ((mods (dict))) + (for-each + (fn (tok) + (cond + (= tok "once") + (dict-set! mods "once" true) + (= tok "changed") + (dict-set! mods "changed" true) + (starts-with? tok "delay:") + (dict-set! mods "delay" + (parse-time (slice tok 6))) + (starts-with? tok "from:") + (dict-set! mods "from" + (slice tok 5)))) + (rest tokens)) + (dict "event" (first tokens) "modifiers" mods)))))) + raw-parts)))))) + + +(define default-trigger + (fn (tag-name) + ;; Default trigger for element type + (cond + (= tag-name "FORM") + (list (dict "event" "submit" "modifiers" (dict))) + (or (= tag-name "INPUT") + (= tag-name "SELECT") + (= tag-name "TEXTAREA")) + (list (dict "event" "change" "modifiers" (dict))) + :else + (list (dict "event" "click" "modifiers" (dict)))))) + + +;; -------------------------------------------------------------------------- +;; Verb extraction +;; -------------------------------------------------------------------------- + +(define get-verb-info + (fn (el) + ;; Check element for sx-get, sx-post, etc. Returns (dict "method" "url") or nil. + (some + (fn (verb) + (let ((url (dom-get-attr el (str "sx-" verb)))) + (if url + (dict "method" (upper verb) "url" url) + nil))) + ENGINE_VERBS))) + + +;; -------------------------------------------------------------------------- +;; Request header building +;; -------------------------------------------------------------------------- + +(define build-request-headers + (fn (el loaded-components css-hash) + ;; Build the SX request headers dict + (let ((headers (dict + "SX-Request" "true" + "SX-Current-URL" (browser-location-href)))) + ;; Target selector + (let ((target-sel (dom-get-attr el "sx-target"))) + (when target-sel + (dict-set! headers "SX-Target" target-sel))) + + ;; Loaded component names + (when (not (empty? loaded-components)) + (dict-set! headers "SX-Components" + (join "," loaded-components))) + + ;; CSS class hash + (when css-hash + (dict-set! headers "SX-Css" css-hash)) + + ;; Extra headers from sx-headers attribute + (let ((extra-h (dom-get-attr el "sx-headers"))) + (when extra-h + (let ((parsed (parse-header-value extra-h))) + (when parsed + (for-each + (fn (key) (dict-set! headers key (str (get parsed key)))) + (keys parsed)))))) + + headers))) + + +;; -------------------------------------------------------------------------- +;; Response header processing +;; -------------------------------------------------------------------------- + +(define process-response-headers + (fn (get-header) + ;; Extract all SX response header directives into a dict. + ;; get-header is (fn (name) → string or nil). + (dict + "redirect" (get-header "SX-Redirect") + "refresh" (get-header "SX-Refresh") + "trigger" (get-header "SX-Trigger") + "retarget" (get-header "SX-Retarget") + "reswap" (get-header "SX-Reswap") + "location" (get-header "SX-Location") + "replace-url" (get-header "SX-Replace-Url") + "css-hash" (get-header "SX-Css-Hash") + "trigger-swap" (get-header "SX-Trigger-After-Swap") + "trigger-settle" (get-header "SX-Trigger-After-Settle") + "content-type" (get-header "Content-Type")))) + + +;; -------------------------------------------------------------------------- +;; Swap specification parsing +;; -------------------------------------------------------------------------- + +(define parse-swap-spec + (fn (raw-swap global-transitions?) + ;; Parse "innerHTML transition:true" → dict with style + transition flag + (let ((parts (split (or raw-swap DEFAULT_SWAP) " ")) + (style (first parts)) + (use-transition global-transitions?)) + (for-each + (fn (p) + (cond + (= p "transition:true") (set! use-transition true) + (= p "transition:false") (set! use-transition false))) + (rest parts)) + (dict "style" style "transition" use-transition)))) + + +;; -------------------------------------------------------------------------- +;; Retry logic +;; -------------------------------------------------------------------------- + +(define parse-retry-spec + (fn (retry-attr) + ;; Parse "exponential:1000:30000" → spec dict or nil + (if (nil? retry-attr) + nil + (let ((parts (split retry-attr ":"))) + (dict + "strategy" (first parts) + "start-ms" (parse-int (nth parts 1) 1000) + "cap-ms" (parse-int (nth parts 2) 30000)))))) + + +(define next-retry-ms + (fn (current-ms cap-ms) + ;; Exponential backoff: double current, cap at max + (min (* current-ms 2) cap-ms))) + + +;; -------------------------------------------------------------------------- +;; Form parameter filtering +;; -------------------------------------------------------------------------- + +(define filter-params + (fn (params-spec all-params) + ;; Filter form parameters by sx-params spec. + ;; all-params is a list of (key value) pairs. + ;; Returns filtered list of (key value) pairs. + (cond + (nil? params-spec) all-params + (= params-spec "none") (list) + (= params-spec "*") all-params + (starts-with? params-spec "not ") + (let ((excluded (map trim (split (slice params-spec 4) ",")))) + (filter + (fn (p) (not (contains? excluded (first p)))) + all-params)) + :else + (let ((allowed (map trim (split params-spec ",")))) + (filter + (fn (p) (contains? allowed (first p))) + all-params))))) + + +;; -------------------------------------------------------------------------- +;; Target resolution +;; -------------------------------------------------------------------------- + +(define resolve-target + (fn (el) + ;; Resolve the swap target for an element + (let ((sel (dom-get-attr el "sx-target"))) + (cond + (or (nil? sel) (= sel "this")) el + (= sel "closest") (dom-parent el) + :else (dom-query sel))))) + + +;; -------------------------------------------------------------------------- +;; Optimistic updates +;; -------------------------------------------------------------------------- + +(define apply-optimistic + (fn (el) + ;; Apply optimistic update preview. Returns state for reverting, or nil. + (let ((directive (dom-get-attr el "sx-optimistic"))) + (if (nil? directive) + nil + (let ((target (or (resolve-target el) el)) + (state (dict "target" target "directive" directive))) + (cond + (= directive "remove") + (do + (dict-set! state "opacity" (dom-get-style target "opacity")) + (dom-set-style target "opacity" "0") + (dom-set-style target "pointer-events" "none")) + (= directive "disable") + (do + (dict-set! state "disabled" (dom-get-prop target "disabled")) + (dom-set-prop target "disabled" true)) + (starts-with? directive "add-class:") + (let ((cls (slice directive 10))) + (dict-set! state "add-class" cls) + (dom-add-class target cls))) + state))))) + + +(define revert-optimistic + (fn (state) + ;; Revert an optimistic update + (when state + (let ((target (get state "target")) + (directive (get state "directive"))) + (cond + (= directive "remove") + (do + (dom-set-style target "opacity" (or (get state "opacity") "")) + (dom-set-style target "pointer-events" "")) + (= directive "disable") + (dom-set-prop target "disabled" (or (get state "disabled") false)) + (get state "add-class") + (dom-remove-class target (get state "add-class"))))))) + + +;; -------------------------------------------------------------------------- +;; Out-of-band swap identification +;; -------------------------------------------------------------------------- + +(define find-oob-swaps + (fn (container) + ;; Find elements marked for out-of-band swapping. + ;; Returns list of (dict "element" el "swap-type" type "target-id" id). + (let ((results (list))) + (for-each + (fn (attr) + (let ((oob-els (dom-query-all container (str "[" attr "]")))) + (for-each + (fn (oob) + (let ((swap-type (or (dom-get-attr oob attr) "outerHTML")) + (target-id (dom-id oob))) + (dom-remove-attr oob attr) + (when target-id + (append! results + (dict "element" oob + "swap-type" swap-type + "target-id" target-id))))) + oob-els))) + (list "sx-swap-oob" "hx-swap-oob")) + results))) + + +;; -------------------------------------------------------------------------- +;; DOM morph algorithm +;; -------------------------------------------------------------------------- +;; Lightweight reconciler: patches oldNode to match newNode in-place, +;; preserving event listeners, focus, scroll position, and form state +;; on keyed (id) elements. + +(define morph-node + (fn (old-node new-node) + ;; Morph old-node to match new-node, preserving listeners/state. + (cond + ;; sx-preserve / sx-ignore → skip + (or (dom-has-attr? old-node "sx-preserve") + (dom-has-attr? old-node "sx-ignore")) + nil + + ;; Different node type or tag → replace wholesale + (or (not (= (dom-node-type old-node) (dom-node-type new-node))) + (not (= (dom-node-name old-node) (dom-node-name new-node)))) + (dom-replace-child (dom-parent old-node) + (dom-clone new-node) old-node) + + ;; Text/comment nodes → update content + (or (= (dom-node-type old-node) 3) (= (dom-node-type old-node) 8)) + (when (not (= (dom-text-content old-node) (dom-text-content new-node))) + (dom-set-text-content old-node (dom-text-content new-node))) + + ;; Element nodes → sync attributes, then recurse children + (= (dom-node-type old-node) 1) + (do + (sync-attrs old-node new-node) + ;; Skip morphing focused input to preserve user's in-progress edits + (when (not (and (dom-is-active-element? old-node) + (dom-is-input-element? old-node))) + (morph-children old-node new-node)))))) + + +(define sync-attrs + (fn (old-el new-el) + ;; Add/update attributes from new, remove those not in new + (for-each + (fn (attr) + (let ((name (first attr)) + (val (nth attr 1))) + (when (not (= (dom-get-attr old-el name) val)) + (dom-set-attr old-el name val)))) + (dom-attr-list new-el)) + (for-each + (fn (attr) + (when (not (dom-has-attr? new-el (first attr))) + (dom-remove-attr old-el (first attr)))) + (dom-attr-list old-el)))) + + +(define morph-children + (fn (old-parent new-parent) + ;; Reconcile children of old-parent to match new-parent. + ;; Keyed elements (with id) are matched and moved in-place. + (let ((old-kids (dom-child-list old-parent)) + (new-kids (dom-child-list new-parent)) + ;; Build ID map of old children for keyed matching + (old-by-id (reduce + (fn (acc kid) + (let ((id (dom-id kid))) + (if id (do (dict-set! acc id kid) acc) acc))) + (dict) old-kids)) + (oi 0)) + + ;; Walk new children, morph/insert/append + (for-each + (fn (new-child) + (let ((match-id (dom-id new-child)) + (match-by-id (if match-id (dict-get old-by-id match-id) nil))) + (cond + ;; Keyed match — move into position if needed, then morph + (and match-by-id (not (nil? match-by-id))) + (do + (when (and (< oi (len old-kids)) + (not (= match-by-id (nth old-kids oi)))) + (dom-insert-before old-parent match-by-id + (if (< oi (len old-kids)) (nth old-kids oi) nil))) + (morph-node match-by-id new-child) + (set! oi (inc oi))) + + ;; Positional match + (< oi (len old-kids)) + (let ((old-child (nth old-kids oi))) + (if (and (dom-id old-child) (not match-id)) + ;; Old has ID, new doesn't — insert new before old + (dom-insert-before old-parent + (dom-clone new-child) old-child) + ;; Normal positional morph + (do + (morph-node old-child new-child) + (set! oi (inc oi))))) + + ;; Extra new children — append + :else + (dom-append old-parent (dom-clone new-child))))) + new-kids) + + ;; Remove leftover old children + (for-each + (fn (i) + (when (>= i oi) + (let ((leftover (nth old-kids i))) + (when (and (dom-is-child-of? leftover old-parent) + (not (dom-has-attr? leftover "sx-preserve")) + (not (dom-has-attr? leftover "sx-ignore"))) + (dom-remove-child old-parent leftover))))) + (range oi (len old-kids)))))) + + +;; -------------------------------------------------------------------------- +;; Swap dispatch +;; -------------------------------------------------------------------------- + +(define swap-dom-nodes + (fn (target new-nodes strategy) + ;; Execute a swap strategy on live DOM nodes. + ;; new-nodes is typically a DocumentFragment or Element. + (case strategy + "innerHTML" + (if (dom-is-fragment? new-nodes) + (morph-children target new-nodes) + (let ((wrapper (dom-create-element "div" nil))) + (dom-append wrapper new-nodes) + (morph-children target wrapper))) + + "outerHTML" + (let ((parent (dom-parent target))) + (if (dom-is-fragment? new-nodes) + ;; Fragment — morph first child, insert rest + (let ((fc (dom-first-child new-nodes))) + (if fc + (do + (morph-node target fc) + ;; Insert remaining siblings after morphed element + (let ((sib (dom-next-sibling fc))) + (insert-remaining-siblings parent target sib))) + (dom-remove-child parent target))) + (morph-node target new-nodes)) + parent) + + "afterend" + (dom-insert-after target new-nodes) + + "beforeend" + (dom-append target new-nodes) + + "afterbegin" + (dom-prepend target new-nodes) + + "beforebegin" + (dom-insert-before (dom-parent target) new-nodes target) + + "delete" + (dom-remove-child (dom-parent target) target) + + "none" + nil + + ;; Default = innerHTML + :else + (if (dom-is-fragment? new-nodes) + (morph-children target new-nodes) + (let ((wrapper (dom-create-element "div" nil))) + (dom-append wrapper new-nodes) + (morph-children target wrapper)))))) + + +(define insert-remaining-siblings + (fn (parent ref-node sib) + ;; Insert sibling chain after ref-node + (when sib + (let ((next (dom-next-sibling sib))) + (dom-insert-after ref-node sib) + (insert-remaining-siblings parent sib next))))) + + +;; -------------------------------------------------------------------------- +;; String-based swap (fallback for HTML responses) +;; -------------------------------------------------------------------------- + +(define swap-html-string + (fn (target html strategy) + ;; Execute a swap strategy using an HTML string (DOMParser pipeline). + (case strategy + "innerHTML" + (dom-set-inner-html target html) + "outerHTML" + (let ((parent (dom-parent target))) + (dom-insert-adjacent-html target "afterend" html) + (dom-remove-child parent target) + parent) + "afterend" + (dom-insert-adjacent-html target "afterend" html) + "beforeend" + (dom-insert-adjacent-html target "beforeend" html) + "afterbegin" + (dom-insert-adjacent-html target "afterbegin" html) + "beforebegin" + (dom-insert-adjacent-html target "beforebegin" html) + "delete" + (dom-remove-child (dom-parent target) target) + "none" + nil + :else + (dom-set-inner-html target html)))) + + +;; -------------------------------------------------------------------------- +;; History management +;; -------------------------------------------------------------------------- + +(define handle-history + (fn (el url resp-headers) + ;; Process history push/replace based on element attrs and response headers + (let ((push-url (dom-get-attr el "sx-push-url")) + (replace-url (dom-get-attr el "sx-replace-url")) + (hdr-replace (get resp-headers "replace-url"))) + (cond + ;; Server override + hdr-replace + (browser-replace-state hdr-replace) + ;; Client push + (and push-url (not (= push-url "false"))) + (browser-push-state + (if (= push-url "true") url push-url)) + ;; Client replace + (and replace-url (not (= replace-url "false"))) + (browser-replace-state + (if (= replace-url "true") url replace-url)))))) + + +;; -------------------------------------------------------------------------- +;; Preload cache +;; -------------------------------------------------------------------------- + +(define PRELOAD_TTL 30000) ;; 30 seconds + +(define preload-cache-get + (fn (cache url) + ;; Get and consume a cached preload response. + ;; Returns (dict "text" ... "content-type" ...) or nil. + (let ((entry (dict-get cache url))) + (if (nil? entry) + nil + (if (> (- (now-ms) (get entry "timestamp")) PRELOAD_TTL) + (do (dict-delete! cache url) nil) + (do (dict-delete! cache url) entry)))))) + + +(define preload-cache-set + (fn (cache url text content-type) + ;; Store a preloaded response + (dict-set! cache url + (dict "text" text "content-type" content-type "timestamp" (now-ms))))) + + +;; -------------------------------------------------------------------------- +;; Trigger dispatch table +;; -------------------------------------------------------------------------- +;; Maps trigger event names to binding strategies. +;; This is the logic; actual browser event binding is platform interface. + +(define classify-trigger + (fn (trigger) + ;; Classify a parsed trigger descriptor for binding. + ;; Returns one of: "poll", "intersect", "load", "revealed", "event" + (let ((event (get trigger "event"))) + (cond + (= event "every") "poll" + (= event "intersect") "intersect" + (= event "load") "load" + (= event "revealed") "revealed" + :else "event")))) + + +;; -------------------------------------------------------------------------- +;; Boost logic +;; -------------------------------------------------------------------------- + +(define should-boost-link? + (fn (link) + ;; Whether a link inside an sx-boost container should be boosted + (let ((href (dom-get-attr link "href"))) + (and href + (not (starts-with? href "#")) + (not (starts-with? href "javascript:")) + (not (starts-with? href "mailto:")) + (browser-same-origin? href) + (not (dom-has-attr? link "sx-get")) + (not (dom-has-attr? link "sx-post")) + (not (dom-has-attr? link "sx-disable")))))) + + +(define should-boost-form? + (fn (form) + ;; Whether a form inside an sx-boost container should be boosted + (and (not (dom-has-attr? form "sx-get")) + (not (dom-has-attr? form "sx-post")) + (not (dom-has-attr? form "sx-disable"))))) + + +;; -------------------------------------------------------------------------- +;; SSE event classification +;; -------------------------------------------------------------------------- + +(define parse-sse-swap + (fn (el) + ;; Parse sx-sse-swap attribute + ;; Returns event name to listen for (default "message") + (or (dom-get-attr el "sx-sse-swap") "message"))) + + +;; -------------------------------------------------------------------------- +;; Platform interface — Engine +;; -------------------------------------------------------------------------- +;; +;; Browser/Network: +;; (browser-location-href) → current URL string +;; (browser-same-origin? url) → boolean +;; (browser-push-state url) → void (history.pushState) +;; (browser-replace-state url) → void (history.replaceState) +;; (browser-navigate url) → void (location.assign) +;; (browser-reload) → void (location.reload) +;; (browser-scroll-to x y) → void +;; (browser-media-matches? query) → boolean (matchMedia) +;; (browser-confirm msg) → boolean +;; (browser-prompt msg) → string or nil +;; (now-ms) → current timestamp in milliseconds +;; +;; Fetch: +;; (browser-fetch url opts) → Promise-like +;; (browser-abort-controller) → AbortController +;; +;; DOM query: +;; (dom-query sel) → Element or nil +;; (dom-query-all root sel) → list of Elements +;; (dom-id el) → string id or nil +;; (dom-parent el) → parent Element +;; (dom-first-child el) → first child node +;; (dom-next-sibling el) → next sibling node +;; (dom-child-list el) → list of child nodes +;; (dom-tag-name el) → uppercase tag name +;; +;; DOM mutation: +;; (dom-create-element tag ns) → Element +;; (dom-append parent child) → void +;; (dom-prepend parent child) → void +;; (dom-insert-before parent node ref) → void +;; (dom-insert-after ref node) → void +;; (dom-remove-child parent child) → void +;; (dom-replace-child parent new old) → void +;; (dom-clone node) → deep clone +;; +;; DOM attributes: +;; (dom-get-attr el name) → string or nil +;; (dom-set-attr el name val) → void +;; (dom-remove-attr el name) → void +;; (dom-has-attr? el name) → boolean +;; (dom-attr-list el) → list of (name value) pairs +;; +;; DOM properties: +;; (dom-get-prop el name) → value +;; (dom-set-prop el name val) → void +;; (dom-get-style el prop) → string +;; (dom-set-style el prop val) → void +;; (dom-add-class el cls) → void +;; (dom-remove-class el cls) → void +;; +;; DOM inspection: +;; (dom-node-type el) → number (1=element, 3=text, 8=comment, 11=fragment) +;; (dom-node-name el) → string (uppercase tag or #text/#comment) +;; (dom-text-content el) → string +;; (dom-set-text-content el s) → void +;; (dom-is-fragment? el) → boolean (nodeType === 11) +;; (dom-is-child-of? child parent) → boolean +;; (dom-is-active-element? el) → boolean (el === document.activeElement) +;; (dom-is-input-element? el) → boolean (INPUT/TEXTAREA/SELECT) +;; +;; DOM content: +;; (dom-set-inner-html el html) → void +;; (dom-insert-adjacent-html el pos html) → void +;; (dom-parse-html-string text) → parsed document +;; +;; Events: +;; (dom-dispatch el name detail) → boolean (dispatchEvent) +;; (dom-add-listener el event fn opts) → void +;; (dom-remove-listener el event fn) → void +;; +;; Parsing: +;; (parse-header-value s) → dict (parse JSON or SX dict from string) +;; +;; Misc: +;; (dict-has? d key) → boolean +;; (dict-delete! d key) → void +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index fd1a60e..fc7e68e 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -446,7 +446,8 @@ (define parse-comp-params (fn (params-expr) - ;; Parse (&key param1 param2 &rest children) → (params has-children) + ;; Parse (&key param1 param2 &children) → (params has-children) + ;; Also accepts &rest as synonym for &children. (let ((params (list)) (has-children false) (in-key false)) @@ -455,12 +456,12 @@ (when (= (type-of p) "symbol") (let ((name (symbol-name p))) (cond - (= name "&key") (set! in-key true) - (= name "&rest") (set! has-children true) - (and in-key (not has-children)) - (append! params name) - :else - (append! params name))))) + (= name "&key") (set! in-key true) + (= name "&rest") (set! has-children true) + (= name "&children") (set! has-children true) + has-children nil ;; skip params after &children/&rest + in-key (append! params name) + :else (append! params name))))) params-expr) (list params has-children)))) diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx index 624d781..7bead17 100644 --- a/shared/sx/ref/render.sx +++ b/shared/sx/ref/render.sx @@ -1,18 +1,17 @@ ;; ========================================================================== -;; render.sx — Reference rendering specification +;; render.sx — Core rendering specification ;; -;; Defines how evaluated SX expressions become output (DOM nodes, HTML -;; strings, or SX wire format). Each target provides a renderer adapter -;; that implements the platform-specific output operations. +;; Shared registries and utilities used by all rendering adapters. +;; This file defines WHAT is renderable (tag registries, attribute rules) +;; and HOW arguments are parsed — but not the output format. ;; -;; Three rendering modes (matching the Python/JS implementations): +;; Adapters: +;; adapter-html.sx — HTML string output (server) +;; adapter-sx.sx — SX wire format output (server → client) +;; adapter-dom.sx — Live DOM node output (browser) ;; -;; 1. render-to-dom — produces DOM nodes (browser only) -;; 2. render-to-html — produces HTML string (server) -;; 3. render-to-sx — produces SX wire format (server → client) -;; -;; This file specifies the LOGIC of rendering. Platform-specific -;; operations are declared as interfaces at the bottom. +;; Each adapter imports these shared definitions and provides its own +;; render entry point (render-to-html, render-to-sx, render-to-dom). ;; ========================================================================== @@ -69,104 +68,13 @@ ;; -------------------------------------------------------------------------- -;; render-to-html — server-side HTML rendering +;; Shared utilities ;; -------------------------------------------------------------------------- -(define render-to-html - (fn (expr env) - (case (type-of expr) - ;; Literals — render directly - "nil" "" - "string" (escape-html expr) - "number" (str expr) - "boolean" (if expr "true" "false") - ;; List — dispatch to render-list which handles HTML tags, special forms, etc. - "list" (if (empty? expr) "" (render-list-to-html expr env)) - ;; Symbol — evaluate then render - "symbol" (render-value-to-html (trampoline (eval-expr expr env)) env) - ;; Keyword — render as text - "keyword" (escape-html (keyword-name expr)) - ;; Everything else — evaluate first - :else (render-value-to-html (trampoline (eval-expr expr env)) env)))) - -(define render-value-to-html - (fn (val env) - (case (type-of val) - "nil" "" - "string" (escape-html val) - "number" (str val) - "boolean" (if val "true" "false") - "list" (render-list-to-html val env) - "raw-html" (raw-html-content val) - "style-value" (style-value-class val) - :else (escape-html (str val))))) - -(define render-list-to-html - (fn (expr env) - (if (empty? expr) - "" - (let ((head (first expr))) - (if (not (= (type-of head) "symbol")) - ;; Data list — render each item - (join "" (map (fn (x) (render-value-to-html x env)) expr)) - (let ((name (symbol-name head)) - (args (rest expr))) - (cond - ;; Fragment - (= name "<>") - (join "" (map (fn (x) (render-to-html x env)) args)) - - ;; Raw HTML passthrough - (= name "raw!") - (join "" (map (fn (x) (str (trampoline (eval-expr x env)))) args)) - - ;; HTML tag - (contains? HTML_TAGS name) - (render-html-element name args env) - - ;; Component call (~name) - (starts-with? name "~") - (let ((comp (env-get env name))) - (if (component? comp) - (render-to-html - (trampoline (call-component comp args env)) - env) - (error (str "Unknown component: " name)))) - - ;; Definitions — evaluate for side effects, render nothing - (or (= name "define") (= name "defcomp") (= name "defmacro") - (= name "defstyle") (= name "defkeyframes") (= name "defhandler")) - (do (trampoline (eval-expr expr env)) "") - - ;; Macro expansion - (and (env-has? env name) (macro? (env-get env name))) - (render-to-html - (trampoline - (eval-expr - (expand-macro (env-get env name) args env) - env)) - env) - - ;; Special form / function call — evaluate then render result - :else - (render-value-to-html - (trampoline (eval-expr expr env)) - env)))))))) - - -(define render-html-element - (fn (tag args env) - (let ((parsed (parse-element-args args env)) - (attrs (first parsed)) - (children (nth parsed 1)) - (is-void (contains? VOID_ELEMENTS tag))) - (str "<" tag - (render-attrs attrs) - (if is-void - " />" - (str ">" - (join "" (map (fn (c) (render-to-html c env)) children)) - "")))))) +(define definition-form? + (fn (name) + (or (= name "define") (= name "defcomp") (= name "defmacro") + (= name "defstyle") (= name "defkeyframes") (= name "defhandler")))) (define parse-element-args @@ -178,7 +86,7 @@ (fn (state arg) (let ((skip (get state "skip"))) (if skip - (assoc state "skip" false) + (assoc state "skip" false "i" (inc (get state "i"))) (if (and (= (type-of arg) "keyword") (< (inc (get state "i")) (len args))) (let ((val (trampoline (eval-expr (nth args (inc (get state "i"))) env)))) @@ -194,6 +102,8 @@ (define render-attrs (fn (attrs) + ;; Render an attrs dict to an HTML attribute string. + ;; Used by adapter-html.sx and adapter-sx.sx. (join "" (map (fn (key) @@ -215,141 +125,14 @@ ;; -------------------------------------------------------------------------- -;; render-to-sx — server-side SX wire format (for client rendering) -;; -------------------------------------------------------------------------- -;; This mode serializes the expression as SX source text. -;; Component calls are NOT expanded — they're sent to the client. -;; HTML tags are serialized as-is. Special forms are evaluated. - -(define render-to-sx - (fn (expr env) - (let ((result (aser expr env))) - (serialize result)))) - -(define aser - (fn (expr env) - ;; Evaluate for SX wire format — serialize rendering forms, - ;; evaluate control flow and function calls. - (case (type-of expr) - "number" expr - "string" expr - "boolean" expr - "nil" nil - - "symbol" - (let ((name (symbol-name expr))) - (cond - (env-has? env name) (env-get env name) - (primitive? name) (get-primitive name) - (= name "true") true - (= name "false") false - (= name "nil") nil - :else (error (str "Undefined symbol: " name)))) - - "keyword" (keyword-name expr) - - "list" - (if (empty? expr) - (list) - (aser-list expr env)) - - :else expr))) - - -(define aser-list - (fn (expr env) - (let ((head (first expr)) - (args (rest expr))) - (if (not (= (type-of head) "symbol")) - (map (fn (x) (aser x env)) expr) - (let ((name (symbol-name head))) - (cond - ;; Fragment — serialize children - (= name "<>") - (aser-fragment args env) - - ;; Component call — serialize WITHOUT expanding - (starts-with? name "~") - (aser-call name args env) - - ;; HTML tag — serialize - (contains? HTML_TAGS name) - (aser-call name args env) - - ;; Special/HO forms — evaluate (produces data) - (or (special-form? name) (ho-form? name)) - (aser-special name expr env) - - ;; Macro — expand then aser - (and (env-has? env name) (macro? (env-get env name))) - (aser (expand-macro (env-get env name) args env) env) - - ;; Function call — evaluate fully - :else - (let ((f (trampoline (eval-expr head env))) - (evaled-args (map (fn (a) (trampoline (eval-expr a env))) args))) - (cond - (and (callable? f) (not (lambda? f)) (not (component? f))) - (apply f evaled-args) - (lambda? f) - (trampoline (call-lambda f evaled-args env)) - (component? f) - (aser-call (str "~" (component-name f)) args env) - :else (error (str "Not callable: " (inspect f))))))))))) - - -(define aser-fragment - (fn (children env) - ;; Serialize (<> child1 child2 ...) to sx source string - (let ((parts (filter - (fn (x) (not (nil? x))) - (map (fn (c) (aser c env)) children)))) - (if (empty? parts) - "" - (str "(<> " (join " " (map serialize parts)) ")"))))) - - -(define aser-call - (fn (name args env) - ;; Serialize (name :key val child ...) — evaluate args but keep as sx - (let ((parts (list name))) - (reduce - (fn (state arg) - (let ((skip (get state "skip"))) - (if skip - (assoc state "skip" false) - (if (and (= (type-of arg) "keyword") - (< (inc (get state "i")) (len args))) - (let ((val (aser (nth args (inc (get state "i"))) env))) - (when (not (nil? val)) - (append! parts (str ":" (keyword-name arg))) - (append! parts (serialize val))) - (assoc state "skip" true "i" (inc (get state "i")))) - (let ((val (aser arg env))) - (when (not (nil? val)) - (append! parts (serialize val))) - (assoc state "i" (inc (get state "i")))))))) - (dict "i" 0 "skip" false) - args) - (str "(" (join " " parts) ")")))) - - -;; -------------------------------------------------------------------------- -;; Platform rendering interface +;; Platform interface (shared across adapters) ;; -------------------------------------------------------------------------- ;; -;; HTML rendering (server targets): +;; HTML/attribute escaping (used by HTML and SX wire adapters): ;; (escape-html s) → HTML-escaped string ;; (escape-attr s) → attribute-value-escaped string ;; (raw-html-content r) → unwrap RawHTML marker to string ;; -;; DOM rendering (browser target): -;; (create-element tag) → DOM Element -;; (create-text-node s) → DOM Text -;; (create-fragment) → DOM DocumentFragment -;; (set-attribute el k v) → void -;; (append-child parent c) → void -;; ;; StyleValue: ;; (style-value? x) → boolean (is x a StyleValue?) ;; (style-value-class sv) → string (CSS class name) @@ -357,7 +140,7 @@ ;; Serialization: ;; (serialize val) → SX source string representation of val ;; -;; Form classification: +;; Form classification (used by SX wire adapter): ;; (special-form? name) → boolean ;; (ho-form? name) → boolean ;; (aser-special name expr env) → evaluate special/HO form through aser From 157a32b426eb9d65515beca8204c31d382d98e5a Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 11:52:03 +0000 Subject: [PATCH 13/24] =?UTF-8?q?Add=20sx-browser.js=20=E2=80=94=20browser?= =?UTF-8?q?-only=20build=20from=20SX=20spec=20(dom+engine)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 1634 +++++++++++++++++++++++++++ 1 file changed, 1634 insertions(+) create mode 100644 shared/static/scripts/sx-browser.js diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js new file mode 100644 index 0000000..03af793 --- /dev/null +++ b/shared/static/scripts/sx-browser.js @@ -0,0 +1,1634 @@ +/** + * sx-ref.js — Generated from reference SX evaluator specification. + * + * Bootstrap-compiled from shared/sx/ref/{eval,render,primitives}.sx + * Compare against hand-written sx.js for correctness verification. + * + * DO NOT EDIT — regenerate with: python bootstrap_js.py + */ +;(function(global) { + "use strict"; + + // ========================================================================= + // Types + // ========================================================================= + + var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function Symbol(name) { this.name = name; } + Symbol.prototype.toString = function() { return this.name; }; + Symbol.prototype._sym = true; + + function Keyword(name) { this.name = name; } + Keyword.prototype.toString = function() { return ":" + this.name; }; + Keyword.prototype._kw = true; + + function Lambda(params, body, closure, name) { + this.params = params; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Lambda.prototype._lambda = true; + + function Component(name, params, hasChildren, body, closure) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + } + Component.prototype._component = true; + + function Macro(params, restParam, body, closure, name) { + this.params = params; + this.restParam = restParam; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Macro.prototype._macro = true; + + function Thunk(expr, env) { this.expr = expr; this.env = env; } + Thunk.prototype._thunk = true; + + function RawHTML(html) { this.html = html; } + RawHTML.prototype._raw = true; + + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + + function isSym(x) { return x != null && x._sym === true; } + function isKw(x) { return x != null && x._kw === true; } + + function merge() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { + var d = arguments[i]; + if (d) for (var k in d) out[k] = d[k]; + } + return out; + } + + function sxOr() { + for (var i = 0; i < arguments.length; i++) { + if (isSxTruthy(arguments[i])) return arguments[i]; + } + return arguments.length ? arguments[arguments.length - 1] : false; + } + + // ========================================================================= + // Platform interface — JS implementation + // ========================================================================= + + function typeOf(x) { + if (isNil(x)) return "nil"; + if (typeof x === "number") return "number"; + if (typeof x === "string") return "string"; + if (typeof x === "boolean") return "boolean"; + if (x._sym) return "symbol"; + if (x._kw) return "keyword"; + if (x._thunk) return "thunk"; + if (x._lambda) return "lambda"; + if (x._component) return "component"; + if (x._macro) return "macro"; + if (x._raw) return "raw-html"; + if (x._styleValue) return "style-value"; + if (Array.isArray(x)) return "list"; + if (typeof x === "object") return "dict"; + return "unknown"; + } + + function symbolName(s) { return s.name; } + function keywordName(k) { return k.name; } + function makeSymbol(n) { return new Symbol(n); } + function makeKeyword(n) { return new Keyword(n); } + + function makeLambda(params, body, env) { return new Lambda(params, body, merge(env)); } + function makeComponent(name, params, hasChildren, body, env) { + return new Component(name, params, hasChildren, body, merge(env)); + } + function makeMacro(params, restParam, body, env, name) { + return new Macro(params, restParam, body, merge(env), name); + } + function makeThunk(expr, env) { return new Thunk(expr, env); } + + function lambdaParams(f) { return f.params; } + function lambdaBody(f) { return f.body; } + function lambdaClosure(f) { return f.closure; } + function lambdaName(f) { return f.name; } + function setLambdaName(f, n) { f.name = n; } + + function componentParams(c) { return c.params; } + function componentBody(c) { return c.body; } + function componentClosure(c) { return c.closure; } + function componentHasChildren(c) { return c.hasChildren; } + function componentName(c) { return c.name; } + + function macroParams(m) { return m.params; } + function macroRestParam(m) { return m.restParam; } + function macroBody(m) { return m.body; } + function macroClosure(m) { return m.closure; } + + function isThunk(x) { return x != null && x._thunk === true; } + function thunkExpr(t) { return t.expr; } + function thunkEnv(t) { return t.env; } + + function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } + function isLambda(x) { return x != null && x._lambda === true; } + function isComponent(x) { return x != null && x._component === true; } + function isMacro(x) { return x != null && x._macro === true; } + + function isStyleValue(x) { return x != null && x._styleValue === true; } + function styleValueClass(x) { return x.className; } + function styleValue_p(x) { return x != null && x._styleValue === true; } + + function buildKeyframes(kfName, steps, env) { + // Platform implementation of defkeyframes + var parts = []; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var selector = isSym(step[0]) ? step[0].name : String(step[0]); + var body = trampoline(evalExpr(step[1], env)); + var decls = isStyleValue(body) ? body.declarations : String(body); + parts.push(selector + "{" + decls + "}"); + } + var kfRule = "@keyframes " + kfName + "{" + parts.join("") + "}"; + var cn = "sx-ref-kf-" + kfName; + var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]); + env[kfName] = sv; + return sv; + } + + function envHas(env, name) { return name in env; } + function envGet(env, name) { return env[name]; } + function envSet(env, name, val) { env[name] = val; } + function envExtend(env) { return merge(env); } + function envMerge(base, overlay) { return merge(base, overlay); } + + function dictSet(d, k, v) { d[k] = v; } + function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; } + + function stripPrefix(s, prefix) { + return s.indexOf(prefix) === 0 ? s.slice(prefix.length) : s; + } + + function error(msg) { throw new Error(msg); } + function inspect(x) { return JSON.stringify(x); } + + // ========================================================================= + // Primitives + // ========================================================================= + + var PRIMITIVES = {}; + + // Arithmetic + PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; }; + PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; }; + PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; }; + PRIMITIVES["/"] = function(a, b) { return a / b; }; + PRIMITIVES["mod"] = function(a, b) { return a % b; }; + PRIMITIVES["inc"] = function(n) { return n + 1; }; + PRIMITIVES["dec"] = function(n) { return n - 1; }; + PRIMITIVES["abs"] = Math.abs; + PRIMITIVES["floor"] = Math.floor; + PRIMITIVES["ceil"] = Math.ceil; + PRIMITIVES["round"] = Math.round; + PRIMITIVES["min"] = Math.min; + PRIMITIVES["max"] = Math.max; + PRIMITIVES["sqrt"] = Math.sqrt; + PRIMITIVES["pow"] = Math.pow; + PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }; + + // Comparison + PRIMITIVES["="] = function(a, b) { return a == b; }; + PRIMITIVES["!="] = function(a, b) { return a != b; }; + PRIMITIVES["<"] = function(a, b) { return a < b; }; + PRIMITIVES[">"] = function(a, b) { return a > b; }; + PRIMITIVES["<="] = function(a, b) { return a <= b; }; + PRIMITIVES[">="] = function(a, b) { return a >= b; }; + + // Logic + PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); }; + + // String + PRIMITIVES["str"] = function() { + var p = []; + for (var i = 0; i < arguments.length; i++) { + var v = arguments[i]; if (isNil(v)) continue; p.push(String(v)); + } + return p.join(""); + }; + PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); }; + PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); }; + PRIMITIVES["trim"] = function(s) { return String(s).trim(); }; + PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; + PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; + PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; + PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; + PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; + PRIMITIVES["concat"] = function() { + var out = []; + for (var i = 0; i < arguments.length; i++) if (arguments[i]) out = out.concat(arguments[i]); + return out; + }; + PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; + + // Predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; + + // Collections + PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; + PRIMITIVES["dict"] = function() { + var d = {}; + for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; + return d; + }; + PRIMITIVES["range"] = function(a, b, step) { + var r = []; step = step || 1; + for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); + return r; + }; + PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; + PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; + PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; + PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; + PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; + PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; + PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); }; + PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; }; + PRIMITIVES["merge"] = function() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } + return out; + }; + PRIMITIVES["assoc"] = function(d) { + var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; + return out; + }; + PRIMITIVES["dissoc"] = function(d) { + var out = {}; for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; + return out; + }; + PRIMITIVES["chunk-every"] = function(c, n) { + var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; + }; + PRIMITIVES["zip-pairs"] = function(c) { + var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r; + }; + PRIMITIVES["into"] = function(target, coll) { + if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll); + var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } + return r; + }; + + // Format + PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); }; + PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; }; + PRIMITIVES["pluralize"] = function(n, s, p) { + if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s"); + return n == 1 ? "" : "s"; + }; + PRIMITIVES["escape"] = function(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; + PRIMITIVES["split-ids"] = function(s) { + if (!s) return []; + return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + }; + PRIMITIVES["css"] = function() { + // Stub — CSSX requires style dictionary which is browser-only + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; + + function isPrimitive(name) { return name in PRIMITIVES; } + function getPrimitive(name) { return PRIMITIVES[name]; } + + // Higher-order helpers used by the transpiled code + function map(fn, coll) { return coll.map(fn); } + function mapIndexed(fn, coll) { return coll.map(function(item, i) { return fn(i, item); }); } + function filter(fn, coll) { return coll.filter(function(x) { return isSxTruthy(fn(x)); }); } + function reduce(fn, init, coll) { + var acc = init; + for (var i = 0; i < coll.length; i++) acc = fn(acc, coll[i]); + return acc; + } + function some(fn, coll) { + for (var i = 0; i < coll.length; i++) { var r = fn(coll[i]); if (isSxTruthy(r)) return r; } + return NIL; + } + function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } + function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } + + // List primitives used directly by transpiled code + var len = PRIMITIVES["len"]; + var first = PRIMITIVES["first"]; + var last = PRIMITIVES["last"]; + var rest = PRIMITIVES["rest"]; + var nth = PRIMITIVES["nth"]; + var cons = PRIMITIVES["cons"]; + var append = PRIMITIVES["append"]; + var isEmpty = PRIMITIVES["empty?"]; + var contains = PRIMITIVES["contains?"]; + var startsWith = PRIMITIVES["starts-with?"]; + var slice = PRIMITIVES["slice"]; + var concat = PRIMITIVES["concat"]; + var str = PRIMITIVES["str"]; + var join = PRIMITIVES["join"]; + var keys = PRIMITIVES["keys"]; + var get = PRIMITIVES["get"]; + var assoc = PRIMITIVES["assoc"]; + var range = PRIMITIVES["range"]; + function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; } + function append_b(arr, x) { arr.push(x); return arr; } + var apply = function(f, args) { return f.apply(null, args); }; + + // Additional primitive aliases used by adapter/engine transpiled code + var split = PRIMITIVES["split"]; + var trim = PRIMITIVES["trim"]; + var upper = PRIMITIVES["upper"]; + var lower = PRIMITIVES["lower"]; + var replace_ = function(s, old, nw) { return s.split(old).join(nw); }; + var endsWith = PRIMITIVES["ends-with?"]; + var parseInt_ = PRIMITIVES["parse-int"]; + var dict_fn = PRIMITIVES["dict"]; + + // HTML rendering helpers + function escapeHtml(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function escapeAttr(s) { return escapeHtml(s); } + function rawHtmlContent(r) { return r.html; } + function makeRawHtml(s) { return { _raw: true, html: s }; } + + // Serializer + function serialize(val) { + if (isNil(val)) return "nil"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (typeof val === "string") return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + if (isSym(val)) return val.name; + if (isKw(val)) return ":" + val.name; + if (Array.isArray(val)) return "(" + val.map(serialize).join(" ") + ")"; + return String(val); + } + + function isSpecialForm(n) { return n in { + "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1, + "defkeyframes":1,"defhandler":1,"begin":1,"do":1, + "quote":1,"quasiquote":1,"->":1,"set!":1 + }; } + function isHoForm(n) { return n in { + "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 + }; } + + // processBindings and evalCond — exposed for DOM adapter render forms + function processBindings(bindings, env) { + var local = merge(env); + for (var i = 0; i < bindings.length; i++) { + var pair = bindings[i]; + if (Array.isArray(pair) && pair.length >= 2) { + var name = isSym(pair[0]) ? pair[0].name : String(pair[0]); + local[name] = trampoline(evalExpr(pair[1], local)); + } + } + return local; + } + function evalCond(clauses, env) { + for (var i = 0; i < clauses.length; i += 2) { + var test = clauses[i]; + if (isSym(test) && test.name === ":else") return clauses[i + 1]; + if (isKw(test) && test.name === "else") return clauses[i + 1]; + if (isSxTruthy(trampoline(evalExpr(test, env)))) return clauses[i + 1]; + } + return null; + } + + function isDefinitionForm(name) { + return name === "define" || name === "defcomp" || name === "defmacro" || + name === "defstyle" || name === "defkeyframes" || name === "defhandler"; + } + + function indexOf_(s, ch) { + return typeof s === "string" ? s.indexOf(ch) : -1; + } + + function dictHas(d, k) { return d != null && k in d; } + function dictDelete(d, k) { delete d[k]; } + + function forEachIndexed(fn, coll) { + for (var i = 0; i < coll.length; i++) fn(i, coll[i]); + return NIL; + } + + // === Transpiled from eval === + + // trampoline + var trampoline = function(val) { return (function() { + var result = val; + return (isSxTruthy(isThunk(result)) ? trampoline(evalExpr(thunkExpr(result), thunkEnv(result))) : result); +})(); }; + + // eval-expr + var evalExpr = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { + var name = symbolName(expr); + return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); +})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return trampoline(evalExpr(v, env)); }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; + + // eval-list + var evalList = function(expr, env) { return (function() { + var head = first(expr); + var args = rest(expr); + return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { + var name = symbolName(head); + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefine(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + var mac = envGet(env, name); + return makeThunk(expandMacro(mac, args, env), env); +})() : evalCall(head, args, env))))))))))))))))))))))))))))))); +})() : evalCall(head, args, env))); +})(); }; + + // eval-call + var evalCall = function(head, args, env) { return (function() { + var f = trampoline(evalExpr(head, env)); + var evaluatedArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); + return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f))))))); +})(); }; + + // call-lambda + var callLambda = function(f, args, callerEnv) { return (function() { + var params = lambdaParams(f); + var local = envMerge(lambdaClosure(f), callerEnv); + return (isSxTruthy((len(args) != len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), makeThunk(lambdaBody(f), local))); +})(); }; + + // call-component + var callComponent = function(comp, rawArgs, env) { return (function() { + var parsed = parseKeywordArgs(rawArgs, env); + var kwargs = first(parsed); + var children = nth(parsed, 1); + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = sxOr(dictGet(kwargs, p), NIL); } } + if (isSxTruthy(componentHasChildren(comp))) { + local["children"] = children; +} + return makeThunk(componentBody(comp), local); +})(); }; + + // parse-keyword-args + var parseKeywordArgs = function(rawArgs, env) { return (function() { + var kwargs = {}; + var children = []; + var i = 0; + reduce(function(state, arg) { return (function() { + var idx = get(state, "i"); + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (idx + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((idx + 1) < len(rawArgs)))) ? (dictSet(kwargs, keywordName(arg), trampoline(evalExpr(nth(rawArgs, (idx + 1)), env))), assoc(state, "skip", true, "i", (idx + 1))) : (append_b(children, trampoline(evalExpr(arg, env))), assoc(state, "i", (idx + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, rawArgs); + return [kwargs, children]; +})(); }; + + // sf-if + var sfIf = function(args, env) { return (function() { + var condition = trampoline(evalExpr(first(args), env)); + return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL)); +})(); }; + + // sf-when + var sfWhen = function(args, env) { return (function() { + var condition = trampoline(evalExpr(first(args), env)); + return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL); +})(); }; + + // sf-cond + var sfCond = function(args, env) { return (isSxTruthy((isSxTruthy((typeOf(first(args)) == "list")) && (len(first(args)) == 2))) ? sfCondScheme(args, env) : sfCondClojure(args, env)); }; + + // sf-cond-scheme + var sfCondScheme = function(clauses, env) { return (isSxTruthy(isEmpty(clauses)) ? NIL : (function() { + var clause = first(clauses); + var test = first(clause); + var body = nth(clause, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))), (isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")))) ? makeThunk(body, env) : (isSxTruthy(trampoline(evalExpr(test, env))) ? makeThunk(body, env) : sfCondScheme(rest(clauses), env))); +})()); }; + + // sf-cond-clojure + var sfCondClojure = function(clauses, env) { return (isSxTruthy((len(clauses) < 2)) ? NIL : (function() { + var test = first(clauses); + var body = nth(clauses, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")), (isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))))) ? makeThunk(body, env) : (isSxTruthy(trampoline(evalExpr(test, env))) ? makeThunk(body, env) : sfCondClojure(slice(clauses, 2), env))); +})()); }; + + // sf-case + var sfCase = function(args, env) { return (function() { + var matchVal = trampoline(evalExpr(first(args), env)); + var clauses = rest(args); + return sfCaseLoop(matchVal, clauses, env); +})(); }; + + // sf-case-loop + var sfCaseLoop = function(matchVal, clauses, env) { return (isSxTruthy((len(clauses) < 2)) ? NIL : (function() { + var test = first(clauses); + var body = nth(clauses, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")), (isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))))) ? makeThunk(body, env) : (isSxTruthy((matchVal == trampoline(evalExpr(test, env)))) ? makeThunk(body, env) : sfCaseLoop(matchVal, slice(clauses, 2), env))); +})()); }; + + // sf-and + var sfAnd = function(args, env) { return (isSxTruthy(isEmpty(args)) ? true : (function() { + var val = trampoline(evalExpr(first(args), env)); + return (isSxTruthy(!val) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env))); +})()); }; + + // sf-or + var sfOr = function(args, env) { return (isSxTruthy(isEmpty(args)) ? false : (function() { + var val = trampoline(evalExpr(first(args), env)); + return (isSxTruthy(val) ? val : sfOr(rest(args), env)); +})()); }; + + // sf-let + var sfLet = function(args, env) { return (function() { + var bindings = first(args); + var body = rest(args); + var local = envExtend(env); + (isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { return (function() { + var vname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding)); + return envSet(local, vname, trampoline(evalExpr(nth(binding, 1), local))); +})(); }, bindings) : (function() { + var i = 0; + return reduce(function(acc, pairIdx) { return (function() { + var vname = (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2))); + var valExpr = nth(bindings, ((pairIdx * 2) + 1)); + return envSet(local, vname, trampoline(evalExpr(valExpr, local))); +})(); }, NIL, range(0, (len(bindings) / 2))); +})()); + { var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } } + return makeThunk(last(body), local); +})(); }; + + // sf-lambda + var sfLambda = function(args, env) { return (function() { + var paramsExpr = first(args); + var body = nth(args, 1); + var paramNames = map(function(p) { return (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p); }, paramsExpr); + return makeLambda(paramNames, body, env); +})(); }; + + // sf-define + var sfDefine = function(args, env) { return (function() { + var nameSym = first(args); + var value = trampoline(evalExpr(nth(args, 1), env)); + if (isSxTruthy((isSxTruthy(isLambda(value)) && isNil(lambdaName(value))))) { + value.name = symbolName(nameSym); +} + env[symbolName(nameSym)] = value; + return value; +})(); }; + + // sf-defcomp + var sfDefcomp = function(args, env) { return (function() { + var nameSym = first(args); + var paramsRaw = nth(args, 1); + var body = nth(args, 2); + var compName = stripPrefix(symbolName(nameSym), "~"); + var parsed = parseCompParams(paramsRaw); + var params = first(parsed); + var hasChildren = nth(parsed, 1); + return (function() { + var comp = makeComponent(compName, params, hasChildren, body, env); + env[symbolName(nameSym)] = comp; + return comp; +})(); +})(); }; + + // parse-comp-params + var parseCompParams = function(paramsExpr) { return (function() { + var params = []; + var hasChildren = false; + var inKey = false; + { var _c = paramsExpr; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; if (isSxTruthy((typeOf(p) == "symbol"))) { + (function() { + var name = symbolName(p); + return (isSxTruthy((name == "&key")) ? (inKey = true) : (isSxTruthy((name == "&rest")) ? (hasChildren = true) : (isSxTruthy((name == "&children")) ? (hasChildren = true) : (isSxTruthy(hasChildren) ? NIL : (isSxTruthy(inKey) ? append_b(params, name) : append_b(params, name)))))); +})(); +} } } + return [params, hasChildren]; +})(); }; + + // sf-defmacro + var sfDefmacro = function(args, env) { return (function() { + var nameSym = first(args); + var paramsRaw = nth(args, 1); + var body = nth(args, 2); + var parsed = parseMacroParams(paramsRaw); + var params = first(parsed); + var restParam = nth(parsed, 1); + return (function() { + var mac = makeMacro(params, restParam, body, env, symbolName(nameSym)); + env[symbolName(nameSym)] = mac; + return mac; +})(); +})(); }; + + // parse-macro-params + var parseMacroParams = function(paramsExpr) { return (function() { + var params = []; + var restParam = NIL; + reduce(function(state, p) { return (isSxTruthy((isSxTruthy((typeOf(p) == "symbol")) && (symbolName(p) == "&rest"))) ? assoc(state, "in-rest", true) : (isSxTruthy(get(state, "in-rest")) ? ((restParam = (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p)), state) : (append_b(params, (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p)), state))); }, {["in-rest"]: false}, paramsExpr); + return [params, restParam]; +})(); }; + + // sf-defstyle + var sfDefstyle = function(args, env) { return (function() { + var nameSym = first(args); + var value = trampoline(evalExpr(nth(args, 1), env)); + env[symbolName(nameSym)] = value; + return value; +})(); }; + + // sf-defkeyframes + var sfDefkeyframes = function(args, env) { return (function() { + var kfName = symbolName(first(args)); + var steps = rest(args); + return buildKeyframes(kfName, steps, env); +})(); }; + + // sf-begin + var sfBegin = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 0, (len(args) - 1))), makeThunk(last(args), env))); }; + + // sf-quote + var sfQuote = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : first(args)); }; + + // sf-quasiquote + var sfQuasiquote = function(args, env) { return qqExpand(first(args), env); }; + + // qq-expand + var qqExpand = function(template, env) { return (isSxTruthy(!(typeOf(template) == "list")) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() { + var head = first(template); + return (isSxTruthy((isSxTruthy((typeOf(head) == "symbol")) && (symbolName(head) == "unquote"))) ? trampoline(evalExpr(nth(template, 1), env)) : reduce(function(result, item) { return (isSxTruthy((isSxTruthy((typeOf(item) == "list")) && isSxTruthy((len(item) == 2)) && isSxTruthy((typeOf(first(item)) == "symbol")) && (symbolName(first(item)) == "splice-unquote"))) ? (function() { + var spliced = trampoline(evalExpr(nth(item, 1), env)); + return (isSxTruthy((typeOf(spliced) == "list")) ? concat(result, spliced) : (isSxTruthy(isNil(spliced)) ? result : append(result, spliced))); +})() : append(result, qqExpand(item, env))); }, [], template)); +})())); }; + + // sf-thread-first + var sfThreadFirst = function(args, env) { return (function() { + var val = trampoline(evalExpr(first(args), env)); + return reduce(function(result, form) { return (isSxTruthy((typeOf(form) == "list")) ? (function() { + var f = trampoline(evalExpr(first(form), env)); + var restArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, rest(form)); + var allArgs = cons(result, restArgs); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f)))))); +})() : (function() { + var f = trampoline(evalExpr(form, env)); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f)))))); +})()); }, val, rest(args)); +})(); }; + + // sf-set! + var sfSetBang = function(args, env) { return (function() { + var name = symbolName(first(args)); + var value = trampoline(evalExpr(nth(args, 1), env)); + env[name] = value; + return value; +})(); }; + + // expand-macro + var expandMacro = function(mac, rawArgs, env) { return (function() { + var local = envMerge(macroClosure(mac), env); + { var _c = mapIndexed(function(i, p) { return [p, i]; }, macroParams(mac)); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = (isSxTruthy((nth(pair, 1) < len(rawArgs))) ? nth(rawArgs, nth(pair, 1)) : NIL); } } + if (isSxTruthy(macroRestParam(mac))) { + local[macroRestParam(mac)] = slice(rawArgs, len(macroParams(mac))); +} + return trampoline(evalExpr(macroBody(mac), local)); +})(); }; + + // ho-map + var hoMap = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return map(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-map-indexed + var hoMapIndexed = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return mapIndexed(function(i, item) { return trampoline(callLambda(f, [i, item], env)); }, coll); +})(); }; + + // ho-filter + var hoFilter = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return filter(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-reduce + var hoReduce = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var init = trampoline(evalExpr(nth(args, 1), env)); + var coll = trampoline(evalExpr(nth(args, 2), env)); + return reduce(function(acc, item) { return trampoline(callLambda(f, [acc, item], env)); }, init, coll); +})(); }; + + // ho-some + var hoSome = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return some(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-every + var hoEvery = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return isEvery(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + // ho-for-each + var hoForEach = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return forEach(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + + + // === Transpiled from render (core) === + + // HTML_TAGS + var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "math", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text", "tspan", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "feTurbulence", "feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA", "feDisplacementMap", "feFlood", "feImage", "feMorphology", "feSpecularLighting", "feDiffuseLighting", "fePointLight", "feSpotLight", "feDistantLight", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; + + // VOID_ELEMENTS + var VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]; + + // BOOLEAN_ATTRS + var BOOLEAN_ATTRS = ["async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected"]; + + // definition-form? + var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defkeyframes"), (name == "defhandler")); }; + + // parse-element-args + var parseElementArgs = function(args, env) { return (function() { + var attrs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + attrs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return [attrs, children]; +})(); }; + + // render-attrs + var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() { + var val = dictGet(attrs, key); + return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\"")))))); +})(); }, keys(attrs))); }; + + + // === Transpiled from adapter-dom === + + // SVG_NS + var SVG_NS = "http://www.w3.org/2000/svg"; + + // MATH_NS + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + // render-to-dom + var renderToDom = function(expr, env, ns) { return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); if (_m == "style-value") return createTextNode(styleValueClass(expr)); return createTextNode((String(expr))); })(); }; + + // render-dom-list + var renderDomList = function(expr, env, ns) { return (function() { + var head = first(expr); + return (isSxTruthy((typeOf(head) == "symbol")) ? (function() { + var name = symbolName(head); + var args = rest(expr); + return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { + var comp = envGet(env, name); + return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(name)); +})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))); +})() : (isSxTruthy(sxOr(isLambda(head), (typeOf(head) == "list"))) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (function() { + var frag = createFragment(); + { var _c = expr; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } + return frag; +})())); +})(); }; + + // render-dom-element + var renderDomElement = function(tag, args, env, ns) { return (function() { + var newNs = (isSxTruthy((tag == "svg")) ? SVG_NS : (isSxTruthy((tag == "math")) ? MATH_NS : ns)); + var el = domCreateElement(tag, newNs); + var extraClass = NIL; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var attrName = keywordName(arg); + var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy((attrName == "style")) && isStyleValue(attrVal))) ? (extraClass = styleValueClass(attrVal)) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal))))))); + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : ((isSxTruthy(!contains(VOID_ELEMENTS, tag)) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + if (isSxTruthy(extraClass)) { + (function() { + var existing = domGetAttr(el, "class"); + return domSetAttr(el, "class", (isSxTruthy(existing) ? (String(existing) + String(" ") + String(extraClass)) : extraClass)); +})(); +} + return el; +})(); }; + + // render-dom-component + var renderDomComponent = function(comp, args, env, ns) { return (function() { + var kwargs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + kwargs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + if (isSxTruthy(componentHasChildren(comp))) { + (function() { + var childFrag = createFragment(); + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; domAppend(childFrag, renderToDom(c, env, ns)); } } + return envSet(local, "children", childFrag); +})(); +} + return renderToDom(componentBody(comp), local, ns); +})(); +})(); }; + + // render-dom-fragment + var renderDomFragment = function(args, env, ns) { return (function() { + var frag = createFragment(); + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } + return frag; +})(); }; + + // render-dom-raw + var renderDomRaw = function(args, env) { return (function() { + var frag = createFragment(); + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (function() { + var val = trampoline(evalExpr(arg, env)); + return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isNil(val)) ? domAppend(frag, createTextNode((String(val)))) : NIL))); +})(); } } + return frag; +})(); }; + + // render-dom-unknown-component + var renderDomUnknownComponent = function(name) { return (function() { + var el = domCreateElement("div", NIL); + domSetAttr(el, "style", "background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace"); + domAppend(el, createTextNode((String("Unknown component: ") + String(name)))); + return el; +})(); }; + + // RENDER_DOM_FORMS + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"]; + + // render-dom-form? + var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; + + // dispatch-render-form + var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (function() { + var condVal = trampoline(evalExpr(nth(expr, 1), env)); + return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment())); +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? createFragment() : (function() { + var frag = createFragment(); + { var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } + return frag; +})()) : (isSxTruthy((name == "cond")) ? (function() { + var branch = evalCond(rest(expr), env); + return (isSxTruthy(branch) ? renderToDom(branch, env, ns) : createFragment()); +})() : (isSxTruthy((name == "case")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() { + var local = processBindings(nth(expr, 1), env); + var frag = createFragment(); + { var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), local, ns)); } } + return frag; +})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? (function() { + var frag = createFragment(); + { var _c = range(1, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } + return frag; +})() : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})() : (isSxTruthy((name == "map-indexed")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + forEachIndexed(function(i, item) { return (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [i, item], env, ns) : renderToDom(apply(f, [i, item]), env, ns)); + return domAppend(frag, val); +})(); }, coll); + return frag; +})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "for-each")) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var coll = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))); }; + + // render-lambda-dom + var renderLambdaDom = function(f, args, env, ns) { return (function() { + var local = envMerge(lambdaClosure(f), env); + forEachIndexed(function(i, p) { return envSet(local, p, nth(args, i)); }, lambdaParams(f)); + return renderToDom(lambdaBody(f), local, ns); +})(); }; + + + // === Transpiled from engine === + + // ENGINE_VERBS + var ENGINE_VERBS = ["get", "post", "put", "delete", "patch"]; + + // DEFAULT_SWAP + var DEFAULT_SWAP = "outerHTML"; + + // parse-time + var parseTime = function(s) { return (isSxTruthy(isNil(s)) ? 0 : (isSxTruthy(endsWith(s, "ms")) ? parseInt_(s, 0) : (isSxTruthy(endsWith(s, "s")) ? (parseInt_(replace_(s, "s", ""), 0) * 1000) : parseInt_(s, 0)))); }; + + // parse-trigger-spec + var parseTriggerSpec = function(spec) { return (isSxTruthy(isNil(spec)) ? NIL : (function() { + var rawParts = split(spec, ","); + return filter(function(x) { return !isNil(x); }, map(function(part) { return (function() { + var tokens = split(trim(part), " "); + return (isSxTruthy(isEmpty(tokens)) ? NIL : (isSxTruthy((isSxTruthy((first(tokens) == "every")) && (len(tokens) >= 2))) ? {["event"]: "every", ["modifiers"]: {["interval"]: parseTime(nth(tokens, 1))}} : (function() { + var mods = {}; + { var _c = rest(tokens); for (var _i = 0; _i < _c.length; _i++) { var tok = _c[_i]; (isSxTruthy((tok == "once")) ? dictSet(mods, "once", true) : (isSxTruthy((tok == "changed")) ? dictSet(mods, "changed", true) : (isSxTruthy(startsWith(tok, "delay:")) ? dictSet(mods, "delay", parseTime(slice(tok, 6))) : (isSxTruthy(startsWith(tok, "from:")) ? dictSet(mods, "from", slice(tok, 5)) : NIL)))); } } + return {["event"]: first(tokens), ["modifiers"]: mods}; +})())); +})(); }, rawParts)); +})()); }; + + // default-trigger + var defaultTrigger = function(tagName) { return (isSxTruthy((tagName == "FORM")) ? [{["event"]: "submit", ["modifiers"]: {}}] : (isSxTruthy(sxOr((tagName == "INPUT"), (tagName == "SELECT"), (tagName == "TEXTAREA"))) ? [{["event"]: "change", ["modifiers"]: {}}] : [{["event"]: "click", ["modifiers"]: {}}])); }; + + // get-verb-info + var getVerbInfo = function(el) { return some(function(verb) { return (function() { + var url = domGetAttr(el, (String("sx-") + String(verb))); + return (isSxTruthy(url) ? {["method"]: upper(verb), ["url"]: url} : NIL); +})(); }, ENGINE_VERBS); }; + + // build-request-headers + var buildRequestHeaders = function(el, loadedComponents, cssHash) { return (function() { + var headers = {["SX-Request"]: "true", ["SX-Current-URL"]: browserLocationHref()}; + (function() { + var targetSel = domGetAttr(el, "sx-target"); + return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL); +})(); + if (isSxTruthy(!isEmpty(loadedComponents))) { + headers["SX-Components"] = join(",", loadedComponents); +} + if (isSxTruthy(cssHash)) { + headers["SX-Css"] = cssHash; +} + (function() { + var extraH = domGetAttr(el, "sx-headers"); + return (isSxTruthy(extraH) ? (function() { + var parsed = parseHeaderValue(extraH); + return (isSxTruthy(parsed) ? forEach(function(key) { return dictSet(headers, key, (String(get(parsed, key)))); }, keys(parsed)) : NIL); +})() : NIL); +})(); + return headers; +})(); }; + + // process-response-headers + var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; }; + + // parse-swap-spec + var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() { + var parts = split(sxOr(rawSwap, DEFAULT_SWAP), " "); + var style = first(parts); + var useTransition = globalTransitions_p; + { var _c = rest(parts); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; (isSxTruthy((p == "transition:true")) ? (useTransition = true) : (isSxTruthy((p == "transition:false")) ? (useTransition = false) : NIL)); } } + return {["style"]: style, ["transition"]: useTransition}; +})(); }; + + // parse-retry-spec + var parseRetrySpec = function(retryAttr) { return (isSxTruthy(isNil(retryAttr)) ? NIL : (function() { + var parts = split(retryAttr, ":"); + return {["strategy"]: first(parts), ["start-ms"]: parseInt_(nth(parts, 1), 1000), ["cap-ms"]: parseInt_(nth(parts, 2), 30000)}; +})()); }; + + // next-retry-ms + var nextRetryMs = function(currentMs, capMs) { return min((currentMs * 2), capMs); }; + + // filter-params + var filterParams = function(paramsSpec, allParams) { return (isSxTruthy(isNil(paramsSpec)) ? allParams : (isSxTruthy((paramsSpec == "none")) ? [] : (isSxTruthy((paramsSpec == "*")) ? allParams : (isSxTruthy(startsWith(paramsSpec, "not ")) ? (function() { + var excluded = map(trim, split(slice(paramsSpec, 4), ",")); + return filter(function(p) { return !contains(excluded, first(p)); }, allParams); +})() : (function() { + var allowed = map(trim, split(paramsSpec, ",")); + return filter(function(p) { return contains(allowed, first(p)); }, allParams); +})())))); }; + + // resolve-target + var resolveTarget = function(el) { return (function() { + var sel = domGetAttr(el, "sx-target"); + return (isSxTruthy(sxOr(isNil(sel), (sel == "this"))) ? el : (isSxTruthy((sel == "closest")) ? domParent(el) : domQuery(sel))); +})(); }; + + // apply-optimistic + var applyOptimistic = function(el) { return (function() { + var directive = domGetAttr(el, "sx-optimistic"); + return (isSxTruthy(isNil(directive)) ? NIL : (function() { + var target = sxOr(resolveTarget(el), el); + var state = {["target"]: target, ["directive"]: directive}; + (isSxTruthy((directive == "remove")) ? (dictSet(state, "opacity", domGetStyle(target, "opacity")), domSetStyle(target, "opacity", "0"), domSetStyle(target, "pointer-events", "none")) : (isSxTruthy((directive == "disable")) ? (dictSet(state, "disabled", domGetProp(target, "disabled")), domSetProp(target, "disabled", true)) : (isSxTruthy(startsWith(directive, "add-class:")) ? (function() { + var cls = slice(directive, 10); + state["add-class"] = cls; + return domAddClass(target, cls); +})() : NIL))); + return state; +})()); +})(); }; + + // revert-optimistic + var revertOptimistic = function(state) { return (isSxTruthy(state) ? (function() { + var target = get(state, "target"); + var directive = get(state, "directive"); + return (isSxTruthy((directive == "remove")) ? (domSetStyle(target, "opacity", sxOr(get(state, "opacity"), "")), domSetStyle(target, "pointer-events", "")) : (isSxTruthy((directive == "disable")) ? domSetProp(target, "disabled", sxOr(get(state, "disabled"), false)) : (isSxTruthy(get(state, "add-class")) ? domRemoveClass(target, get(state, "add-class")) : NIL))); +})() : NIL); }; + + // find-oob-swaps + var findOobSwaps = function(container) { return (function() { + var results = []; + { var _c = ["sx-swap-oob", "hx-swap-oob"]; for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + return (isSxTruthy(targetId) ? append_b(results, {["element"]: oob, ["swap-type"]: swapType, ["target-id"]: targetId}) : NIL); +})(); }, oobEls); +})(); } } + return results; +})(); }; + + // morph-node + var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!(domNodeType(oldNode) == domNodeType(newNode)), !(domNodeName(oldNode) == domNodeName(newNode)))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!(domTextContent(oldNode) == domTextContent(newNode))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!(isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); }; + + // sync-attrs + var syncAttrs = function(oldEl, newEl) { return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(!(domGetAttr(oldEl, name) == val)) ? domSetAttr(oldEl, name, val) : NIL); +})(); }, domAttrList(newEl)); }; + + // morph-children + var morphChildren = function(oldParent, newParent) { return (function() { + var oldKids = domChildList(oldParent); + var newKids = domChildList(newParent); + var oldById = reduce(function(acc, kid) { return (function() { + var id = domId(kid); + return (isSxTruthy(id) ? (dictSet(acc, id, kid), acc) : acc); +})(); }, {}, oldKids); + var oi = 0; + { var _c = newKids; for (var _i = 0; _i < _c.length; _i++) { var newChild = _c[_i]; (function() { + var matchId = domId(newChild); + var matchById = (isSxTruthy(matchId) ? dictGet(oldById, matchId) : NIL); + return (isSxTruthy((isSxTruthy(matchById) && !isNil(matchById))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !(matchById == nth(oldKids, oi)))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() { + var oldChild = nth(oldKids, oi); + return (isSxTruthy((isSxTruthy(domId(oldChild)) && !matchId)) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1)))); +})() : domAppend(oldParent, domClone(newChild)))); +})(); } } + return forEach(function(i) { return (isSxTruthy((i >= oi)) ? (function() { + var leftover = nth(oldKids, i); + return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!domHasAttr(leftover, "sx-preserve")) && !domHasAttr(leftover, "sx-ignore"))) ? domRemoveChild(oldParent, leftover) : NIL); +})() : NIL); }, range(oi, len(oldKids))); +})(); }; + + // swap-dom-nodes + var swapDomNodes = function(target, newNodes, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + (isSxTruthy(domIsFragment(newNodes)) ? (function() { + var fc = domFirstChild(newNodes); + return (isSxTruthy(fc) ? (morphNode(target, fc), (function() { + var sib = domNextSibling(fc); + return insertRemainingSiblings(parent, target, sib); +})()) : domRemoveChild(parent, target)); +})() : morphNode(target, newNodes)); + return parent; +})(); if (_m == "afterend") return domInsertAfter(target, newNodes); if (_m == "beforeend") return domAppend(target, newNodes); if (_m == "afterbegin") return domPrepend(target, newNodes); if (_m == "beforebegin") return domInsertBefore(domParent(target), newNodes, target); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); })(); }; + + // insert-remaining-siblings + var insertRemainingSiblings = function(parent, refNode, sib) { return (isSxTruthy(sib) ? (function() { + var next = domNextSibling(sib); + domInsertAfter(refNode, sib); + return insertRemainingSiblings(parent, sib, next); +})() : NIL); }; + + // swap-html-string + var swapHtmlString = function(target, html, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return domSetInnerHtml(target, html); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + domInsertAdjacentHtml(target, "afterend", html); + domRemoveChild(parent, target); + return parent; +})(); if (_m == "afterend") return domInsertAdjacentHtml(target, "afterend", html); if (_m == "beforeend") return domInsertAdjacentHtml(target, "beforeend", html); if (_m == "afterbegin") return domInsertAdjacentHtml(target, "afterbegin", html); if (_m == "beforebegin") return domInsertAdjacentHtml(target, "beforebegin", html); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return domSetInnerHtml(target, html); })(); }; + + // handle-history + var handleHistory = function(el, url, respHeaders) { return (function() { + var pushUrl = domGetAttr(el, "sx-push-url"); + var replaceUrl = domGetAttr(el, "sx-replace-url"); + var hdrReplace = get(respHeaders, "replace-url"); + return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !(pushUrl == "false"))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !(replaceUrl == "false"))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL))); +})(); }; + + // PRELOAD_TTL + var PRELOAD_TTL = 30000; + + // preload-cache-get + var preloadCacheGet = function(cache, url) { return (function() { + var entry = dictGet(cache, url); + return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "timestamp")) > PRELOAD_TTL)) ? (dictDelete(cache, url), NIL) : (dictDelete(cache, url), entry))); +})(); }; + + // preload-cache-set + var preloadCacheSet = function(cache, url, text, contentType) { return dictSet(cache, url, {["text"]: text, ["content-type"]: contentType, ["timestamp"]: nowMs()}); }; + + // classify-trigger + var classifyTrigger = function(trigger) { return (function() { + var event = get(trigger, "event"); + return (isSxTruthy((event == "every")) ? "poll" : (isSxTruthy((event == "intersect")) ? "intersect" : (isSxTruthy((event == "load")) ? "load" : (isSxTruthy((event == "revealed")) ? "revealed" : "event")))); +})(); }; + + // should-boost-link? + var shouldBoostLink = function(link) { return (function() { + var href = domGetAttr(link, "href"); + return (isSxTruthy(href) && isSxTruthy(!startsWith(href, "#")) && isSxTruthy(!startsWith(href, "javascript:")) && isSxTruthy(!startsWith(href, "mailto:")) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!domHasAttr(link, "sx-get")) && isSxTruthy(!domHasAttr(link, "sx-post")) && !domHasAttr(link, "sx-disable")); +})(); }; + + // should-boost-form? + var shouldBoostForm = function(form) { return (isSxTruthy(!domHasAttr(form, "sx-get")) && isSxTruthy(!domHasAttr(form, "sx-post")) && !domHasAttr(form, "sx-disable")); }; + + // parse-sse-swap + var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + + + // ========================================================================= + // Platform interface — DOM adapter (browser-only) + // ========================================================================= + + var _hasDom = typeof document !== "undefined"; + + var SVG_NS = "http://www.w3.org/2000/svg"; + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + function domCreateElement(tag, ns) { + if (!_hasDom) return null; + if (ns) return document.createElementNS(ns, tag); + return document.createElement(tag); + } + + function createTextNode(s) { + return _hasDom ? document.createTextNode(s) : null; + } + + function createFragment() { + return _hasDom ? document.createDocumentFragment() : null; + } + + function domAppend(parent, child) { + if (parent && child) parent.appendChild(child); + } + + function domPrepend(parent, child) { + if (parent && child) parent.insertBefore(child, parent.firstChild); + } + + function domSetAttr(el, name, val) { + if (el && el.setAttribute) el.setAttribute(name, val); + } + + function domGetAttr(el, name) { + if (!el || !el.getAttribute) return NIL; + var v = el.getAttribute(name); + return v === null ? NIL : v; + } + + function domRemoveAttr(el, name) { + if (el && el.removeAttribute) el.removeAttribute(name); + } + + function domHasAttr(el, name) { + return !!(el && el.hasAttribute && el.hasAttribute(name)); + } + + function domParseHtml(html) { + if (!_hasDom) return null; + var tpl = document.createElement("template"); + tpl.innerHTML = html; + return tpl.content; + } + + function domClone(node) { + return node && node.cloneNode ? node.cloneNode(true) : node; + } + + function domParent(el) { return el ? el.parentNode : null; } + function domId(el) { return el && el.id ? el.id : NIL; } + function domNodeType(el) { return el ? el.nodeType : 0; } + function domNodeName(el) { return el ? el.nodeName : ""; } + function domTextContent(el) { return el ? el.textContent || el.nodeValue || "" : ""; } + function domSetTextContent(el, s) { if (el) { if (el.nodeType === 3 || el.nodeType === 8) el.nodeValue = s; else el.textContent = s; } } + function domIsFragment(el) { return el ? el.nodeType === 11 : false; } + function domIsChildOf(child, parent) { return !!(parent && child && child.parentNode === parent); } + function domIsActiveElement(el) { return _hasDom && el === document.activeElement; } + function domIsInputElement(el) { + if (!el || !el.tagName) return false; + var t = el.tagName; + return t === "INPUT" || t === "TEXTAREA" || t === "SELECT"; + } + function domFirstChild(el) { return el ? el.firstChild : null; } + function domNextSibling(el) { return el ? el.nextSibling : null; } + + function domChildList(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + + function domAttrList(el) { + if (!el || !el.attributes) return []; + var r = []; + for (var i = 0; i < el.attributes.length; i++) { + r.push([el.attributes[i].name, el.attributes[i].value]); + } + return r; + } + + function domInsertBefore(parent, node, ref) { + if (parent && node) parent.insertBefore(node, ref || null); + } + + function domInsertAfter(ref, node) { + if (ref && ref.parentNode && node) { + ref.parentNode.insertBefore(node, ref.nextSibling); + } + } + + function domRemoveChild(parent, child) { + if (parent && child && child.parentNode === parent) parent.removeChild(child); + } + + function domReplaceChild(parent, newChild, oldChild) { + if (parent && newChild && oldChild) parent.replaceChild(newChild, oldChild); + } + + function domSetInnerHtml(el, html) { + if (el) el.innerHTML = html; + } + + function domInsertAdjacentHtml(el, pos, html) { + if (el && el.insertAdjacentHTML) el.insertAdjacentHTML(pos, html); + } + + function domGetStyle(el, prop) { + return el && el.style ? el.style[prop] || "" : ""; + } + + function domSetStyle(el, prop, val) { + if (el && el.style) el.style[prop] = val; + } + + function domGetProp(el, name) { return el ? el[name] : NIL; } + function domSetProp(el, name, val) { if (el) el[name] = val; } + + function domAddClass(el, cls) { + if (el && el.classList) el.classList.add(cls); + } + + function domRemoveClass(el, cls) { + if (el && el.classList) el.classList.remove(cls); + } + + function domDispatch(el, name, detail) { + if (!_hasDom || !el) return false; + var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} }); + return el.dispatchEvent(evt); + } + + function domQuery(sel) { + return _hasDom ? document.querySelector(sel) : null; + } + + function domQueryAll(root, sel) { + if (!root || !root.querySelectorAll) return []; + return Array.prototype.slice.call(root.querySelectorAll(sel)); + } + + function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + + + // ========================================================================= + // Platform interface — Engine (browser-only) + // ========================================================================= + + function browserLocationHref() { + return typeof location !== "undefined" ? location.href : ""; + } + + function browserSameOrigin(url) { + try { return new URL(url, location.href).origin === location.origin; } + catch (e) { return true; } + } + + function browserPushState(url) { + if (typeof history !== "undefined") { + try { history.pushState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserReplaceState(url) { + if (typeof history !== "undefined") { + try { history.replaceState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserNavigate(url) { + if (typeof location !== "undefined") location.assign(url); + } + + function browserReload() { + if (typeof location !== "undefined") location.reload(); + } + + function browserScrollTo(x, y) { + if (typeof window !== "undefined") window.scrollTo(x, y); + } + + function browserMediaMatches(query) { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + } + + function browserConfirm(msg) { + if (typeof window === "undefined") return false; + return window.confirm(msg); + } + + function browserPrompt(msg) { + if (typeof window === "undefined") return NIL; + var r = window.prompt(msg); + return r === null ? NIL : r; + } + + function nowMs() { return Date.now(); } + + function parseHeaderValue(s) { + if (!s) return null; + try { + if (s.charAt(0) === "{" && s.charAt(1) === ":") return parse(s); + return JSON.parse(s); + } catch (e) { return null; } + } + + + // ========================================================================= + // Post-transpilation fixups + // ========================================================================= + // The reference spec's call-lambda only handles Lambda objects, but HO forms + // (map, reduce, etc.) may receive native primitives. Wrap to handle both. + var _rawCallLambda = callLambda; + callLambda = function(f, args, callerEnv) { + if (typeof f === "function") return f.apply(null, args); + return _rawCallLambda(f, args, callerEnv); + }; + + // Expose render functions as primitives so SX code can call them + if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom; + + // ========================================================================= + // Parser + // ========================================================================= + + function parse(text) { + var pos = 0; + function skipWs() { + while (pos < text.length) { + var ch = text[pos]; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pos++; continue; } + if (ch === ";") { while (pos < text.length && text[pos] !== "\n") pos++; continue; } + break; + } + } + function readExpr() { + skipWs(); + if (pos >= text.length) return undefined; + var ch = text[pos]; + if (ch === "(") { pos++; return readList(")"); } + if (ch === "[") { pos++; return readList("]"); } + if (ch === "{") { pos++; return readMap(); } + if (ch === '"') return readString(); + if (ch === ":") return readKeyword(); + if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } + if (ch === ",") { + pos++; + if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; } + return [new Symbol("unquote"), readExpr()]; + } + if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); + if (ch >= "0" && ch <= "9") return readNumber(); + return readSymbol(); + } + function readList(close) { + var items = []; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated list"); + if (text[pos] === close) { pos++; return items; } + items.push(readExpr()); + } + } + function readMap() { + var result = {}; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated map"); + if (text[pos] === "}") { pos++; return result; } + var key = readExpr(); + var keyStr = (key && key._kw) ? key.name : String(key); + result[keyStr] = readExpr(); + } + } + function readString() { + pos++; // skip " + var s = ""; + while (pos < text.length) { + var ch = text[pos]; + if (ch === '"') { pos++; return s; } + if (ch === "\\") { pos++; var esc = text[pos]; s += esc === "n" ? "\n" : esc === "t" ? "\t" : esc === "r" ? "\r" : esc; pos++; continue; } + s += ch; pos++; + } + throw new Error("Unterminated string"); + } + function readKeyword() { + pos++; // skip : + var name = readIdent(); + return new Keyword(name); + } + function readNumber() { + var start = pos; + if (text[pos] === "-") pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + if (pos < text.length && text[pos] === ".") { pos++; while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; } + if (pos < text.length && (text[pos] === "e" || text[pos] === "E")) { + pos++; + if (pos < text.length && (text[pos] === "+" || text[pos] === "-")) pos++; + while (pos < text.length && text[pos] >= "0" && text[pos] <= "9") pos++; + } + return Number(text.slice(start, pos)); + } + function readIdent() { + var start = pos; + while (pos < text.length && /[a-zA-Z0-9_~*+\-><=/!?.:&]/.test(text[pos])) pos++; + return text.slice(start, pos); + } + function readSymbol() { + var name = readIdent(); + if (name === "true") return true; + if (name === "false") return false; + if (name === "nil") return NIL; + return new Symbol(name); + } + var exprs = []; + while (true) { + skipWs(); + if (pos >= text.length) break; + exprs.push(readExpr()); + } + return exprs; + } + + // ========================================================================= + // Public API + // ========================================================================= + + var componentEnv = {}; + + function loadComponents(source) { + var exprs = parse(source); + for (var i = 0; i < exprs.length; i++) { + trampoline(evalExpr(exprs[i], componentEnv)); + } + } + + function render(source) { + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); + return frag; + } + + var SxRef = { + parse: parse, + eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, + loadComponents: loadComponents, + render: render, + + serialize: serialize, + NIL: NIL, + Symbol: Symbol, + Keyword: Keyword, + componentEnv: componentEnv, + renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null, + parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null, + morphNode: typeof morphNode === "function" ? morphNode : null, + morphChildren: typeof morphChildren === "function" ? morphChildren : null, + swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + _version: "ref-2.0 (dom+engine, bootstrap-compiled)" + }; + + if (typeof module !== "undefined" && module.exports) module.exports = SxRef; + else global.SxRef = SxRef; + +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file From 319702229971f8378c6f006306834c2596f04ab1 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 11:55:59 +0000 Subject: [PATCH 14/24] Restructure Specs section into Architecture, Core, and Adapters pages - Add Architecture intro page explaining the spec's two-layer design (core language + selectable adapters) with dependency graph - Split specs into Core (parser, eval, primitives, render) and Adapters (DOM, HTML, SX wire, SxEngine) overview pages - Add individual detail pages for all adapter and engine specs - Update nav with Architecture landing, Core, Adapters, and all individual spec file links Co-Authored-By: Claude Opus 4.6 --- sx/sx/layouts.sx | 2 +- sx/sx/nav-data.sx | 8 +- sx/sx/specs.sx | 162 +++++++++++++++++++++++++++++++++++++++- sx/sxc/pages/docs.sx | 14 ++-- sx/sxc/pages/helpers.py | 42 +++++++---- 5 files changed, 203 insertions(+), 25 deletions(-) diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 0cc5f2b..e2c6917 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -12,7 +12,7 @@ (dict :label "Protocols" :href "/protocols/wire-format") (dict :label "Examples" :href "/examples/click-to-load") (dict :label "Essays" :href "/essays/sx-sucks") - (dict :label "Specs" :href "/specs/core")))) + (dict :label "Specs" :href "/specs/")))) (<> (map (lambda (item) (~nav-link :href (get item "href") diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 162d746..0f61bd8 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -68,11 +68,17 @@ (dict :label "The Reflexive Web" :href "/essays/reflexive-web"))) (define specs-nav-items (list + (dict :label "Architecture" :href "/specs/") (dict :label "Core" :href "/specs/core") (dict :label "Parser" :href "/specs/parser") (dict :label "Evaluator" :href "/specs/evaluator") (dict :label "Primitives" :href "/specs/primitives") - (dict :label "Renderer" :href "/specs/renderer"))) + (dict :label "Renderer" :href "/specs/renderer") + (dict :label "Adapters" :href "/specs/adapters") + (dict :label "DOM Adapter" :href "/specs/adapter-dom") + (dict :label "HTML Adapter" :href "/specs/adapter-html") + (dict :label "SX Wire Adapter" :href "/specs/adapter-sx") + (dict :label "SxEngine" :href "/specs/engine"))) ;; Find the current nav label for a slug by matching href suffix. ;; Returns the label string or nil if no match. diff --git a/sx/sx/specs.sx b/sx/sx/specs.sx index 6e83467..4f8bb8b 100644 --- a/sx/sx/specs.sx +++ b/sx/sx/specs.sx @@ -1,9 +1,157 @@ ;; Spec viewer components — display canonical SX specification source -(defcomp ~spec-core-content (&key spec-files) - (~doc-page :title "SX Core Specification" +;; --------------------------------------------------------------------------- +;; Architecture intro page +;; --------------------------------------------------------------------------- + +(defcomp ~spec-architecture-content () + (~doc-page :title "Spec Architecture" + (div :class "space-y-8" + + (div :class "space-y-4" + (p :class "text-lg text-stone-600" + "SX is defined in SX. The canonical specification is a set of s-expression files that are both documentation and executable definition. Bootstrap compilers read these files to generate native implementations in JavaScript, Python, Rust, or any other target.") + (p :class "text-stone-600" + "The spec is split into two layers: a " + (strong "core") " that defines the language itself, and " + (strong "adapters") " that connect it to specific environments.")) + + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Core") + (p :class "text-stone-600" + "The core is platform-independent. It defines how SX source is parsed, how expressions are evaluated, what primitives exist, and what shared rendering definitions all adapters use. These four files are the language.") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "File") + (th :class "px-3 py-2 font-medium text-stone-600" "Role"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/parser" :class "hover:underline" + :sx-get "/specs/parser" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "parser.sx")) + (td :class "px-3 py-2 text-stone-700" "Tokenization and parsing of SX source text into AST")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/evaluator" :class "hover:underline" + :sx-get "/specs/evaluator" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "eval.sx")) + (td :class "px-3 py-2 text-stone-700" "Tree-walking evaluation of SX expressions")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/primitives" :class "hover:underline" + :sx-get "/specs/primitives" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "primitives.sx")) + (td :class "px-3 py-2 text-stone-700" "All built-in pure functions and their signatures")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/renderer" :class "hover:underline" + :sx-get "/specs/renderer" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "render.sx")) + (td :class "px-3 py-2 text-stone-700" "Shared rendering registries and utilities used by all adapters")))))) + + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Adapters") + (p :class "text-stone-600" + "Adapters are selectable rendering backends. Each one takes the same evaluated expression tree and produces output for a specific environment. You only need the adapters relevant to your target.") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "File") + (th :class "px-3 py-2 font-medium text-stone-600" "Output") + (th :class "px-3 py-2 font-medium text-stone-600" "Environment"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/adapter-dom" :class "hover:underline" + :sx-get "/specs/adapter-dom" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "adapter-dom.sx")) + (td :class "px-3 py-2 text-stone-700" "Live DOM nodes") + (td :class "px-3 py-2 text-stone-500" "Browser")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/adapter-html" :class "hover:underline" + :sx-get "/specs/adapter-html" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "adapter-html.sx")) + (td :class "px-3 py-2 text-stone-700" "HTML strings") + (td :class "px-3 py-2 text-stone-500" "Server")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/adapter-sx" :class "hover:underline" + :sx-get "/specs/adapter-sx" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "adapter-sx.sx")) + (td :class "px-3 py-2 text-stone-700" "SX wire format") + (td :class "px-3 py-2 text-stone-500" "Server to client")))))) + + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Engine") + (p :class "text-stone-600" + "The engine is the browser-side fetch/swap/history system. It processes " + (code :class "text-violet-700 text-sm" "sx-*") + " attributes on elements to make HTTP requests, swap content, manage browser history, and handle events. It depends on the core evaluator and the DOM adapter.") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "File") + (th :class "px-3 py-2 font-medium text-stone-600" "Role"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" + (a :href "/specs/engine" :class "hover:underline" + :sx-get "/specs/engine" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + "engine.sx")) + (td :class "px-3 py-2 text-stone-700" "SxEngine — fetch, swap, history, SSE, triggers, indicators")))))) + + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Dependency graph") + (div :class "bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700" +"parser.sx (standalone — no dependencies) +primitives.sx (standalone — declarative registry) +eval.sx depends on: parser, primitives +render.sx (standalone — shared registries) + +adapter-dom.sx depends on: render, eval +adapter-html.sx depends on: render, eval +adapter-sx.sx depends on: render, eval + +engine.sx depends on: eval, adapter-dom"))) + + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Self-hosting") + (p :class "text-stone-600" + "Every spec file is written in the same restricted subset of SX that the evaluator itself defines. A bootstrap compiler for a new target only needs to understand this subset — roughly 20 special forms and 80 primitives — to generate a fully native implementation. The spec files are the single source of truth; implementations are derived artifacts.") + (p :class "text-stone-600" + "This is not a theoretical exercise. The JavaScript implementation (" + (code :class "text-violet-700 text-sm" "sx.js") + ") and the Python implementation (" + (code :class "text-violet-700 text-sm" "shared/sx/") + ") are both generated from these spec files via " + (code :class "text-violet-700 text-sm" "bootstrap_js.py") + " and its Python counterpart."))))) + +;; --------------------------------------------------------------------------- +;; Overview pages (Core / Adapters) — show truncated previews of each file +;; --------------------------------------------------------------------------- + +(defcomp ~spec-overview-content (&key spec-files) + (~doc-page :title (or spec-title "Specs") (p :class "text-stone-600 mb-6" - "SX is defined in SX. These four files constitute the canonical, self-hosting specification of the language. Each file is both documentation and executable definition — bootstrap compilers read them to generate native implementations.") + (case spec-title + "Core Language" + "The core specification defines the language itself — parsing, evaluation, primitives, and shared rendering definitions. These four files are platform-independent and sufficient to implement SX on any target." + "Adapters & Engine" + "Adapters connect the core language to specific environments. Each adapter takes evaluated expression trees and produces output for its target. The engine adds browser-side fetch/swap behaviour." + :else "")) (div :class "space-y-8" (map (fn (spec) (div :class "space-y-3" @@ -21,6 +169,10 @@ (code (highlight (get spec "source") "sx")))))) spec-files)))) +;; --------------------------------------------------------------------------- +;; Detail page — full source of a single spec file +;; --------------------------------------------------------------------------- + (defcomp ~spec-detail-content (&key spec-title spec-desc spec-filename spec-source) (~doc-page :title spec-title (div :class "flex items-baseline gap-3 mb-4" @@ -30,6 +182,10 @@ (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code (highlight spec-source "sx")))))) +;; --------------------------------------------------------------------------- +;; Not found +;; --------------------------------------------------------------------------- + (defcomp ~spec-not-found (&key slug) (~doc-page :title "Spec Not Found" (p :class "text-stone-600" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 6f46692..9041617 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -253,11 +253,10 @@ :layout (:sx-section :section "Specs" :sub-label "Specs" - :sub-href "/specs/core" - :sub-nav (~section-nav :items specs-nav-items :current "Core") - :selected "Core") - :data (spec-data "core") - :content (~spec-core-content :spec-files spec-files)) + :sub-href "/specs/" + :sub-nav (~section-nav :items specs-nav-items :current "Architecture") + :selected "Architecture") + :content (~spec-architecture-content)) (defpage specs-page :path "/specs/" @@ -265,7 +264,7 @@ :layout (:sx-section :section "Specs" :sub-label "Specs" - :sub-href "/specs/core" + :sub-href "/specs/" :sub-nav (~section-nav :items specs-nav-items :current (find-current specs-nav-items slug)) :selected (or (find-current specs-nav-items slug) "")) @@ -273,7 +272,8 @@ :content (if spec-not-found (~spec-not-found :slug slug) (case slug - "core" (~spec-core-content :spec-files spec-files) + "core" (~spec-overview-content :spec-files spec-files) + "adapters" (~spec-overview-content :spec-files spec-files) :else (~spec-detail-content :spec-title spec-title :spec-desc spec-desc diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index f7ce796..1d0bec5 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -104,21 +104,28 @@ def _reference_data(slug: str) -> dict: } -_SPEC_FILES = { +_CORE_SPECS = { "parser": ("parser.sx", "Parser", "Tokenization and parsing of SX source text into AST."), "evaluator": ("eval.sx", "Evaluator", "Tree-walking evaluation of SX expressions."), "primitives": ("primitives.sx", "Primitives", "All built-in pure functions and their signatures."), - "renderer": ("render.sx", "Renderer", "Rendering evaluated expressions to DOM, HTML, or SX wire format."), + "renderer": ("render.sx", "Renderer", "Shared rendering registries and utilities used by all adapters."), } +_ADAPTER_SPECS = { + "adapter-dom": ("adapter-dom.sx", "DOM Adapter", "Renders SX expressions to live DOM nodes. Browser-only."), + "adapter-html": ("adapter-html.sx", "HTML Adapter", "Renders SX expressions to HTML strings. Server-side."), + "adapter-sx": ("adapter-sx.sx", "SX Wire Adapter", "Serializes SX for client-side rendering. Component calls stay unexpanded."), + "engine": ("engine.sx", "SxEngine", "Fetch/swap/history engine for browser-side SX. Like HTMX but native to SX."), +} + +_ALL_SPECS = {**_CORE_SPECS, **_ADAPTER_SPECS} + def _spec_data(slug: str) -> dict: - """Return spec file source and highlighted version for display.""" + """Return spec file source and metadata for display.""" import os - from content.highlight import highlight as _highlight ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") - # Normalise — inside container shared is at /app/shared if not os.path.isdir(ref_dir): ref_dir = "/app/shared/sx/ref" @@ -128,19 +135,28 @@ def _spec_data(slug: str) -> dict: if slug == "core": specs = [] for key in ("parser", "evaluator", "primitives", "renderer"): - filename, title, desc = _SPEC_FILES[key] + filename, title, desc = _CORE_SPECS[key] filepath = os.path.join(ref_dir, filename) source = _read_spec(filepath) specs.append({ - "title": title, - "desc": desc, - "filename": filename, - "source": source, - "href": f"/specs/{key}", + "title": title, "desc": desc, "filename": filename, + "source": source, "href": f"/specs/{key}", }) - return {**base, "spec-title": "SX Core Specification", "spec-files": specs} + return {**base, "spec-title": "Core Language", "spec-files": specs} - info = _SPEC_FILES.get(slug) + if slug == "adapters": + specs = [] + for key in ("adapter-dom", "adapter-html", "adapter-sx", "engine"): + filename, title, desc = _ADAPTER_SPECS[key] + filepath = os.path.join(ref_dir, filename) + source = _read_spec(filepath) + specs.append({ + "title": title, "desc": desc, "filename": filename, + "source": source, "href": f"/specs/{key}", + }) + return {**base, "spec-title": "Adapters & Engine", "spec-files": specs} + + info = _ALL_SPECS.get(slug) if not info: return {**base, "spec-not-found": True} From d4b23aae4c56a267787e5fe92de9113aa0668950 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 12:54:39 +0000 Subject: [PATCH 15/24] Add engine orchestration to SX spec (fetch, triggers, swap, SSE, history, init) 29 orchestration functions written in SX + adapter style: request pipeline (execute-request, do-fetch, handle-fetch-success), trigger binding (poll, intersect, load, revealed, event), post-swap processing, OOB swaps, boost, SSE, inline handlers, preload, history/popstate, and engine-init. Platform JS implementations in bootstrap_js.py for all browser-specific operations. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 896 ++++++++++++++++++++++++++++ shared/static/scripts/sx-ref.js | 896 ++++++++++++++++++++++++++++ shared/sx/ref/bootstrap_js.py | 636 ++++++++++++++++++++ shared/sx/ref/engine.sx | 790 +++++++++++++++++++++++- 4 files changed, 3197 insertions(+), 21 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 03af793..417bca8 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -1252,6 +1252,354 @@ // parse-sse-swap var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + // _preload-cache + var _preloadCache = {}; + + // _css-hash + var _cssHash = ""; + + // dispatch-trigger-events + var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() { + var parsed = tryParseJson(headerVal); + return (isSxTruthy((isSxTruthy(parsed) && isDict(parsed))) ? forEach(function(key) { return domDispatch(el, key, dictGet(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { + var n = trim(name); + return (isSxTruthy(!(n == "")) ? domDispatch(el, n, {}) : NIL); +})(); }, split(headerVal, ","))); +})() : NIL); }; + + // init-css-tracking + var initCssTracking = function() { return (function() { + var meta = domQuery("meta[name=\"sx-css-classes\"]"); + return (isSxTruthy(meta) ? (function() { + var content = domGetAttr(meta, "content"); + return (isSxTruthy(content) ? (_cssHash = content) : NIL); +})() : NIL); +})(); }; + + // execute-request + var executeRequest = function(el, verbInfo, extraParams) { return (function() { + var currentVerb = getVerbInfo(el); + var verb = (isSxTruthy(currentVerb) ? currentVerb : verbInfo); + var method = get(verb, "method"); + var url = get(verb, "url"); + if (isSxTruthy(!domHasClass(el, "sx-error"))) { + domRemoveAttr(el, "data-sx-retry-ms"); +} + return (isSxTruthy((function() { + var media = domGetAttr(el, "sx-media"); + return (isSxTruthy(media) && !browserMediaMatches(media)); +})()) ? promiseResolve(NIL) : (isSxTruthy((function() { + var msg = domGetAttr(el, "sx-confirm"); + return (isSxTruthy(msg) && !browserConfirm(msg)); +})()) ? promiseResolve(NIL) : (function() { + var promptMsg = domGetAttr(el, "sx-prompt"); + var params = extraParams; + return (isSxTruthy(promptMsg) ? (function() { + var promptVal = browserPrompt(promptMsg); + return (isSxTruthy(isNil(promptVal)) ? promiseResolve(NIL) : ((params = sxOr(params, {})), dictSet(params, "promptValue", promptVal), doFetch(el, verb, method, url, params))); +})() : doFetch(el, verb, method, url, params)); +})())); +})(); }; + + // do-fetch + var doFetch = function(el, verb, method, url, extraParams) { return (function() { + var syncAttr = domGetAttr(el, "sx-sync"); + if (isSxTruthy((isSxTruthy(syncAttr) && contains(syncAttr, "replace")))) { + abortPrevious(el); +} + return (function() { + var ctrl = newAbortController(); + trackController(el, ctrl); + return (function() { + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + if (isSxTruthy((isSxTruthy(extraParams) && dictHas(extraParams, "promptValue")))) { + headers["SX-Prompt"] = get(extraParams, "promptValue"); +} + if (isSxTruthy((isSxTruthy(!(method == "GET")) && browserSameOrigin(url)))) { + (function() { + var csrf = csrfToken(); + return (isSxTruthy(csrf) ? dictSet(headers, "X-CSRFToken", csrf) : NIL); +})(); +} + return (function() { + var bodyInfo = buildRequestBody(el, method, url); + return (function() { + var body = get(bodyInfo, "body"); + var finalUrl = get(bodyInfo, "url"); + var ct = get(bodyInfo, "content-type"); + if (isSxTruthy(ct)) { + headers["Content-Type"] = ct; +} + return (isSxTruthy(!domDispatch(el, "sx:beforeRequest", {["method"]: method, ["url"]: finalUrl})) ? promiseResolve(NIL) : (domAddClass(el, "sx-request"), domSetAttr(el, "aria-busy", "true"), (function() { + var indicator = showIndicator(el); + var disabledElts = disableElements(el); + var preloaded = (isSxTruthy((method == "GET")) ? preloadCacheGet(_preloadCache, finalUrl) : NIL); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["preloaded"]: preloaded, ["cross-origin"]: isCrossOrigin(finalUrl)}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status}), handleRetry(el, verb, extraParams)) : (domDispatch(el, "sx:afterRequest", {}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!isAbortError(err)) ? (domDispatch(el, "sx:sendError", {["error"]: err}), handleRetry(el, verb, extraParams)) : NIL)); }); +})())); +})(); +})(); +})(); +})(); +})(); }; + + // handle-fetch-success + var handleFetchSuccess = function(el, url, verb, extraParams, getHeader, text) { return (function() { + var headers = processResponseHeaders(getHeader); + return (isSxTruthy(get(headers, "redirect")) ? browserNavigate(get(headers, "redirect")) : (isSxTruthy((get(headers, "refresh") == "true")) ? browserReload() : (dispatchTriggerEvents(el, get(headers, "trigger")), (function() { + var rawSwap = sxOr(domGetAttr(el, "sx-swap"), DEFAULT_SWAP); + var target = resolveTarget(el); + var selectSel = domGetAttr(el, "sx-select"); + if (isSxTruthy(get(headers, "retarget"))) { + target = sxOr(domQuery(get(headers, "retarget")), target); +} + if (isSxTruthy(get(headers, "reswap"))) { + rawSwap = get(headers, "reswap"); +} + return (function() { + var swap = parseSwapSpec(rawSwap, false); + var ct = sxOr(get(headers, "content-type"), ""); + (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, target, swap, selectSel, text) : handleHtmlResponse(el, target, swap, selectSel, text)); + if (isSxTruthy(get(headers, "location"))) { + fetchLocation(get(headers, "location")); +} + handleHistory(el, url, headers); + domDispatch(el, "sx:afterSwap", {["target"]: target}); + dispatchTriggerEvents(el, get(headers, "trigger-swap")); + return requestAnimationFrame_(function() { return (domDispatch(el, "sx:afterSettle", {["target"]: target}), dispatchTriggerEvents(el, get(headers, "trigger-settle"))); }); +})(); +})()))); +})(); }; + + // handle-sx-response + var handleSxResponse = function(el, target, swap, selectSel, text) { return (function() { + var cleaned = stripComponentScripts(text); + var cleaned2 = extractResponseCss(cleaned); + return (function() { + var source = trim(cleaned2); + return (isSxTruthy((isSxTruthy(source) && !(source == ""))) ? (function() { + var dom = sxRender(source); + var container = domCreateElement("div", NIL); + domAppend(container, dom); + processOobSwaps(container, function(t, oob, s) { return swapDomNodes(t, oob, s); }); + return (function() { + var selected = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapDomNodes(target, selected, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})() : NIL); +})(); +})(); }; + + // handle-html-response + var handleHtmlResponse = function(el, target, swap, selectSel, text) { return (function() { + var doc = domParseHtmlDocument(text); + sxProcessScripts(doc); + processOobSwaps(doc, function(t, oob, s) { return swapHtmlString(t, domOuterHtml(oob), s); }); + return (function() { + var content = (isSxTruthy(selectSel) ? selectHtmlFromDoc(doc, selectSel) : sxOr(domBodyInnerHtml(doc), text)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapHtmlString(target, content, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})(); }; + + // handle-retry + var handleRetry = function(el, verbInfo, extraParams) { return (function() { + var retryAttr = domGetAttr(el, "sx-retry"); + return (isSxTruthy(retryAttr) ? (function() { + var spec = parseRetrySpec(retryAttr); + var currentMs = sxOr(parseInt_(domGetAttr(el, "data-sx-retry-ms"), 0), get(spec, "start-ms")); + domAddClass(el, "sx-error"); + domRemoveClass(el, "sx-loading"); + return setTimeout_(function() { return (domRemoveClass(el, "sx-error"), domAddClass(el, "sx-loading"), domSetAttr(el, "data-sx-retry-ms", (String(nextRetryMs(currentMs, get(spec, "cap-ms"))))), executeRequest(el, verbInfo, extraParams)); }, currentMs); +})() : NIL); +})(); }; + + // bind-triggers + var bindTriggers = function(el, verbInfo) { return (function() { + var triggerSpec = domGetAttr(el, "sx-trigger"); + var triggers = (isSxTruthy(triggerSpec) ? parseTriggerSpec(triggerSpec) : defaultTrigger(domTagName(el))); + return forEach(function(trig) { return (function() { + var kind = classifyTrigger(trig); + return (isSxTruthy((kind == "poll")) ? setInterval_(function() { return executeRequest(el, verbInfo, NIL); }, sxOr(get(get(trig, "modifiers"), "interval"), 1000)) : (isSxTruthy((kind == "intersect")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, get(get(trig, "modifiers"), "once"), get(get(trig, "modifiers"), "delay")) : (isSxTruthy((kind == "load")) ? setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, 0) : (isSxTruthy((kind == "revealed")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, true, NIL) : bindEvent(el, verbInfo, trig))))); +})(); }, triggers); +})(); }; + + // bind-event + var bindEvent = function(el, verbInfo, trig) { return (function() { + var eventName = get(trig, "event"); + var mods = get(trig, "modifiers"); + var listenTarget = (isSxTruthy(get(mods, "from")) ? sxOr(domQuery(get(mods, "from")), el) : el); + var timer = NIL; + var lastVal = NIL; + return domAddListener(listenTarget, eventName, function(e) { return ((isSxTruthy((eventName == "submit")) ? preventDefault_(e) : NIL), (isSxTruthy((isSxTruthy((eventName == "click")) && (domTagName(el) == "A"))) ? preventDefault_(e) : NIL), (isSxTruthy(!validateForRequest(el)) ? domDispatch(el, "sx:validationFailed", {}) : (isSxTruthy((isSxTruthy(get(mods, "changed")) && isSxTruthy(!isNil(elementValue(el))) && (elementValue(el) == lastVal))) ? NIL : ((isSxTruthy(get(mods, "changed")) ? (lastVal = elementValue(el)) : NIL), (function() { + var optState = applyOptimistic(el); + var execFn = function() { return (function() { + var p = executeRequest(el, verbInfo, NIL); + return (isSxTruthy((isSxTruthy(optState) && p)) ? promiseCatch(p, function(_) { return revertOptimistic(optState); }) : NIL); +})(); }; + return (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(execFn, get(mods, "delay")))) : execFn()); +})())))); }, {["once"]: get(mods, "once")}); +})(); }; + + // post-swap + var postSwap = function(root) { return (activateScripts(root), sxProcessScripts(root), sxHydrate(root), processElements(root)); }; + + // activate-scripts + var activateScripts = function(root) { return (function() { + var dead = domQueryAll(root, "script:not([type]), script[type='text/javascript']"); + return forEach(function(d) { return (function() { + var live = createScriptClone(d); + return domReplaceChild(domParent(d), live, d); +})(); }, dead); +})(); }; + + // process-oob-swaps + var processOobSwaps = function(container, swapFn) { return forEach(function(attr) { return (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + if (isSxTruthy(domParent(oob))) { + domRemoveChild(domParent(oob), oob); +} + return (isSxTruthy(targetId) ? (function() { + var target = domQueryById(targetId); + return (isSxTruthy(target) ? swapFn(target, oob, swapType) : NIL); +})() : NIL); +})(); }, oobEls); +})(); }, ["sx-swap-oob", "hx-swap-oob"]); }; + + // hoist-head-elements + var hoistHeadElements = function(root) { return (function() { + var styles = domQueryAll(root, "style[data-sx-css]"); + var links = domQueryAll(root, "link[rel='stylesheet']"); + { var _c = styles; for (var _i = 0; _i < _c.length; _i++) { var el = _c[_i]; if (isSxTruthy(domParent(el))) { + domRemoveChild(domParent(el), el); +} } } + return forEach(function(el) { return (isSxTruthy(domParent(el)) ? domRemoveChild(domParent(el), el) : NIL); }, links); +})(); }; + + // process-boosted + var processBoosted = function(root) { return (function() { + var containers = domQueryAll(root, "[sx-boost]"); + if (isSxTruthy(domMatches(root, "[sx-boost]"))) { + boostDescendants(root); +} + return forEach(boostDescendants, containers); +})(); }; + + // boost-descendants + var boostDescendants = function(container) { return ((function() { + var links = domQueryAll(container, "a[href]"); + return forEach(function(link) { return (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link))) ? (markProcessed(link, "boost"), bindBoostLink(link, domGetAttr(link, "href")), (isSxTruthy(!domHasAttr(link, "sx-target")) ? domSetAttr(link, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(link, "sx-swap")) ? domSetAttr(link, "sx-swap", "innerHTML") : NIL), (isSxTruthy(!domHasAttr(link, "sx-select")) ? domSetAttr(link, "sx-select", "#main-panel") : NIL)) : NIL); }, links); +})(), (function() { + var forms = domQueryAll(container, "form"); + return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), bindBoostForm(form, sxOr(upper(domGetAttr(form, "method")), "GET"), sxOr(domGetAttr(form, "action"), browserLocationHref())), (isSxTruthy(!domHasAttr(form, "sx-target")) ? domSetAttr(form, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(form, "sx-swap")) ? domSetAttr(form, "sx-swap", "innerHTML") : NIL)) : NIL); }, forms); +})()); }; + + // process-sse + var processSse = function(root) { return (function() { + var sseEls = domQueryAll(root, "[sx-sse]"); + if (isSxTruthy(domMatches(root, "[sx-sse]"))) { + bindSse(root); +} + return forEach(bindSse, sseEls); +})(); }; + + // bind-sse + var bindSse = function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), (function() { + var url = domGetAttr(el, "sx-sse"); + return (isSxTruthy(url) ? (function() { + var source = eventSourceConnect(url, el); + return (function() { + var swapEls = domQueryAll(el, "[sx-sse-swap]"); + if (isSxTruthy(domHasAttr(el, "sx-sse-swap"))) { + bindSseSwap(el, source); +} + return forEach(function(child) { return bindSseSwap(child, source); }, swapEls); +})(); +})() : NIL); +})()) : NIL); }; + + // bind-sse-swap + var bindSseSwap = function(el, source) { return (function() { + var eventName = parseSseSwap(el); + return eventSourceListen(source, eventName, function(data) { return (function() { + var target = sxOr(resolveTarget(el), el); + var swapStyle = sxOr(domGetAttr(el, "sx-swap"), "innerHTML"); + (isSxTruthy(startsWith(trim(data), "(")) ? (function() { + var dom = sxRender(data); + return swapDomNodes(target, dom, swapStyle); +})() : swapHtmlString(target, data, swapStyle)); + postSwap(target); + return domDispatch(el, "sx:sseMessage", {["data"]: data, ["event"]: eventName}); +})(); }); +})(); }; + + // bind-inline-handlers + var bindInlineHandlers = function(el) { return (isSxTruthy(!isProcessed(el, "on")) ? (markProcessed(el, "on"), (function() { + var attrs = domAttrList(el); + return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(startsWith(name, "sx-on:")) ? bindInlineHandler(el, slice(name, 6), val) : NIL); +})(); }, attrs); +})()) : NIL); }; + + // bind-preload-for + var bindPreloadFor = function(el) { return (isSxTruthy(domHasAttr(el, "sx-preload")) ? (function() { + var mode = sxOr(domGetAttr(el, "sx-preload"), "mousedown"); + var events = (isSxTruthy((mode == "mouseover")) ? ["mouseenter", "focusin"] : ["mousedown", "focusin"]); + var debounceMs = (isSxTruthy((mode == "mouseover")) ? 100 : 0); + return bindPreload(el, events, debounceMs, function() { return (function() { + var verb = getVerbInfo(el); + return (isSxTruthy(verb) ? (function() { + var url = get(verb, "url"); + return (isSxTruthy(isNil(preloadCacheGet(_preloadCache, url))) ? doPreload(url) : NIL); +})() : NIL); +})(); }); +})() : NIL); }; + + // do-preload + var doPreload = function(url) { return (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + return fetchPreload(url, headers, _preloadCache); +})(); }; + + // VERB_SELECTOR + var VERB_SELECTOR = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"; + + // process-elements + var processElements = function(root) { return (function() { + var root = sxOr(root, domBody()); + return (isSxTruthy(root) ? ((isSxTruthy(domMatches(root, VERB_SELECTOR)) ? processOne(root) : NIL), (function() { + var elements = domQueryAll(root, VERB_SELECTOR); + return forEach(processOne, elements); +})(), processBoosted(root), processSse(root), (function() { + var onEls = domQueryAll(root, "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]"); + return forEach(bindInlineHandlers, onEls); +})()) : NIL); +})(); }; + + // process-one + var processOne = function(el) { return (isSxTruthy(!isProcessed(el, "bound")) ? (isSxTruthy(!sxOr(domHasAttr(el, "sx-disable"), domClosest(el, "[sx-disable]"))) ? (markProcessed(el, "bound"), (function() { + var verbInfo = getVerbInfo(el); + return (isSxTruthy(verbInfo) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL); +})()) : NIL) : NIL); }; + + // handle-popstate + var handlePopstate = function(scrollY) { return (function() { + var url = browserLocationHref(); + var main = domQueryById("main-panel"); + return (isSxTruthy(!main) ? browserReload() : (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + headers["SX-History-Restore"] = "true"; + return fetchAndRestore(main, url, headers, scrollY); +})()); +})(); }; + + // engine-init + var engineInit = function() { return (initCssTracking(), sxProcessScripts(NIL), sxHydrate(NIL), processElements(NIL)); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -1411,6 +1759,8 @@ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1472,6 +1822,531 @@ } catch (e) { return null; } } + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } 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; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } + // ========================================================================= // Post-transpilation fixups @@ -1625,9 +2500,30 @@ morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + process: typeof processElements === "function" ? processElements : null, + executeRequest: typeof executeRequest === "function" ? executeRequest : null, + postSwap: typeof postSwap === "function" ? postSwap : null, + init: typeof engineInit === "function" ? engineInit : null, _version: "ref-2.0 (dom+engine, bootstrap-compiled)" }; + + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + } if (typeof module !== "undefined" && module.exports) module.exports = SxRef; else global.SxRef = SxRef; diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index c7aff82..76c06bf 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -1400,6 +1400,354 @@ // parse-sse-swap var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + // _preload-cache + var _preloadCache = {}; + + // _css-hash + var _cssHash = ""; + + // dispatch-trigger-events + var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() { + var parsed = tryParseJson(headerVal); + return (isSxTruthy((isSxTruthy(parsed) && isDict(parsed))) ? forEach(function(key) { return domDispatch(el, key, dictGet(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { + var n = trim(name); + return (isSxTruthy(!(n == "")) ? domDispatch(el, n, {}) : NIL); +})(); }, split(headerVal, ","))); +})() : NIL); }; + + // init-css-tracking + var initCssTracking = function() { return (function() { + var meta = domQuery("meta[name=\"sx-css-classes\"]"); + return (isSxTruthy(meta) ? (function() { + var content = domGetAttr(meta, "content"); + return (isSxTruthy(content) ? (_cssHash = content) : NIL); +})() : NIL); +})(); }; + + // execute-request + var executeRequest = function(el, verbInfo, extraParams) { return (function() { + var currentVerb = getVerbInfo(el); + var verb = (isSxTruthy(currentVerb) ? currentVerb : verbInfo); + var method = get(verb, "method"); + var url = get(verb, "url"); + if (isSxTruthy(!domHasClass(el, "sx-error"))) { + domRemoveAttr(el, "data-sx-retry-ms"); +} + return (isSxTruthy((function() { + var media = domGetAttr(el, "sx-media"); + return (isSxTruthy(media) && !browserMediaMatches(media)); +})()) ? promiseResolve(NIL) : (isSxTruthy((function() { + var msg = domGetAttr(el, "sx-confirm"); + return (isSxTruthy(msg) && !browserConfirm(msg)); +})()) ? promiseResolve(NIL) : (function() { + var promptMsg = domGetAttr(el, "sx-prompt"); + var params = extraParams; + return (isSxTruthy(promptMsg) ? (function() { + var promptVal = browserPrompt(promptMsg); + return (isSxTruthy(isNil(promptVal)) ? promiseResolve(NIL) : ((params = sxOr(params, {})), dictSet(params, "promptValue", promptVal), doFetch(el, verb, method, url, params))); +})() : doFetch(el, verb, method, url, params)); +})())); +})(); }; + + // do-fetch + var doFetch = function(el, verb, method, url, extraParams) { return (function() { + var syncAttr = domGetAttr(el, "sx-sync"); + if (isSxTruthy((isSxTruthy(syncAttr) && contains(syncAttr, "replace")))) { + abortPrevious(el); +} + return (function() { + var ctrl = newAbortController(); + trackController(el, ctrl); + return (function() { + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + if (isSxTruthy((isSxTruthy(extraParams) && dictHas(extraParams, "promptValue")))) { + headers["SX-Prompt"] = get(extraParams, "promptValue"); +} + if (isSxTruthy((isSxTruthy(!(method == "GET")) && browserSameOrigin(url)))) { + (function() { + var csrf = csrfToken(); + return (isSxTruthy(csrf) ? dictSet(headers, "X-CSRFToken", csrf) : NIL); +})(); +} + return (function() { + var bodyInfo = buildRequestBody(el, method, url); + return (function() { + var body = get(bodyInfo, "body"); + var finalUrl = get(bodyInfo, "url"); + var ct = get(bodyInfo, "content-type"); + if (isSxTruthy(ct)) { + headers["Content-Type"] = ct; +} + return (isSxTruthy(!domDispatch(el, "sx:beforeRequest", {["method"]: method, ["url"]: finalUrl})) ? promiseResolve(NIL) : (domAddClass(el, "sx-request"), domSetAttr(el, "aria-busy", "true"), (function() { + var indicator = showIndicator(el); + var disabledElts = disableElements(el); + var preloaded = (isSxTruthy((method == "GET")) ? preloadCacheGet(_preloadCache, finalUrl) : NIL); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["preloaded"]: preloaded, ["cross-origin"]: isCrossOrigin(finalUrl)}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status}), handleRetry(el, verb, extraParams)) : (domDispatch(el, "sx:afterRequest", {}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), (isSxTruthy(!isAbortError(err)) ? (domDispatch(el, "sx:sendError", {["error"]: err}), handleRetry(el, verb, extraParams)) : NIL)); }); +})())); +})(); +})(); +})(); +})(); +})(); }; + + // handle-fetch-success + var handleFetchSuccess = function(el, url, verb, extraParams, getHeader, text) { return (function() { + var headers = processResponseHeaders(getHeader); + return (isSxTruthy(get(headers, "redirect")) ? browserNavigate(get(headers, "redirect")) : (isSxTruthy((get(headers, "refresh") == "true")) ? browserReload() : (dispatchTriggerEvents(el, get(headers, "trigger")), (function() { + var rawSwap = sxOr(domGetAttr(el, "sx-swap"), DEFAULT_SWAP); + var target = resolveTarget(el); + var selectSel = domGetAttr(el, "sx-select"); + if (isSxTruthy(get(headers, "retarget"))) { + target = sxOr(domQuery(get(headers, "retarget")), target); +} + if (isSxTruthy(get(headers, "reswap"))) { + rawSwap = get(headers, "reswap"); +} + return (function() { + var swap = parseSwapSpec(rawSwap, false); + var ct = sxOr(get(headers, "content-type"), ""); + (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, target, swap, selectSel, text) : handleHtmlResponse(el, target, swap, selectSel, text)); + if (isSxTruthy(get(headers, "location"))) { + fetchLocation(get(headers, "location")); +} + handleHistory(el, url, headers); + domDispatch(el, "sx:afterSwap", {["target"]: target}); + dispatchTriggerEvents(el, get(headers, "trigger-swap")); + return requestAnimationFrame_(function() { return (domDispatch(el, "sx:afterSettle", {["target"]: target}), dispatchTriggerEvents(el, get(headers, "trigger-settle"))); }); +})(); +})()))); +})(); }; + + // handle-sx-response + var handleSxResponse = function(el, target, swap, selectSel, text) { return (function() { + var cleaned = stripComponentScripts(text); + var cleaned2 = extractResponseCss(cleaned); + return (function() { + var source = trim(cleaned2); + return (isSxTruthy((isSxTruthy(source) && !(source == ""))) ? (function() { + var dom = sxRender(source); + var container = domCreateElement("div", NIL); + domAppend(container, dom); + processOobSwaps(container, function(t, oob, s) { return swapDomNodes(t, oob, s); }); + return (function() { + var selected = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapDomNodes(target, selected, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})() : NIL); +})(); +})(); }; + + // handle-html-response + var handleHtmlResponse = function(el, target, swap, selectSel, text) { return (function() { + var doc = domParseHtmlDocument(text); + sxProcessScripts(doc); + processOobSwaps(doc, function(t, oob, s) { return swapHtmlString(t, domOuterHtml(oob), s); }); + return (function() { + var content = (isSxTruthy(selectSel) ? selectHtmlFromDoc(doc, selectSel) : sxOr(domBodyInnerHtml(doc), text)); + return (isSxTruthy((isSxTruthy(!(get(swap, "style") == "none")) && target)) ? withTransition(get(swap, "transition"), function() { return (swapHtmlString(target, content, get(swap, "style")), hoistHeadElements(target)); }) : NIL); +})(); +})(); }; + + // handle-retry + var handleRetry = function(el, verbInfo, extraParams) { return (function() { + var retryAttr = domGetAttr(el, "sx-retry"); + return (isSxTruthy(retryAttr) ? (function() { + var spec = parseRetrySpec(retryAttr); + var currentMs = sxOr(parseInt_(domGetAttr(el, "data-sx-retry-ms"), 0), get(spec, "start-ms")); + domAddClass(el, "sx-error"); + domRemoveClass(el, "sx-loading"); + return setTimeout_(function() { return (domRemoveClass(el, "sx-error"), domAddClass(el, "sx-loading"), domSetAttr(el, "data-sx-retry-ms", (String(nextRetryMs(currentMs, get(spec, "cap-ms"))))), executeRequest(el, verbInfo, extraParams)); }, currentMs); +})() : NIL); +})(); }; + + // bind-triggers + var bindTriggers = function(el, verbInfo) { return (function() { + var triggerSpec = domGetAttr(el, "sx-trigger"); + var triggers = (isSxTruthy(triggerSpec) ? parseTriggerSpec(triggerSpec) : defaultTrigger(domTagName(el))); + return forEach(function(trig) { return (function() { + var kind = classifyTrigger(trig); + return (isSxTruthy((kind == "poll")) ? setInterval_(function() { return executeRequest(el, verbInfo, NIL); }, sxOr(get(get(trig, "modifiers"), "interval"), 1000)) : (isSxTruthy((kind == "intersect")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, get(get(trig, "modifiers"), "once"), get(get(trig, "modifiers"), "delay")) : (isSxTruthy((kind == "load")) ? setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, 0) : (isSxTruthy((kind == "revealed")) ? observeIntersection(el, function() { return executeRequest(el, verbInfo, NIL); }, true, NIL) : bindEvent(el, verbInfo, trig))))); +})(); }, triggers); +})(); }; + + // bind-event + var bindEvent = function(el, verbInfo, trig) { return (function() { + var eventName = get(trig, "event"); + var mods = get(trig, "modifiers"); + var listenTarget = (isSxTruthy(get(mods, "from")) ? sxOr(domQuery(get(mods, "from")), el) : el); + var timer = NIL; + var lastVal = NIL; + return domAddListener(listenTarget, eventName, function(e) { return ((isSxTruthy((eventName == "submit")) ? preventDefault_(e) : NIL), (isSxTruthy((isSxTruthy((eventName == "click")) && (domTagName(el) == "A"))) ? preventDefault_(e) : NIL), (isSxTruthy(!validateForRequest(el)) ? domDispatch(el, "sx:validationFailed", {}) : (isSxTruthy((isSxTruthy(get(mods, "changed")) && isSxTruthy(!isNil(elementValue(el))) && (elementValue(el) == lastVal))) ? NIL : ((isSxTruthy(get(mods, "changed")) ? (lastVal = elementValue(el)) : NIL), (function() { + var optState = applyOptimistic(el); + var execFn = function() { return (function() { + var p = executeRequest(el, verbInfo, NIL); + return (isSxTruthy((isSxTruthy(optState) && p)) ? promiseCatch(p, function(_) { return revertOptimistic(optState); }) : NIL); +})(); }; + return (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(execFn, get(mods, "delay")))) : execFn()); +})())))); }, {["once"]: get(mods, "once")}); +})(); }; + + // post-swap + var postSwap = function(root) { return (activateScripts(root), sxProcessScripts(root), sxHydrate(root), processElements(root)); }; + + // activate-scripts + var activateScripts = function(root) { return (function() { + var dead = domQueryAll(root, "script:not([type]), script[type='text/javascript']"); + return forEach(function(d) { return (function() { + var live = createScriptClone(d); + return domReplaceChild(domParent(d), live, d); +})(); }, dead); +})(); }; + + // process-oob-swaps + var processOobSwaps = function(container, swapFn) { return forEach(function(attr) { return (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + if (isSxTruthy(domParent(oob))) { + domRemoveChild(domParent(oob), oob); +} + return (isSxTruthy(targetId) ? (function() { + var target = domQueryById(targetId); + return (isSxTruthy(target) ? swapFn(target, oob, swapType) : NIL); +})() : NIL); +})(); }, oobEls); +})(); }, ["sx-swap-oob", "hx-swap-oob"]); }; + + // hoist-head-elements + var hoistHeadElements = function(root) { return (function() { + var styles = domQueryAll(root, "style[data-sx-css]"); + var links = domQueryAll(root, "link[rel='stylesheet']"); + { var _c = styles; for (var _i = 0; _i < _c.length; _i++) { var el = _c[_i]; if (isSxTruthy(domParent(el))) { + domRemoveChild(domParent(el), el); +} } } + return forEach(function(el) { return (isSxTruthy(domParent(el)) ? domRemoveChild(domParent(el), el) : NIL); }, links); +})(); }; + + // process-boosted + var processBoosted = function(root) { return (function() { + var containers = domQueryAll(root, "[sx-boost]"); + if (isSxTruthy(domMatches(root, "[sx-boost]"))) { + boostDescendants(root); +} + return forEach(boostDescendants, containers); +})(); }; + + // boost-descendants + var boostDescendants = function(container) { return ((function() { + var links = domQueryAll(container, "a[href]"); + return forEach(function(link) { return (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link))) ? (markProcessed(link, "boost"), bindBoostLink(link, domGetAttr(link, "href")), (isSxTruthy(!domHasAttr(link, "sx-target")) ? domSetAttr(link, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(link, "sx-swap")) ? domSetAttr(link, "sx-swap", "innerHTML") : NIL), (isSxTruthy(!domHasAttr(link, "sx-select")) ? domSetAttr(link, "sx-select", "#main-panel") : NIL)) : NIL); }, links); +})(), (function() { + var forms = domQueryAll(container, "form"); + return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), bindBoostForm(form, sxOr(upper(domGetAttr(form, "method")), "GET"), sxOr(domGetAttr(form, "action"), browserLocationHref())), (isSxTruthy(!domHasAttr(form, "sx-target")) ? domSetAttr(form, "sx-target", "#main-panel") : NIL), (isSxTruthy(!domHasAttr(form, "sx-swap")) ? domSetAttr(form, "sx-swap", "innerHTML") : NIL)) : NIL); }, forms); +})()); }; + + // process-sse + var processSse = function(root) { return (function() { + var sseEls = domQueryAll(root, "[sx-sse]"); + if (isSxTruthy(domMatches(root, "[sx-sse]"))) { + bindSse(root); +} + return forEach(bindSse, sseEls); +})(); }; + + // bind-sse + var bindSse = function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), (function() { + var url = domGetAttr(el, "sx-sse"); + return (isSxTruthy(url) ? (function() { + var source = eventSourceConnect(url, el); + return (function() { + var swapEls = domQueryAll(el, "[sx-sse-swap]"); + if (isSxTruthy(domHasAttr(el, "sx-sse-swap"))) { + bindSseSwap(el, source); +} + return forEach(function(child) { return bindSseSwap(child, source); }, swapEls); +})(); +})() : NIL); +})()) : NIL); }; + + // bind-sse-swap + var bindSseSwap = function(el, source) { return (function() { + var eventName = parseSseSwap(el); + return eventSourceListen(source, eventName, function(data) { return (function() { + var target = sxOr(resolveTarget(el), el); + var swapStyle = sxOr(domGetAttr(el, "sx-swap"), "innerHTML"); + (isSxTruthy(startsWith(trim(data), "(")) ? (function() { + var dom = sxRender(data); + return swapDomNodes(target, dom, swapStyle); +})() : swapHtmlString(target, data, swapStyle)); + postSwap(target); + return domDispatch(el, "sx:sseMessage", {["data"]: data, ["event"]: eventName}); +})(); }); +})(); }; + + // bind-inline-handlers + var bindInlineHandlers = function(el) { return (isSxTruthy(!isProcessed(el, "on")) ? (markProcessed(el, "on"), (function() { + var attrs = domAttrList(el); + return forEach(function(attr) { return (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(startsWith(name, "sx-on:")) ? bindInlineHandler(el, slice(name, 6), val) : NIL); +})(); }, attrs); +})()) : NIL); }; + + // bind-preload-for + var bindPreloadFor = function(el) { return (isSxTruthy(domHasAttr(el, "sx-preload")) ? (function() { + var mode = sxOr(domGetAttr(el, "sx-preload"), "mousedown"); + var events = (isSxTruthy((mode == "mouseover")) ? ["mouseenter", "focusin"] : ["mousedown", "focusin"]); + var debounceMs = (isSxTruthy((mode == "mouseover")) ? 100 : 0); + return bindPreload(el, events, debounceMs, function() { return (function() { + var verb = getVerbInfo(el); + return (isSxTruthy(verb) ? (function() { + var url = get(verb, "url"); + return (isSxTruthy(isNil(preloadCacheGet(_preloadCache, url))) ? doPreload(url) : NIL); +})() : NIL); +})(); }); +})() : NIL); }; + + // do-preload + var doPreload = function(url) { return (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + return fetchPreload(url, headers, _preloadCache); +})(); }; + + // VERB_SELECTOR + var VERB_SELECTOR = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"; + + // process-elements + var processElements = function(root) { return (function() { + var root = sxOr(root, domBody()); + return (isSxTruthy(root) ? ((isSxTruthy(domMatches(root, VERB_SELECTOR)) ? processOne(root) : NIL), (function() { + var elements = domQueryAll(root, VERB_SELECTOR); + return forEach(processOne, elements); +})(), processBoosted(root), processSse(root), (function() { + var onEls = domQueryAll(root, "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]"); + return forEach(bindInlineHandlers, onEls); +})()) : NIL); +})(); }; + + // process-one + var processOne = function(el) { return (isSxTruthy(!isProcessed(el, "bound")) ? (isSxTruthy(!sxOr(domHasAttr(el, "sx-disable"), domClosest(el, "[sx-disable]"))) ? (markProcessed(el, "bound"), (function() { + var verbInfo = getVerbInfo(el); + return (isSxTruthy(verbInfo) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL); +})()) : NIL) : NIL); }; + + // handle-popstate + var handlePopstate = function(scrollY) { return (function() { + var url = browserLocationHref(); + var main = domQueryById("main-panel"); + return (isSxTruthy(!main) ? browserReload() : (function() { + var headers = buildRequestHeaders(NIL, loadedComponentNames(), _cssHash); + headers["SX-History-Restore"] = "true"; + return fetchAndRestore(main, url, headers, scrollY); +})()); +})(); }; + + // engine-init + var engineInit = function() { return (initCssTracking(), sxProcessScripts(NIL), sxHydrate(NIL), processElements(NIL)); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -1559,6 +1907,8 @@ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1620,6 +1970,531 @@ } catch (e) { return null; } } + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } 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; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } + // ========================================================================= // Post-transpilation fixups @@ -1791,9 +2666,30 @@ morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + process: typeof processElements === "function" ? processElements : null, + executeRequest: typeof executeRequest === "function" ? executeRequest : null, + postSwap: typeof postSwap === "function" ? postSwap : null, + init: typeof engineInit === "function" ? engineInit : null, _version: "ref-2.0 (dom+engine+html+sx, bootstrap-compiled)" }; + + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + } if (typeof module !== "undefined" && module.exports) module.exports = SxRef; else global.SxRef = SxRef; diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 5c9ac5f..473375b 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -297,6 +297,92 @@ class JSEmitter: "should-boost-link?": "shouldBoostLink", "should-boost-form?": "shouldBoostForm", "parse-sse-swap": "parseSseSwap", + # engine.sx orchestration + "_preload-cache": "_preloadCache", + "_css-hash": "_cssHash", + "dispatch-trigger-events": "dispatchTriggerEvents", + "init-css-tracking": "initCssTracking", + "execute-request": "executeRequest", + "do-fetch": "doFetch", + "handle-fetch-success": "handleFetchSuccess", + "handle-sx-response": "handleSxResponse", + "handle-html-response": "handleHtmlResponse", + "handle-retry": "handleRetry", + "bind-triggers": "bindTriggers", + "bind-event": "bindEvent", + "post-swap": "postSwap", + "activate-scripts": "activateScripts", + "process-oob-swaps": "processOobSwaps", + "hoist-head-elements": "hoistHeadElements", + "process-boosted": "processBoosted", + "boost-descendants": "boostDescendants", + "process-sse": "processSse", + "bind-sse": "bindSse", + "bind-sse-swap": "bindSseSwap", + "bind-inline-handlers": "bindInlineHandlers", + "bind-preload-for": "bindPreloadFor", + "do-preload": "doPreload", + "VERB_SELECTOR": "VERB_SELECTOR", + "process-elements": "processElements", + "process-one": "processOne", + "handle-popstate": "handlePopstate", + "engine-init": "engineInit", + # engine orchestration platform + "promise-resolve": "promiseResolve", + "promise-catch": "promiseCatch", + "abort-previous": "abortPrevious", + "track-controller": "trackController", + "new-abort-controller": "newAbortController", + "controller-signal": "controllerSignal", + "abort-error?": "isAbortError", + "set-timeout": "setTimeout_", + "set-interval": "setInterval_", + "clear-timeout": "clearTimeout_", + "request-animation-frame": "requestAnimationFrame_", + "csrf-token": "csrfToken", + "cross-origin?": "isCrossOrigin", + "loaded-component-names": "loadedComponentNames", + "build-request-body": "buildRequestBody", + "show-indicator": "showIndicator", + "disable-elements": "disableElements", + "clear-loading-state": "clearLoadingState", + "fetch-request": "fetchRequest", + "fetch-location": "fetchLocation", + "fetch-and-restore": "fetchAndRestore", + "fetch-preload": "fetchPreload", + "dom-query-by-id": "domQueryById", + "dom-matches?": "domMatches", + "dom-closest": "domClosest", + "dom-body": "domBody", + "dom-has-class?": "domHasClass", + "dom-append-to-head": "domAppendToHead", + "dom-parse-html-document": "domParseHtmlDocument", + "dom-outer-html": "domOuterHtml", + "dom-body-inner-html": "domBodyInnerHtml", + "prevent-default": "preventDefault_", + "element-value": "elementValue", + "validate-for-request": "validateForRequest", + "with-transition": "withTransition", + "observe-intersection": "observeIntersection", + "event-source-connect": "eventSourceConnect", + "event-source-listen": "eventSourceListen", + "bind-boost-link": "bindBoostLink", + "bind-boost-form": "bindBoostForm", + "bind-inline-handler": "bindInlineHandler", + "bind-preload": "bindPreload", + "mark-processed!": "markProcessed", + "is-processed?": "isProcessed", + "create-script-clone": "createScriptClone", + "sx-render": "sxRender", + "sx-process-scripts": "sxProcessScripts", + "sx-hydrate": "sxHydrate", + "strip-component-scripts": "stripComponentScripts", + "extract-response-css": "extractResponseCss", + "select-from-container": "selectFromContainer", + "children-to-fragment": "childrenToFragment", + "select-html-from-doc": "selectHtmlFromDoc", + "try-parse-json": "tryParseJson", + "process-css-response": "processCssResponse", "browser-location-href": "browserLocationHref", "browser-same-origin?": "browserSameOrigin", "browser-push-state": "browserPushState", @@ -1360,6 +1446,8 @@ PLATFORM_ENGINE_JS = """ // Platform interface — Engine (browser-only) // ========================================================================= + // --- Browser/Network --- + function browserLocationHref() { return typeof location !== "undefined" ? location.href : ""; } @@ -1420,6 +1508,531 @@ PLATFORM_ENGINE_JS = """ return JSON.parse(s); } catch (e) { return null; } } + + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = config.preloaded + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } 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; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.processScripts) SxObj.processScripts(root || undefined); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (SxObj && SxObj.hydrate) SxObj.hydrate(root || undefined); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : (typeof SxRef !== "undefined" ? SxRef : null); + return text.replace(/]*type="text\\/sx"[^>]*data-components[^>]*>([\\s\\S]*?)<\\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\\s\\S]*?)<\\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } """ def fixups_js(has_html, has_sx, has_dom): @@ -1642,10 +2255,33 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, adapter_label): api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,') api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,') api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,') + api_lines.append(' process: typeof processElements === "function" ? processElements : null,') + api_lines.append(' executeRequest: typeof executeRequest === "function" ? executeRequest : null,') + api_lines.append(' postSwap: typeof postSwap === "function" ? postSwap : null,') + api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,') api_lines.append(f' _version: "{version}"') api_lines.append(' };') api_lines.append('') + if has_engine: + api_lines.append(''' + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxRefInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxRefInit); + } else { + _sxRefInit(); + } + }''') + api_lines.append(' if (typeof module !== "undefined" && module.exports) module.exports = SxRef;') api_lines.append(' else global.SxRef = SxRef;') diff --git a/shared/sx/ref/engine.sx b/shared/sx/ref/engine.sx index d4e9a56..8e84d9f 100644 --- a/shared/sx/ref/engine.sx +++ b/shared/sx/ref/engine.sx @@ -662,11 +662,684 @@ (or (dom-get-attr el "sx-sse-swap") "message"))) +;; ========================================================================== +;; Engine orchestration +;; +;; The following functions define the runtime behavior of the engine: +;; request execution, trigger binding, post-swap lifecycle, boost, SSE, +;; and main processing. Browser-specific mechanics (fetch, addEventListener, +;; IntersectionObserver, EventSource, etc.) are declared as platform +;; interface at the bottom. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Engine state +;; -------------------------------------------------------------------------- + +(define _preload-cache (dict)) +(define _css-hash "") + + +;; -------------------------------------------------------------------------- +;; Event dispatch helpers +;; -------------------------------------------------------------------------- + +(define dispatch-trigger-events + (fn (el header-val) + ;; Parse and dispatch SX-Trigger header events. + ;; Value: JSON object, JSON string, or comma-separated names. + (when header-val + (let ((parsed (try-parse-json header-val))) + (if (and parsed (dict? parsed)) + (for-each + (fn (key) (dom-dispatch el key (dict-get parsed key))) + (keys parsed)) + (for-each + (fn (name) + (let ((n (trim name))) + (when (not (= n "")) + (dom-dispatch el n (dict))))) + (split header-val ","))))))) + + +;; -------------------------------------------------------------------------- +;; CSS tracking +;; -------------------------------------------------------------------------- + +(define init-css-tracking + (fn () + ;; Read CSS hash from + (let ((meta (dom-query "meta[name=\"sx-css-classes\"]"))) + (when meta + (let ((content (dom-get-attr meta "content"))) + (when content + (set! _css-hash content))))))) + + +;; -------------------------------------------------------------------------- +;; Request execution +;; -------------------------------------------------------------------------- + +(define execute-request + (fn (el verb-info extra-params) + ;; Pre-flight gate logic: media, confirm, prompt. + ;; Returns a promise. + (let ((current-verb (get-verb-info el)) + (verb (if current-verb current-verb verb-info)) + (method (get verb "method")) + (url (get verb "url"))) + ;; Reset retry backoff on fresh requests + (when (not (dom-has-class? el "sx-error")) + (dom-remove-attr el "data-sx-retry-ms")) + ;; Gate: media query + (if (let ((media (dom-get-attr el "sx-media"))) + (and media (not (browser-media-matches? media)))) + (promise-resolve nil) + ;; Gate: confirm dialog + (if (let ((msg (dom-get-attr el "sx-confirm"))) + (and msg (not (browser-confirm msg)))) + (promise-resolve nil) + ;; Gate: prompt dialog + (let ((prompt-msg (dom-get-attr el "sx-prompt")) + (params extra-params)) + (if prompt-msg + (let ((prompt-val (browser-prompt prompt-msg))) + (if (nil? prompt-val) + (promise-resolve nil) + (do + (set! params (or params (dict))) + (dict-set! params "promptValue" prompt-val) + (do-fetch el verb method url params)))) + (do-fetch el verb method url params)))))))) + + +;; -------------------------------------------------------------------------- +;; Fetch pipeline +;; -------------------------------------------------------------------------- + +(define do-fetch + (fn (el verb method url extra-params) + ;; Build request, execute fetch, handle response. + ;; Returns a promise. + (let ((sync-attr (dom-get-attr el "sx-sync"))) + (when (and sync-attr (contains? sync-attr "replace")) + (abort-previous el)) + (let ((ctrl (new-abort-controller))) + (track-controller el ctrl) + (let ((headers (build-request-headers el + (loaded-component-names) _css-hash))) + ;; Prompt header + (when (and extra-params (dict-has? extra-params "promptValue")) + (dict-set! headers "SX-Prompt" + (get extra-params "promptValue"))) + ;; CSRF for mutating same-origin + (when (and (not (= method "GET")) (browser-same-origin? url)) + (let ((csrf (csrf-token))) + (when csrf + (dict-set! headers "X-CSRFToken" csrf)))) + ;; Build request body + (let ((body-info (build-request-body el method url))) + (let ((body (get body-info "body")) + (final-url (get body-info "url")) + (ct (get body-info "content-type"))) + (when ct (dict-set! headers "Content-Type" ct)) + ;; Lifecycle: beforeRequest + (if (not (dom-dispatch el "sx:beforeRequest" + (dict "method" method "url" final-url))) + (promise-resolve nil) + (do + ;; Loading state + (dom-add-class el "sx-request") + (dom-set-attr el "aria-busy" "true") + (let ((indicator (show-indicator el)) + (disabled-elts (disable-elements el)) + (preloaded (if (= method "GET") + (preload-cache-get _preload-cache final-url) + nil))) + ;; Platform fetch with callbacks + (fetch-request + (dict "url" final-url "method" method + "headers" headers "body" body + "signal" (controller-signal ctrl) + "preloaded" preloaded + "cross-origin" (cross-origin? final-url)) + ;; Success: (fn (resp-ok status get-header text) ...) + (fn (resp-ok status get-header text) + (do + (clear-loading-state el indicator disabled-elts) + (if (not resp-ok) + (do + (dom-dispatch el "sx:responseError" + (dict "status" status)) + (handle-retry el verb extra-params)) + (do + (dom-dispatch el "sx:afterRequest" (dict)) + (handle-fetch-success el final-url verb + extra-params get-header text))))) + ;; Error: (fn (err) ...) + (fn (err) + (do + (clear-loading-state el indicator disabled-elts) + (when (not (abort-error? err)) + (do + (dom-dispatch el "sx:sendError" + (dict "error" err)) + (handle-retry el verb extra-params)))))))))))))))) + + +;; -------------------------------------------------------------------------- +;; Response handling +;; -------------------------------------------------------------------------- + +(define handle-fetch-success + (fn (el url verb extra-params get-header text) + ;; Process a successful fetch response. + (let ((headers (process-response-headers get-header))) + ;; Redirect — skip swap + (if (get headers "redirect") + (browser-navigate (get headers "redirect")) + ;; Refresh — skip swap + (if (= (get headers "refresh") "true") + (browser-reload) + (do + ;; Trigger events from header + (dispatch-trigger-events el (get headers "trigger")) + ;; Determine swap target and strategy + (let ((raw-swap (or (dom-get-attr el "sx-swap") DEFAULT_SWAP)) + (target (resolve-target el)) + (select-sel (dom-get-attr el "sx-select"))) + ;; Server overrides + (when (get headers "retarget") + (set! target (or (dom-query (get headers "retarget")) target))) + (when (get headers "reswap") + (set! raw-swap (get headers "reswap"))) + ;; Parse swap spec + (let ((swap (parse-swap-spec raw-swap false)) + (ct (or (get headers "content-type") ""))) + ;; Dispatch by content type + (if (contains? ct "text/sx") + (handle-sx-response el target swap select-sel text) + (handle-html-response el target swap select-sel text)) + ;; SX-Location + (when (get headers "location") + (fetch-location (get headers "location"))) + ;; History + (handle-history el url headers) + ;; After-swap lifecycle + (dom-dispatch el "sx:afterSwap" (dict "target" target)) + (dispatch-trigger-events el (get headers "trigger-swap")) + (request-animation-frame + (fn () + (do + (dom-dispatch el "sx:afterSettle" + (dict "target" target)) + (dispatch-trigger-events el + (get headers "trigger-settle"))))))))))))) + + +;; -------------------------------------------------------------------------- +;; SX response handler +;; -------------------------------------------------------------------------- + +(define handle-sx-response + (fn (el target swap select-sel text) + ;; Process text/sx response: extract components, CSS, render, swap. + (let ((cleaned (strip-component-scripts text)) + (cleaned2 (extract-response-css cleaned))) + (let ((source (trim cleaned2))) + (when (and source (not (= source ""))) + (let ((dom (sx-render source)) + (container (dom-create-element "div" nil))) + (dom-append container dom) + ;; OOB processing on live DOM nodes + (process-oob-swaps container + (fn (t oob s) (swap-dom-nodes t oob s))) + ;; Select filtering + (let ((selected (if select-sel + (select-from-container container select-sel) + (children-to-fragment container)))) + ;; Main swap + (when (and (not (= (get swap "style") "none")) target) + (with-transition (get swap "transition") + (fn () + (do + (swap-dom-nodes target selected (get swap "style")) + (hoist-head-elements target)))))))))))) + + +;; -------------------------------------------------------------------------- +;; HTML response handler +;; -------------------------------------------------------------------------- + +(define handle-html-response + (fn (el target swap select-sel text) + ;; Process HTML response: parse, scripts, OOB, swap. + (let ((doc (dom-parse-html-document text))) + ;; Process sx scripts + (sx-process-scripts doc) + ;; OOB processing + (process-oob-swaps doc + (fn (t oob s) + (swap-html-string t (dom-outer-html oob) s))) + ;; Build content + (let ((content (if select-sel + (select-html-from-doc doc select-sel) + (or (dom-body-inner-html doc) text)))) + ;; Main swap + (when (and (not (= (get swap "style") "none")) target) + (with-transition (get swap "transition") + (fn () + (do + (swap-html-string target content (get swap "style")) + (hoist-head-elements target))))))))) + + +;; -------------------------------------------------------------------------- +;; Retry handling +;; -------------------------------------------------------------------------- + +(define handle-retry + (fn (el verb-info extra-params) + ;; Retry failed request with exponential backoff. + (let ((retry-attr (dom-get-attr el "sx-retry"))) + (when retry-attr + (let ((spec (parse-retry-spec retry-attr)) + (current-ms (or (parse-int + (dom-get-attr el "data-sx-retry-ms") 0) + (get spec "start-ms")))) + (dom-add-class el "sx-error") + (dom-remove-class el "sx-loading") + (set-timeout + (fn () + (do + (dom-remove-class el "sx-error") + (dom-add-class el "sx-loading") + (dom-set-attr el "data-sx-retry-ms" + (str (next-retry-ms current-ms (get spec "cap-ms")))) + (execute-request el verb-info extra-params))) + current-ms)))))) + + +;; -------------------------------------------------------------------------- +;; Trigger binding +;; -------------------------------------------------------------------------- + +(define bind-triggers + (fn (el verb-info) + ;; Parse triggers and bind event handlers. + (let ((trigger-spec (dom-get-attr el "sx-trigger")) + (triggers (if trigger-spec + (parse-trigger-spec trigger-spec) + (default-trigger (dom-tag-name el))))) + (for-each + (fn (trig) + (let ((kind (classify-trigger trig))) + (cond + (= kind "poll") + (set-interval + (fn () (execute-request el verb-info nil)) + (or (get (get trig "modifiers") "interval") 1000)) + (= kind "intersect") + (observe-intersection el + (fn () (execute-request el verb-info nil)) + (get (get trig "modifiers") "once") + (get (get trig "modifiers") "delay")) + (= kind "load") + (set-timeout + (fn () (execute-request el verb-info nil)) 0) + (= kind "revealed") + (observe-intersection el + (fn () (execute-request el verb-info nil)) + true nil) + :else + (bind-event el verb-info trig)))) + triggers)))) + + +;; -------------------------------------------------------------------------- +;; Event binding with modifiers +;; -------------------------------------------------------------------------- + +(define bind-event + (fn (el verb-info trig) + ;; Bind a single event with modifiers (once, delay, changed, from). + (let ((event-name (get trig "event")) + (mods (get trig "modifiers")) + (listen-target (if (get mods "from") + (or (dom-query (get mods "from")) el) + el)) + (timer nil) + (last-val nil)) + (dom-add-listener listen-target event-name + (fn (e) + (do + ;; Prevent defaults + (when (= event-name "submit") (prevent-default e)) + (when (and (= event-name "click") (= (dom-tag-name el) "A")) + (prevent-default e)) + ;; Validation gate + (if (not (validate-for-request el)) + (dom-dispatch el "sx:validationFailed" (dict)) + ;; Changed modifier gate + (if (and (get mods "changed") + (not (nil? (element-value el))) + (= (element-value el) last-val)) + nil + (do + (when (get mods "changed") + (set! last-val (element-value el))) + ;; Apply optimistic update + (let ((opt-state (apply-optimistic el)) + (exec-fn + (fn () + (let ((p (execute-request el verb-info nil))) + (when (and opt-state p) + (promise-catch p + (fn (_) (revert-optimistic opt-state)))))))) + ;; Delay modifier + (if (get mods "delay") + (do + (clear-timeout timer) + (set! timer + (set-timeout exec-fn (get mods "delay")))) + (exec-fn)))))))) + (dict "once" (get mods "once")))))) + + +;; -------------------------------------------------------------------------- +;; Post-swap lifecycle +;; -------------------------------------------------------------------------- + +(define post-swap + (fn (root) + ;; Post-swap: activate scripts, load components, hydrate, bind engine. + (do + (activate-scripts root) + (sx-process-scripts root) + (sx-hydrate root) + (process-elements root)))) + +(define activate-scripts + (fn (root) + ;; Scripts inserted via innerHTML don't execute. + ;; Replace dead scripts with live clones so the browser runs them. + (let ((dead (dom-query-all root + "script:not([type]), script[type='text/javascript']"))) + (for-each + (fn (d) + (let ((live (create-script-clone d))) + (dom-replace-child (dom-parent d) live d))) + dead)))) + + +;; -------------------------------------------------------------------------- +;; Out-of-band swap processing (orchestration variant) +;; -------------------------------------------------------------------------- + +(define process-oob-swaps + (fn (container swap-fn) + ;; Find elements with sx-swap-oob/hx-swap-oob and swap to targets. + (for-each + (fn (attr) + (let ((oob-els (dom-query-all container (str "[" attr "]")))) + (for-each + (fn (oob) + (let ((swap-type (or (dom-get-attr oob attr) "outerHTML")) + (target-id (dom-id oob))) + (dom-remove-attr oob attr) + (when (dom-parent oob) + (dom-remove-child (dom-parent oob) oob)) + (when target-id + (let ((target (dom-query-by-id target-id))) + (when target + (swap-fn target oob swap-type)))))) + oob-els))) + (list "sx-swap-oob" "hx-swap-oob")))) + + +;; -------------------------------------------------------------------------- +;; Head element hoisting +;; -------------------------------------------------------------------------- + +(define hoist-head-elements + (fn (root) + ;; Move