diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 7c93daf..45f503c 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -11,6 +11,7 @@ x-dev-env: &dev-env
RELOAD: "true"
WORKERS: "1"
SX_USE_REF: "1"
+ SX_BOUNDARY_STRICT: "1"
x-sibling-models: &sibling-models
# Every app needs all sibling __init__.py + models/ for cross-domain SQLAlchemy imports
diff --git a/docker-compose.yml b/docker-compose.yml
index b0fa048..ab263f8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -56,6 +56,7 @@ x-app-env: &app-env
AP_DOMAIN_MARKET: market.rose-ash.com
AP_DOMAIN_EVENTS: events.rose-ash.com
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
+ SX_BOUNDARY_STRICT: "1"
services:
blog:
diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js
index 87efa7b..bc48950 100644
--- a/shared/static/scripts/sx.js
+++ b/shared/static/scripts/sx.js
@@ -87,7 +87,7 @@
var RE_COMMENT = /;[^\n]*/y;
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/y;
var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
- var RE_KEYWORD = /:[a-zA-Z_][a-zA-Z0-9_>:\-]*/y;
+ var RE_KEYWORD = /:[a-zA-Z_~*+\-><=/!?&\[][a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]*/y;
var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
function Tokenizer(text) {
diff --git a/shared/sx/boundary.py b/shared/sx/boundary.py
new file mode 100644
index 0000000..27cdf48
--- /dev/null
+++ b/shared/sx/boundary.py
@@ -0,0 +1,144 @@
+"""
+SX Boundary Enforcement — runtime validation.
+
+Reads declarations from boundary.sx + primitives.sx and validates
+that all registered primitives, I/O handlers, and page helpers
+are declared in the spec.
+
+Controlled by SX_BOUNDARY_STRICT env var:
+ - "1": validation raises errors (fail fast)
+ - anything else: validation logs warnings
+"""
+from __future__ import annotations
+
+import logging
+import os
+from typing import Any
+
+logger = logging.getLogger("sx.boundary")
+
+# ---------------------------------------------------------------------------
+# Lazy-loaded declaration sets (populated on first use)
+# ---------------------------------------------------------------------------
+
+_DECLARED_PURE: frozenset[str] | None = None
+_DECLARED_IO: frozenset[str] | None = None
+_DECLARED_HELPERS: dict[str, frozenset[str]] | None = None
+
+
+def _load_declarations() -> None:
+ global _DECLARED_PURE, _DECLARED_IO, _DECLARED_HELPERS
+ if _DECLARED_PURE is not None:
+ return
+ try:
+ from .ref.boundary_parser import parse_primitives_sx, parse_boundary_sx
+ _DECLARED_PURE = parse_primitives_sx()
+ _DECLARED_IO, _DECLARED_HELPERS = parse_boundary_sx()
+ logger.debug(
+ "Boundary loaded: %d pure, %d io, %d services",
+ len(_DECLARED_PURE), len(_DECLARED_IO), len(_DECLARED_HELPERS),
+ )
+ except Exception as e:
+ logger.warning("Failed to load boundary declarations: %s", e)
+ _DECLARED_PURE = frozenset()
+ _DECLARED_IO = frozenset()
+ _DECLARED_HELPERS = {}
+
+
+def _is_strict() -> bool:
+ return os.environ.get("SX_BOUNDARY_STRICT") == "1"
+
+
+def _report(message: str) -> None:
+ if _is_strict():
+ raise RuntimeError(f"SX boundary violation: {message}")
+ else:
+ logger.warning("SX boundary: %s", message)
+
+
+# ---------------------------------------------------------------------------
+# Validation functions
+# ---------------------------------------------------------------------------
+
+def validate_primitive(name: str) -> None:
+ """Validate that a pure primitive is declared in primitives.sx."""
+ _load_declarations()
+ assert _DECLARED_PURE is not None
+ if name not in _DECLARED_PURE:
+ _report(f"Undeclared pure primitive: {name!r}. Add to primitives.sx.")
+
+
+def validate_io(name: str) -> None:
+ """Validate that an I/O primitive is declared in boundary.sx."""
+ _load_declarations()
+ assert _DECLARED_IO is not None
+ if name not in _DECLARED_IO:
+ _report(f"Undeclared I/O primitive: {name!r}. Add to boundary.sx.")
+
+
+def validate_helper(service: str, name: str) -> None:
+ """Validate that a page helper is declared in boundary.sx."""
+ _load_declarations()
+ assert _DECLARED_HELPERS is not None
+ svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
+ if name not in svc_helpers:
+ _report(
+ f"Undeclared page helper: {name!r} for service {service!r}. "
+ f"Add to boundary.sx."
+ )
+
+
+def validate_boundary_value(value: Any, context: str = "") -> None:
+ """Validate that a value is an allowed SX boundary type.
+
+ Allowed: int, float, str, bool, None/NIL, list, dict, SxExpr, StyleValue.
+ NOT allowed: datetime, ORM models, Quart objects, raw callables.
+ """
+ from .types import NIL, StyleValue
+ from .parser import SxExpr
+
+ if value is None or value is NIL:
+ return
+ if isinstance(value, (int, float, str, bool)):
+ return
+ if isinstance(value, SxExpr):
+ return
+ if isinstance(value, StyleValue):
+ return
+ if isinstance(value, list):
+ for item in value:
+ validate_boundary_value(item, context)
+ return
+ if isinstance(value, dict):
+ for k, v in value.items():
+ validate_boundary_value(v, context)
+ return
+
+ type_name = type(value).__name__
+ ctx_msg = f" (in {context})" if context else ""
+ _report(
+ f"Non-SX type crossing boundary{ctx_msg}: {type_name}. "
+ f"Convert to dict/string at the edge."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Declaration accessors (for introspection / bootstrapper use)
+# ---------------------------------------------------------------------------
+
+def declared_pure() -> frozenset[str]:
+ _load_declarations()
+ assert _DECLARED_PURE is not None
+ return _DECLARED_PURE
+
+
+def declared_io() -> frozenset[str]:
+ _load_declarations()
+ assert _DECLARED_IO is not None
+ return _DECLARED_IO
+
+
+def declared_helpers() -> dict[str, frozenset[str]]:
+ _load_declarations()
+ assert _DECLARED_HELPERS is not None
+ return dict(_DECLARED_HELPERS)
diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py
index a137753..c913ce7 100644
--- a/shared/sx/helpers.py
+++ b/shared/sx/helpers.py
@@ -16,6 +16,16 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
+def _sx_fragment(*parts: str) -> SxExpr:
+ """Wrap pre-rendered SX wire format strings in a fragment.
+
+ Infrastructure utility for composing already-serialized SX strings.
+ NOT for building SX from Python data — use sx_call() or _render_to_sx().
+ """
+ joined = " ".join(p for p in parts if p)
+ return SxExpr(f"(<> {joined})") if joined else SxExpr("")
+
+
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
@@ -51,8 +61,7 @@ def _as_sx(val: Any) -> SxExpr | None:
if isinstance(val, SxExpr):
return val if val.source else None
html = str(val)
- escaped = html.replace("\\", "\\\\").replace('"', '\\"')
- return SxExpr(f'(~rich-text :html "{escaped}")')
+ return sx_call("rich-text", html=html)
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
@@ -77,7 +86,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
- return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
+ return _sx_fragment(*parts) if parts else SxExpr("")
async def mobile_root_nav_sx(ctx: dict) -> str:
@@ -130,7 +139,7 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
- return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
+ return _sx_fragment(*parts) if parts else SxExpr("")
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
@@ -158,7 +167,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
- return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
+ return _sx_fragment(*parts) if parts else SxExpr("")
# ---------------------------------------------------------------------------
@@ -265,7 +274,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return await _render_to_sx("header-child-sx",
- id=id, inner=SxExpr(f"(<> {inner_sx})"),
+ id=id, inner=_sx_fragment(inner_sx),
)
@@ -273,7 +282,7 @@ async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx wire format."""
return await _render_to_sx("oob-sx",
- oobs=SxExpr(f"(<> {oobs})") if oobs else None,
+ oobs=_sx_fragment(oobs) if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -294,7 +303,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
if not menu:
menu = await mobile_root_nav_sx(ctx)
body_sx = await _render_to_sx("app-body",
- header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
+ header_rows=_sx_fragment(header_rows) if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -303,7 +312,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
if meta:
# Wrap body + meta in a fragment so sx.js renders both;
# auto-hoist moves meta/title/link elements to
.
- body_sx = "(<> " + meta + " " + body_sx + ")"
+ body_sx = _sx_fragment(meta, body_sx)
return sx_page(ctx, body_sx, meta_html=meta_html)
diff --git a/shared/sx/pages.py b/shared/sx/pages.py
index 4a94b99..8912808 100644
--- a/shared/sx/pages.py
+++ b/shared/sx/pages.py
@@ -79,9 +79,34 @@ def register_page_helpers(service: str, helpers: dict[str, Any]) -> None:
:auth :public
:content (docs-content slug))
"""
+ from .boundary import validate_helper, validate_boundary_value
+ import asyncio
+ import functools
+
+ for name in helpers:
+ validate_helper(service, name)
+
+ # Wrap helpers to validate return values at the boundary
+ wrapped: dict[str, Any] = {}
+ for name, fn in helpers.items():
+ if asyncio.iscoroutinefunction(fn):
+ @functools.wraps(fn)
+ async def _async_wrap(*a, _fn=fn, _name=name, **kw):
+ result = await _fn(*a, **kw)
+ validate_boundary_value(result, context=f"helper {_name!r}")
+ return result
+ wrapped[name] = _async_wrap
+ else:
+ @functools.wraps(fn)
+ def _sync_wrap(*a, _fn=fn, _name=name, **kw):
+ result = _fn(*a, **kw)
+ validate_boundary_value(result, context=f"helper {_name!r}")
+ return result
+ wrapped[name] = _sync_wrap
+
if service not in _PAGE_HELPERS:
_PAGE_HELPERS[service] = {}
- _PAGE_HELPERS[service].update(helpers)
+ _PAGE_HELPERS[service].update(wrapped)
def get_page_helpers(service: str) -> dict[str, Any]:
diff --git a/shared/sx/parser.py b/shared/sx/parser.py
index 1abe667..276aafa 100644
--- a/shared/sx/parser.py
+++ b/shared/sx/parser.py
@@ -83,7 +83,7 @@ class Tokenizer:
COMMENT = re.compile(r";[^\n]*")
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
- KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*")
+ KEYWORD = re.compile(r":[a-zA-Z_~*+\-><=/!?&\[]{1}[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]*")
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
# <> for the fragment symbol, and & for &key/&rest.
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py
index ad74440..942f7b6 100644
--- a/shared/sx/primitives.py
+++ b/shared/sx/primitives.py
@@ -30,6 +30,8 @@ def register_primitive(name: str):
return "".join(str(a) for a in args)
"""
def decorator(fn: Callable) -> Callable:
+ from .boundary import validate_primitive
+ validate_primitive(name)
_PRIMITIVES[name] = fn
return fn
return decorator
@@ -431,60 +433,6 @@ def prim_into(target: Any, coll: Any) -> Any:
raise ValueError(f"into: unsupported target type {type(target).__name__}")
-# ---------------------------------------------------------------------------
-# URL helpers
-# ---------------------------------------------------------------------------
-
-@register_primitive("app-url")
-def prim_app_url(service: str, path: str = "/") -> str:
- """``(app-url "blog" "/my-post/")`` → full URL for service."""
- from shared.infrastructure.urls import app_url
- return app_url(service, path)
-
-
-@register_primitive("url-for")
-def prim_url_for(endpoint: str, **kwargs: Any) -> str:
- """``(url-for "endpoint")`` → quart.url_for."""
- from quart import url_for
- return url_for(endpoint, **kwargs)
-
-
-@register_primitive("asset-url")
-def prim_asset_url(path: str = "") -> str:
- """``(asset-url "/img/logo.png")`` → versioned static URL."""
- from shared.infrastructure.urls import asset_url
- return asset_url(path)
-
-
-@register_primitive("config")
-def prim_config(key: str) -> Any:
- """``(config "key")`` → shared.config.config()[key]."""
- from shared.config import config
- cfg = config()
- return cfg.get(key)
-
-
-@register_primitive("jinja-global")
-def prim_jinja_global(key: str, default: Any = None) -> Any:
- """``(jinja-global "key")`` → current_app.jinja_env.globals[key]."""
- from quart import current_app
- return current_app.jinja_env.globals.get(key, default)
-
-
-@register_primitive("relations-from")
-def prim_relations_from(entity_type: str) -> list[dict]:
- """``(relations-from "page")`` → list of RelationDef dicts."""
- from shared.sx.relations import relations_from
- return [
- {
- "name": d.name, "from_type": d.from_type, "to_type": d.to_type,
- "cardinality": d.cardinality, "nav": d.nav,
- "nav_icon": d.nav_icon, "nav_label": d.nav_label,
- }
- for d in relations_from(entity_type)
- ]
-
-
# ---------------------------------------------------------------------------
# Format helpers
# ---------------------------------------------------------------------------
@@ -520,11 +468,15 @@ def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
@register_primitive("parse-datetime")
def prim_parse_datetime(val: Any) -> Any:
- """``(parse-datetime "2024-01-15T10:00:00")`` → datetime object."""
+ """``(parse-datetime "2024-01-15T10:00:00")`` → ISO string or nil."""
from datetime import datetime
if not val or val is NIL:
return NIL
- return datetime.fromisoformat(str(val))
+ try:
+ dt = datetime.fromisoformat(str(val))
+ return dt.isoformat()
+ except (ValueError, TypeError):
+ return NIL
@register_primitive("split-ids")
@@ -570,13 +522,6 @@ def prim_escape(s: Any) -> str:
return str(_escape(str(s) if s is not None and s is not NIL else ""))
-@register_primitive("route-prefix")
-def prim_route_prefix() -> str:
- """``(route-prefix)`` → service URL prefix for dev/prod routing."""
- from shared.utils import route_prefix
- return route_prefix()
-
-
# ---------------------------------------------------------------------------
# Style primitives
# ---------------------------------------------------------------------------
diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py
index 09b758d..fdcb868 100644
--- a/shared/sx/primitives_io.py
+++ b/shared/sx/primitives_io.py
@@ -59,6 +59,11 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"events-slot-ctx",
"events-ticket-type-ctx",
"market-header-ctx",
+ "app-url",
+ "asset-url",
+ "config",
+ "jinja-global",
+ "relations-from",
})
@@ -258,10 +263,19 @@ def _dto_to_dict(obj: Any) -> dict[str, Any]:
def _convert_result(result: Any) -> Any:
- """Convert a service method result for sx consumption."""
+ """Convert a service method result for sx consumption.
+
+ Converts DTOs/dataclasses to dicts, datetimes to ISO strings,
+ and ensures only SX-typed values cross the boundary.
+ """
if result is None:
from .types import NIL
return NIL
+ if isinstance(result, (int, float, str, bool)):
+ return result
+ # datetime → ISO string at the edge
+ if hasattr(result, "isoformat") and callable(result.isoformat):
+ return result.isoformat()
if isinstance(result, dict):
return {k: _convert_result(v) for k, v in result.items()}
if isinstance(result, tuple):
@@ -273,7 +287,7 @@ def _convert_result(result: Any) -> Any:
return [
_dto_to_dict(item)
if hasattr(item, "__dataclass_fields__") or hasattr(item, "_asdict")
- else item
+ else _convert_result(item)
for item in result
]
return result
@@ -474,14 +488,14 @@ async def _io_account_nav_ctx(
from quart import g
from .types import NIL
from .parser import SxExpr
+ from .helpers import sx_call
val = getattr(g, "account_nav", None)
if not val:
return NIL
if isinstance(val, SxExpr):
return val
# HTML string → wrap for SX rendering
- escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
- return SxExpr(f'(~rich-text :html "{escaped}")')
+ return sx_call("rich-text", html=str(val))
async def _io_app_rights(
@@ -873,6 +887,67 @@ async def _io_events_ticket_type_ctx(
}
+async def _io_app_url(
+ args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
+) -> str:
+ """``(app-url "blog" "/my-post/")`` → full URL for service."""
+ if not args:
+ raise ValueError("app-url requires a service name")
+ from shared.infrastructure.urls import app_url
+ service = str(args[0])
+ path = str(args[1]) if len(args) > 1 else "/"
+ return app_url(service, path)
+
+
+async def _io_asset_url(
+ args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
+) -> str:
+ """``(asset-url "/img/logo.png")`` → versioned static URL."""
+ from shared.infrastructure.urls import asset_url
+ path = str(args[0]) if args else ""
+ return asset_url(path)
+
+
+async def _io_config(
+ args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
+) -> Any:
+ """``(config "key")`` → shared.config.config()[key]."""
+ if not args:
+ raise ValueError("config requires a key")
+ from shared.config import config
+ cfg = config()
+ return cfg.get(str(args[0]))
+
+
+async def _io_jinja_global(
+ args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
+) -> Any:
+ """``(jinja-global "key")`` → current_app.jinja_env.globals[key]."""
+ if not args:
+ raise ValueError("jinja-global requires a key")
+ from quart import current_app
+ key = str(args[0])
+ default = args[1] if len(args) > 1 else None
+ return current_app.jinja_env.globals.get(key, default)
+
+
+async def _io_relations_from(
+ args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
+) -> list[dict]:
+ """``(relations-from "page")`` → list of RelationDef dicts."""
+ if not args:
+ raise ValueError("relations-from requires an entity type")
+ from shared.sx.relations import relations_from
+ return [
+ {
+ "name": d.name, "from_type": d.from_type, "to_type": d.to_type,
+ "cardinality": d.cardinality, "nav": d.nav,
+ "nav_icon": d.nav_icon, "nav_label": d.nav_label,
+ }
+ for d in relations_from(str(args[0]))
+ ]
+
+
async def _io_market_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
@@ -966,4 +1041,20 @@ _IO_HANDLERS: dict[str, Any] = {
"events-slot-ctx": _io_events_slot_ctx,
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
"market-header-ctx": _io_market_header_ctx,
+ "app-url": _io_app_url,
+ "asset-url": _io_asset_url,
+ "config": _io_config,
+ "jinja-global": _io_jinja_global,
+ "relations-from": _io_relations_from,
}
+
+# Validate all I/O handlers are declared in boundary.sx
+def _validate_io_handlers() -> None:
+ from .boundary import validate_io
+ for name in _IO_HANDLERS:
+ validate_io(name)
+ for name in IO_PRIMITIVES:
+ if name not in _IO_HANDLERS:
+ validate_io(name)
+
+_validate_io_handlers()
diff --git a/shared/sx/ref/BOUNDARY.md b/shared/sx/ref/BOUNDARY.md
new file mode 100644
index 0000000..40b4279
--- /dev/null
+++ b/shared/sx/ref/BOUNDARY.md
@@ -0,0 +1,85 @@
+# SX Boundary Enforcement
+
+## Principle
+
+SX is an uninterrupted island of pure evaluation. Host code (Python, JavaScript, Rust, etc.) interacts with it only through declared entry points. The specification enforces this — violations are errors, not style suggestions.
+
+## The Three Tiers
+
+### Tier 1: Pure Primitives
+
+Declared in `primitives.sx`. Stateless, synchronous, no side effects. Available in every SX environment on every target.
+
+Examples: `+`, `str`, `map`, `get`, `concat`, `merge`
+
+### Tier 2: I/O Primitives
+
+Declared in `boundary.sx`. Async, side-effectful, require host context (request, config, services). Server-side only.
+
+Examples: `frag`, `query`, `current-user`, `csrf-token`, `request-arg`
+
+### Tier 3: Page Helpers
+
+Declared in `boundary.sx` with a `:service` scope. Registered per-app, return data that `.sx` components render. Server-side only.
+
+Examples: `highlight` (sx), `editor-data` (blog), `all-markets-data` (market)
+
+## Boundary Types
+
+Only these types may cross the host-SX boundary:
+
+| Type | Python | JavaScript | Rust (future) |
+|------|--------|-----------|----------------|
+| number | `int`, `float` | `number` | `f64` |
+| string | `str` | `string` | `String` |
+| boolean | `bool` | `boolean` | `bool` |
+| nil | `NIL` sentinel | `NIL` sentinel | `SxValue::Nil` |
+| keyword | `str` (colon-prefixed) | `string` | `String` |
+| list | `list` | `Array` | `Vec` |
+| dict | `dict` | `Object` / `Map` | `HashMap` |
+| sx-source | `SxExpr` wrapper | `string` | `String` |
+| style-value | `StyleValue` | `StyleValue` | `StyleValue` |
+
+**NOT allowed:** ORM models, datetime objects, request objects, raw callables, framework types. Convert at the edge before crossing.
+
+## Enforcement Mechanism
+
+The bootstrappers (`bootstrap_js.py`, `bootstrap_py.py`, future `bootstrap_rs.py`, etc.) read `boundary.sx` and emit target-native validation:
+
+- **Typed targets (Rust, Haskell, TypeScript):** Boundary types become an enum/ADT/discriminated union. Registration functions have type signatures that reject non-SX values at compile time. You literally cannot register a primitive that returns a `datetime` — it won't typecheck.
+
+- **Python + mypy:** Boundary types become a `Protocol`/`Union` type. `validate_boundary_value()` checks at runtime; mypy catches most violations statically.
+
+- **JavaScript:** Runtime validation only. `registerPrimitive()` checks the name against the declared set. Boundary type checking is runtime.
+
+## The Contract
+
+1. **Spec-first.** Every primitive, I/O function, and page helper must be declared in `primitives.sx` or `boundary.sx` before it can be registered. Undeclared registration = error.
+
+2. **SX types only.** Values crossing the boundary must be SX-typed. Host-native types (datetime, ORM models, request objects) must be converted to dicts/strings at the edge.
+
+3. **Data in, markup out.** Python returns data (dicts, lists, strings). `.sx` files compose markup. No SX source construction in Python — no f-strings, no string concatenation, no `SxExpr(f"...")`.
+
+4. **Closed island.** SX code can only call symbols in its env + declared primitives. There is no FFI, no `eval-python`, no escape hatch from inside SX.
+
+5. **Fail fast.** Violations are runtime errors (startup crash), not warnings. For typed targets, they're compile errors.
+
+## Adding a New Primitive
+
+1. Add declaration to `primitives.sx` (pure) or `boundary.sx` (I/O / page helper)
+2. Implement in the target language's primitive file
+3. The bootstrapper-emitted validator will accept it on next rebuild/restart
+4. If you skip step 1, the app crashes on startup telling you exactly what's missing
+
+## File Map
+
+```
+shared/sx/ref/
+ primitives.sx — Pure primitive declarations
+ boundary.sx — I/O primitive + page helper + boundary type declarations
+ bootstrap_js.py — JS bootstrapper (reads both, emits validation)
+ bootstrap_py.py — Python bootstrapper (reads both, emits validation)
+ eval.sx — Evaluator spec (symbol resolution, env model)
+ parser.sx — Parser spec
+ render.sx — Renderer spec (shared registries)
+```
diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx
new file mode 100644
index 0000000..308ac1f
--- /dev/null
+++ b/shared/sx/ref/boundary.sx
@@ -0,0 +1,456 @@
+;; ==========================================================================
+;; boundary.sx — SX boundary contract
+;;
+;; Declares everything allowed to cross the host-SX boundary:
+;; I/O primitives (Tier 2) and page helpers (Tier 3).
+;;
+;; Pure primitives (Tier 1) are declared in primitives.sx.
+;; This file declares what primitives.sx does NOT cover:
+;; async/side-effectful host functions that need request context.
+;;
+;; Format:
+;; (define-io-primitive "name"
+;; :params (param1 param2 &key ...)
+;; :returns "type"
+;; :async true
+;; :doc "description"
+;; :context :request)
+;;
+;; (define-page-helper "name"
+;; :params (param1 param2)
+;; :returns "type"
+;; :service "service-name")
+;;
+;; Bootstrappers read this file and emit frozen sets + validation
+;; functions for the target language.
+;; ==========================================================================
+
+
+;; --------------------------------------------------------------------------
+;; Tier 1: Pure primitives — declared in primitives.sx
+;; --------------------------------------------------------------------------
+
+(declare-tier :pure :source "primitives.sx")
+
+
+;; --------------------------------------------------------------------------
+;; Tier 2: I/O primitives — async, side-effectful, need host context
+;; --------------------------------------------------------------------------
+
+(define-io-primitive "frag"
+ :params (service frag-type &key)
+ :returns "string"
+ :async true
+ :doc "Fetch cross-service HTML fragment."
+ :context :request)
+
+(define-io-primitive "query"
+ :params (service query-name &key)
+ :returns "any"
+ :async true
+ :doc "Fetch data from another service via internal HTTP."
+ :context :request)
+
+(define-io-primitive "action"
+ :params (service action-name &key)
+ :returns "any"
+ :async true
+ :doc "Call an action on another service via internal HTTP."
+ :context :request)
+
+(define-io-primitive "current-user"
+ :params ()
+ :returns "dict?"
+ :async true
+ :doc "Current authenticated user dict, or nil."
+ :context :request)
+
+(define-io-primitive "htmx-request?"
+ :params ()
+ :returns "boolean"
+ :async true
+ :doc "True if current request has HX-Request header."
+ :context :request)
+
+(define-io-primitive "service"
+ :params (service-or-method &rest args &key)
+ :returns "any"
+ :async true
+ :doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
+ :context :request)
+
+(define-io-primitive "request-arg"
+ :params (name &rest default)
+ :returns "any"
+ :async true
+ :doc "Read a query string argument from the current request."
+ :context :request)
+
+(define-io-primitive "request-path"
+ :params ()
+ :returns "string"
+ :async true
+ :doc "Current request path."
+ :context :request)
+
+(define-io-primitive "nav-tree"
+ :params ()
+ :returns "list"
+ :async true
+ :doc "Navigation tree as list of node dicts."
+ :context :request)
+
+(define-io-primitive "get-children"
+ :params (&key parent-type parent-id)
+ :returns "list"
+ :async true
+ :doc "Fetch child entities for a parent."
+ :context :request)
+
+(define-io-primitive "g"
+ :params (key)
+ :returns "any"
+ :async true
+ :doc "Read a value from the Quart request-local g object."
+ :context :request)
+
+(define-io-primitive "csrf-token"
+ :params ()
+ :returns "string"
+ :async true
+ :doc "Current CSRF token string."
+ :context :request)
+
+(define-io-primitive "abort"
+ :params (status &rest message)
+ :returns "nil"
+ :async true
+ :doc "Raise HTTP error from SX."
+ :context :request)
+
+(define-io-primitive "url-for"
+ :params (endpoint &key)
+ :returns "string"
+ :async true
+ :doc "Generate URL for a named endpoint."
+ :context :request)
+
+(define-io-primitive "route-prefix"
+ :params ()
+ :returns "string"
+ :async true
+ :doc "Service URL prefix for dev/prod routing."
+ :context :request)
+
+(define-io-primitive "root-header-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
+ :context :request)
+
+(define-io-primitive "post-header-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with post-level header values."
+ :context :request)
+
+(define-io-primitive "select-colours"
+ :params ()
+ :returns "string"
+ :async true
+ :doc "Shared select/hover CSS class string."
+ :context :request)
+
+(define-io-primitive "account-nav-ctx"
+ :params ()
+ :returns "any"
+ :async true
+ :doc "Account nav fragments, or nil."
+ :context :request)
+
+(define-io-primitive "app-rights"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "User rights dict from g.rights."
+ :context :request)
+
+(define-io-primitive "federation-actor-ctx"
+ :params ()
+ :returns "dict?"
+ :async true
+ :doc "Serialized ActivityPub actor dict or nil."
+ :context :request)
+
+(define-io-primitive "request-view-args"
+ :params (key)
+ :returns "any"
+ :async true
+ :doc "Read a URL view argument from the current request."
+ :context :request)
+
+(define-io-primitive "cart-page-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with cart page header values."
+ :context :request)
+
+(define-io-primitive "events-calendar-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with events calendar header values."
+ :context :request)
+
+(define-io-primitive "events-day-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with events day header values."
+ :context :request)
+
+(define-io-primitive "events-entry-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with events entry header values."
+ :context :request)
+
+(define-io-primitive "events-slot-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with events slot header values."
+ :context :request)
+
+(define-io-primitive "events-ticket-type-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with ticket type header values."
+ :context :request)
+
+(define-io-primitive "market-header-ctx"
+ :params ()
+ :returns "dict"
+ :async true
+ :doc "Dict with market header data."
+ :context :request)
+
+;; Moved from primitives.py — these need host context (infra/config/Quart)
+
+(define-io-primitive "app-url"
+ :params (service &rest path)
+ :returns "string"
+ :async false
+ :doc "Full URL for a service: (app-url \"blog\" \"/my-post/\")."
+ :context :config)
+
+(define-io-primitive "asset-url"
+ :params (&rest path)
+ :returns "string"
+ :async false
+ :doc "Versioned static asset URL."
+ :context :config)
+
+(define-io-primitive "config"
+ :params (key)
+ :returns "any"
+ :async false
+ :doc "Read a value from app-config.yaml."
+ :context :config)
+
+(define-io-primitive "jinja-global"
+ :params (key &rest default)
+ :returns "any"
+ :async false
+ :doc "Read a Jinja environment global."
+ :context :request)
+
+(define-io-primitive "relations-from"
+ :params (entity-type)
+ :returns "list"
+ :async false
+ :doc "List of RelationDef dicts for an entity type."
+ :context :config)
+
+
+;; --------------------------------------------------------------------------
+;; Tier 3: Page helpers — service-scoped, registered per app
+;; --------------------------------------------------------------------------
+
+;; SX docs service
+(define-page-helper "highlight"
+ :params (code lang)
+ :returns "sx-source"
+ :service "sx")
+
+(define-page-helper "primitives-data"
+ :params ()
+ :returns "dict"
+ :service "sx")
+
+(define-page-helper "reference-data"
+ :params (slug)
+ :returns "dict"
+ :service "sx")
+
+(define-page-helper "attr-detail-data"
+ :params (slug)
+ :returns "dict"
+ :service "sx")
+
+(define-page-helper "header-detail-data"
+ :params (slug)
+ :returns "dict"
+ :service "sx")
+
+(define-page-helper "event-detail-data"
+ :params (slug)
+ :returns "dict"
+ :service "sx")
+
+(define-page-helper "read-spec-file"
+ :params (filename)
+ :returns "string"
+ :service "sx")
+
+(define-page-helper "bootstrapper-data"
+ :params (target)
+ :returns "dict"
+ :service "sx")
+
+;; Blog service
+(define-page-helper "editor-data"
+ :params (&key)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "editor-page-data"
+ :params (&key)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-admin-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-data-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-preview-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-entries-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-settings-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+(define-page-helper "post-edit-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "blog")
+
+;; Events service
+(define-page-helper "calendar-admin-data"
+ :params (&key calendar-slug)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "day-admin-data"
+ :params (&key calendar-slug year month day)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "slots-data"
+ :params (&key calendar-slug)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "slot-data"
+ :params (&key calendar-slug slot-id)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "entry-data"
+ :params (&key calendar-slug entry-id)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "entry-admin-data"
+ :params (&key calendar-slug entry-id year month day)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "ticket-types-data"
+ :params (&key calendar-slug entry-id year month day)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "ticket-type-data"
+ :params (&key calendar-slug entry-id ticket-type-id year month day)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "tickets-data"
+ :params (&key)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "ticket-detail-data"
+ :params (&key code)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "ticket-admin-data"
+ :params (&key)
+ :returns "dict"
+ :service "events")
+
+(define-page-helper "markets-data"
+ :params (&key)
+ :returns "dict"
+ :service "events")
+
+;; Market service
+(define-page-helper "all-markets-data"
+ :params (&key)
+ :returns "dict"
+ :service "market")
+
+(define-page-helper "page-markets-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "market")
+
+(define-page-helper "page-admin-data"
+ :params (&key slug)
+ :returns "dict"
+ :service "market")
+
+(define-page-helper "market-home-data"
+ :params (&key page-slug market-slug)
+ :returns "dict"
+ :service "market")
+
+
+;; --------------------------------------------------------------------------
+;; Boundary types — what's allowed to cross the host-SX boundary
+;; --------------------------------------------------------------------------
+
+(define-boundary-types
+ (list "number" "string" "boolean" "nil" "keyword"
+ "list" "dict" "sx-source" "style-value"))
diff --git a/shared/sx/ref/boundary_parser.py b/shared/sx/ref/boundary_parser.py
new file mode 100644
index 0000000..ade09b2
--- /dev/null
+++ b/shared/sx/ref/boundary_parser.py
@@ -0,0 +1,107 @@
+"""
+Parse boundary.sx and primitives.sx to extract declared names.
+
+Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
+by the validation module.
+"""
+from __future__ import annotations
+
+import os
+from typing import Any
+
+# Allow standalone use (from bootstrappers) or in-project imports
+try:
+ from shared.sx.parser import parse_all
+ from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
+except ImportError:
+ import sys
+ _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
+
+
+def _ref_dir() -> str:
+ return os.path.dirname(os.path.abspath(__file__))
+
+
+def _read_file(filename: str) -> str:
+ filepath = os.path.join(_ref_dir(), filename)
+ with open(filepath, encoding="utf-8") as f:
+ return f.read()
+
+
+def _extract_keyword_arg(expr: list, key: str) -> Any:
+ """Extract :key value from a flat keyword-arg list."""
+ for i, item in enumerate(expr):
+ if isinstance(item, Keyword) and item.name == key and i + 1 < len(expr):
+ return expr[i + 1]
+ return None
+
+
+def parse_primitives_sx() -> frozenset[str]:
+ """Parse primitives.sx and return frozenset of declared pure primitive names."""
+ source = _read_file("primitives.sx")
+ exprs = parse_all(source)
+ names: set[str] = set()
+ for expr in exprs:
+ if (isinstance(expr, list) and len(expr) >= 2
+ and isinstance(expr[0], Symbol)
+ and expr[0].name == "define-primitive"):
+ name = expr[1]
+ if isinstance(name, str):
+ names.add(name)
+ return frozenset(names)
+
+
+def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
+ """Parse boundary.sx and return (io_names, {service: helper_names}).
+
+ Returns:
+ io_names: frozenset of declared I/O primitive names
+ helpers: dict mapping service name to frozenset of helper names
+ """
+ source = _read_file("boundary.sx")
+ exprs = parse_all(source)
+ io_names: set[str] = set()
+ helpers: dict[str, set[str]] = {}
+
+ for expr in exprs:
+ if not isinstance(expr, list) or not expr:
+ continue
+ head = expr[0]
+ if not isinstance(head, Symbol):
+ continue
+
+ if head.name == "define-io-primitive":
+ name = expr[1]
+ if isinstance(name, str):
+ io_names.add(name)
+
+ elif head.name == "define-page-helper":
+ name = expr[1]
+ service = _extract_keyword_arg(expr, "service")
+ if isinstance(name, str) and isinstance(service, str):
+ helpers.setdefault(service, set()).add(name)
+
+ frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
+ return frozenset(io_names), frozen_helpers
+
+
+def parse_boundary_types() -> frozenset[str]:
+ """Parse boundary.sx and return the declared boundary type names."""
+ source = _read_file("boundary.sx")
+ exprs = parse_all(source)
+ for expr in exprs:
+ if (isinstance(expr, list) and len(expr) >= 2
+ and isinstance(expr[0], Symbol)
+ and expr[0].name == "define-boundary-types"):
+ type_list = expr[1]
+ if isinstance(type_list, list):
+ # (list "number" "string" ...)
+ return frozenset(
+ item for item in type_list
+ if isinstance(item, str)
+ )
+ return frozenset()
diff --git a/shared/sx/ref/cssx.sx b/shared/sx/ref/cssx.sx
index a50fdbe..f1cde6e 100644
--- a/shared/sx/ref/cssx.sx
+++ b/shared/sx/ref/cssx.sx
@@ -185,7 +185,10 @@
(cond
(nil? variant)
- (append! base-decls decls)
+ (if (is-child-selector-atom? base)
+ (append! pseudo-rules
+ (list ">:not(:first-child)" decls))
+ (append! base-decls decls))
(dict-has? _responsive-breakpoints variant)
(append! media-rules
@@ -222,23 +225,23 @@
(fn (mr)
(set! hash-input
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
- (chunk-every media-rules 2))
+ media-rules)
(for-each
(fn (pr)
(set! hash-input
(str hash-input (first pr) "{" (nth pr 1) "}")))
- (chunk-every pseudo-rules 2))
+ pseudo-rules)
(for-each
(fn (kf)
(set! hash-input (str hash-input (nth kf 1))))
- (chunk-every kf-needed 2))
+ kf-needed)
(let ((cn (str "sx-" (hash-style hash-input)))
(sv (make-style-value cn
(join ";" base-decls)
- (chunk-every media-rules 2)
- (chunk-every pseudo-rules 2)
- (chunk-every kf-needed 2))))
+ media-rules
+ pseudo-rules
+ kf-needed)))
(dict-set! _style-cache key sv)
;; Inject CSS rules
(inject-style-value sv atoms)
diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx
index 5c50bf4..1b3c0f5 100644
--- a/shared/sx/ref/parser.sx
+++ b/shared/sx/ref/parser.sx
@@ -4,10 +4,16 @@
;; Defines how SX source text is tokenized and parsed into AST.
;; The parser is intentionally simple — s-expressions need minimal parsing.
;;
+;; Single-pass recursive descent: reads source text directly into AST,
+;; no separate tokenization phase. All mutable cursor state lives inside
+;; the parse closure.
+;;
;; Grammar:
;; program → expr*
-;; expr → atom | list | quote-sugar
+;; expr → atom | list | vector | map | quote-sugar
;; list → '(' expr* ')'
+;; vector → '[' expr* ']' (sugar for list)
+;; map → '{' (key expr)* '}'
;; atom → string | number | keyword | symbol | boolean | nil
;; string → '"' (char | escape)* '"'
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
@@ -15,316 +21,256 @@
;; symbol → ident
;; boolean → 'true' | 'false'
;; nil → 'nil'
-;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
+;; ident → ident-start ident-char*
;; comment → ';' to end of line (discarded)
;;
-;; Dict literal:
-;; {key val ...} → dict object (keys are keywords or expressions)
-;;
;; Quote sugar:
-;; `(expr) → (quasiquote expr)
-;; ,(expr) → (unquote expr)
-;; ,@(expr) → (splice-unquote expr)
+;; `expr → (quasiquote expr)
+;; ,expr → (unquote expr)
+;; ,@expr → (splice-unquote expr)
+;;
+;; Platform interface (each target implements natively):
+;; (ident-start? ch) → boolean
+;; (ident-char? ch) → boolean
+;; (make-symbol name) → Symbol value
+;; (make-keyword name) → Keyword value
+;; (escape-string s) → string with " and \ escaped for serialization
;; ==========================================================================
;; --------------------------------------------------------------------------
-;; Tokenizer
+;; Parser — single-pass recursive descent
;; --------------------------------------------------------------------------
-;; Produces a flat stream of tokens from source text.
-;; Each token is a (type value line col) tuple.
+;; Returns a list of top-level AST expressions.
-(define tokenize
+(define sx-parse
(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
+
+ ;; -- Cursor helpers (closure over pos, source, len-src) --
+
+ (define skip-comment
+ (fn ()
+ (when (and (< pos len-src) (not (= (nth source pos) "\n")))
+ (set! pos (inc pos))
+ (skip-comment))))
+
+ (define skip-ws
(fn ()
(when (< pos len-src)
(let ((ch (nth source pos)))
(cond
- ;; Whitespace — skip
- (whitespace? ch)
- (do (advance-pos!) (scan-next))
-
+ ;; Whitespace
+ (or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
+ (do (set! pos (inc pos)) (skip-ws))
;; Comment — skip to end of line
(= ch ";")
- (do (skip-to-eol!) (scan-next))
+ (do (set! pos (inc pos))
+ (skip-comment)
+ (skip-ws))
+ ;; Not whitespace or comment — stop
+ :else nil)))))
+
+ ;; -- Atom readers --
+
+ (define read-string
+ (fn ()
+ (set! pos (inc pos)) ;; skip opening "
+ (let ((buf ""))
+ (define read-str-loop
+ (fn ()
+ (if (>= pos len-src)
+ (error "Unterminated string")
+ (let ((ch (nth source pos)))
+ (cond
+ (= ch "\"")
+ (do (set! pos (inc pos)) nil) ;; done
+ (= ch "\\")
+ (do (set! pos (inc pos))
+ (let ((esc (nth source pos)))
+ (set! buf (str buf
+ (cond
+ (= esc "n") "\n"
+ (= esc "t") "\t"
+ (= esc "r") "\r"
+ :else esc)))
+ (set! pos (inc pos))
+ (read-str-loop)))
+ :else
+ (do (set! buf (str buf ch))
+ (set! pos (inc pos))
+ (read-str-loop)))))))
+ (read-str-loop)
+ buf)))
+
+ (define read-ident
+ (fn ()
+ (let ((start pos))
+ (define read-ident-loop
+ (fn ()
+ (when (and (< pos len-src)
+ (ident-char? (nth source pos)))
+ (set! pos (inc pos))
+ (read-ident-loop))))
+ (read-ident-loop)
+ (slice source start pos))))
+
+ (define read-keyword
+ (fn ()
+ (set! pos (inc pos)) ;; skip :
+ (make-keyword (read-ident))))
+
+ (define read-number
+ (fn ()
+ (let ((start pos))
+ ;; Optional leading minus
+ (when (and (< pos len-src) (= (nth source pos) "-"))
+ (set! pos (inc pos)))
+ ;; Integer digits
+ (define read-digits
+ (fn ()
+ (when (and (< pos len-src)
+ (let ((c (nth source pos)))
+ (and (>= c "0") (<= c "9"))))
+ (set! pos (inc pos))
+ (read-digits))))
+ (read-digits)
+ ;; Decimal part
+ (when (and (< pos len-src) (= (nth source pos) "."))
+ (set! pos (inc pos))
+ (read-digits))
+ ;; Exponent
+ (when (and (< pos len-src)
+ (or (= (nth source pos) "e")
+ (= (nth source pos) "E")))
+ (set! pos (inc pos))
+ (when (and (< pos len-src)
+ (or (= (nth source pos) "+")
+ (= (nth source pos) "-")))
+ (set! pos (inc pos)))
+ (read-digits))
+ (parse-number (slice source start pos)))))
+
+ (define read-symbol
+ (fn ()
+ (let ((name (read-ident)))
+ (cond
+ (= name "true") true
+ (= name "false") false
+ (= name "nil") nil
+ :else (make-symbol name)))))
+
+ ;; -- Composite readers --
+
+ (define read-list
+ (fn (close-ch)
+ (let ((items (list)))
+ (define read-list-loop
+ (fn ()
+ (skip-ws)
+ (if (>= pos len-src)
+ (error "Unterminated list")
+ (if (= (nth source pos) close-ch)
+ (do (set! pos (inc pos)) nil) ;; done
+ (do (append! items (read-expr))
+ (read-list-loop))))))
+ (read-list-loop)
+ items)))
+
+ (define read-map
+ (fn ()
+ (let ((result (dict)))
+ (define read-map-loop
+ (fn ()
+ (skip-ws)
+ (if (>= pos len-src)
+ (error "Unterminated map")
+ (if (= (nth source pos) "}")
+ (do (set! pos (inc pos)) nil) ;; done
+ (let ((key-expr (read-expr))
+ (key-str (if (= (type-of key-expr) "keyword")
+ (keyword-name key-expr)
+ (str key-expr)))
+ (val-expr (read-expr)))
+ (dict-set! result key-str val-expr)
+ (read-map-loop))))))
+ (read-map-loop)
+ result)))
+
+ ;; -- Main expression reader --
+
+ (define read-expr
+ (fn ()
+ (skip-ws)
+ (if (>= pos len-src)
+ (error "Unexpected end of input")
+ (let ((ch (nth source pos)))
+ (cond
+ ;; Lists
+ (= ch "(")
+ (do (set! pos (inc pos)) (read-list ")"))
+ (= ch "[")
+ (do (set! pos (inc pos)) (read-list "]"))
+
+ ;; Map
+ (= ch "{")
+ (do (set! pos (inc pos)) (read-map))
;; 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))
-
- ;; 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))))
+ (read-string)
;; Keyword
(= ch ":")
- (do (append! tokens (scan-keyword)) (scan-next))
+ (read-keyword)
+
+ ;; Quasiquote sugar
+ (= ch "`")
+ (do (set! pos (inc pos))
+ (list (make-symbol "quasiquote") (read-expr)))
+
+ ;; Unquote / splice-unquote
+ (= ch ",")
+ (do (set! pos (inc pos))
+ (if (and (< pos len-src) (= (nth source pos) "@"))
+ (do (set! pos (inc pos))
+ (list (make-symbol "splice-unquote") (read-expr)))
+ (list (make-symbol "unquote") (read-expr))))
;; 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))
+ (or (and (>= ch "0") (<= ch "9"))
+ (and (= ch "-")
+ (< (inc pos) len-src)
+ (let ((next-ch (nth source (inc pos))))
+ (and (>= next-ch "0") (<= next-ch "9")))))
+ (read-number)
- ;; Symbol
+ ;; Symbol (must be ident-start char)
(ident-start? ch)
- (do (append! tokens (scan-symbol)) (scan-next))
+ (read-symbol)
- ;; Unknown — skip
+ ;; Unexpected
:else
- (do (advance-pos!) (scan-next)))))))
- (scan-next)
- tokens)))
+ (error (str "Unexpected character: " ch)))))))
-
-;; --------------------------------------------------------------------------
-;; 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"))
-
- "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))
- "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)))
-
-
-(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)))
+ ;; -- Entry point: parse all top-level expressions --
+ (let ((exprs (list)))
+ (define parse-loop
+ (fn ()
+ (skip-ws)
+ (when (< pos len-src)
+ (append! exprs (read-expr))
+ (parse-loop))))
+ (parse-loop)
+ exprs))))
;; --------------------------------------------------------------------------
;; Serializer — AST → SX source text
;; --------------------------------------------------------------------------
-(define serialize
+(define sx-serialize
(fn (val)
(case (type-of val)
"nil" "nil"
@@ -333,46 +279,41 @@
"string" (str "\"" (escape-string val) "\"")
"symbol" (symbol-name val)
"keyword" (str ":" (keyword-name val))
- "list" (str "(" (join " " (map serialize val)) ")")
- "dict" (serialize-dict val)
+ "list" (str "(" (join " " (map sx-serialize val)) ")")
+ "dict" (sx-serialize-dict val)
"sx-expr" (sx-expr-source val)
:else (str val))))
-(define serialize-dict
+(define sx-serialize-dict
(fn (d)
- (str "(dict "
+ (str "{"
(join " "
(reduce
(fn (acc key)
- (concat acc (list (str ":" key) (serialize (dict-get d key)))))
+ (concat acc (list (str ":" key) (sx-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, ., :)
+;; Character classification (implemented natively per target):
+;; (ident-start? ch) → boolean
+;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
;;
-;; Constructors:
-;; (make-symbol name) → Symbol value
-;; (make-keyword name) → Keyword value
-;; (parse-number s) → number (int or float from string)
+;; (ident-char? ch) → boolean
+;; True for: ident-start chars plus: 0-9 . : / [ ] # ,
+;;
+;; Constructors (provided by the SX runtime):
+;; (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
+;; (escape-string s) → string with " and \ escaped
+;; (sx-expr-source e) → unwrap SxExpr to its source string
;; --------------------------------------------------------------------------
diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx
index 7aea3bb..efa95b4 100644
--- a/shared/sx/ref/primitives.sx
+++ b/shared/sx/ref/primitives.sx
@@ -443,6 +443,16 @@
:doc "Split comma-separated ID string into list of trimmed non-empty strings.")
+;; --------------------------------------------------------------------------
+;; Assertions
+;; --------------------------------------------------------------------------
+
+(define-primitive "assert"
+ :params (condition &rest message)
+ :returns "boolean"
+ :doc "Assert condition is truthy; raise error with message if not.")
+
+
;; --------------------------------------------------------------------------
;; CSSX — style system primitives
;; --------------------------------------------------------------------------
diff --git a/shared/sx/style_dict.py b/shared/sx/style_dict.py
index 199676f..39bedc3 100644
--- a/shared/sx/style_dict.py
+++ b/shared/sx/style_dict.py
@@ -33,6 +33,7 @@ STYLE_ATOMS: dict[str, str] = {
"flex": "display:flex",
"inline-flex": "display:inline-flex",
"table": "display:table",
+ "table-row": "display:table-row",
"grid": "display:grid",
"contents": "display:contents",
"hidden": "display:none",
@@ -84,6 +85,7 @@ STYLE_ATOMS: dict[str, str] = {
"flex-row": "flex-direction:row",
"flex-col": "flex-direction:column",
"flex-wrap": "flex-wrap:wrap",
+ "flex-0": "flex:0",
"flex-1": "flex:1 1 0%",
"flex-shrink-0": "flex-shrink:0",
"shrink-0": "flex-shrink:0",
@@ -149,6 +151,7 @@ STYLE_ATOMS: dict[str, str] = {
"mt-2": "margin-top:.5rem",
"mt-3": "margin-top:.75rem",
"mt-4": "margin-top:1rem",
+ "mt-5": "margin-top:1.25rem",
"mt-6": "margin-top:1.5rem",
"mt-8": "margin-top:2rem",
"mt-[8px]": "margin-top:8px",
@@ -196,6 +199,7 @@ STYLE_ATOMS: dict[str, str] = {
"pb-8": "padding-bottom:2rem",
"pb-[48px]": "padding-bottom:48px",
"pl-2": "padding-left:.5rem",
+ "pl-3": "padding-left:.75rem",
"pl-5": "padding-left:1.25rem",
"pl-6": "padding-left:1.5rem",
"pr-1": "padding-right:.25rem",
@@ -216,11 +220,15 @@ STYLE_ATOMS: dict[str, str] = {
"w-10": "width:2.5rem",
"w-11": "width:2.75rem",
"w-12": "width:3rem",
+ "w-14": "width:3.5rem",
"w-16": "width:4rem",
"w-20": "width:5rem",
"w-24": "width:6rem",
"w-28": "width:7rem",
+ "w-32": "width:8rem",
+ "w-40": "width:10rem",
"w-48": "width:12rem",
+ "w-56": "width:14rem",
"w-1/2": "width:50%",
"w-1/3": "width:33.333333%",
"w-1/4": "width:25%",
@@ -241,6 +249,7 @@ STYLE_ATOMS: dict[str, str] = {
"h-10": "height:2.5rem",
"h-12": "height:3rem",
"h-14": "height:3.5rem",
+ "h-14": "height:3.5rem",
"h-16": "height:4rem",
"h-24": "height:6rem",
"h-28": "height:7rem",
@@ -268,11 +277,15 @@ STYLE_ATOMS: dict[str, str] = {
"max-w-3xl": "max-width:48rem",
"max-w-4xl": "max-width:56rem",
"max-w-full": "max-width:100%",
+ "max-w-0": "max-width:0",
"max-w-none": "max-width:none",
"max-w-screen-2xl": "max-width:1536px",
"max-w-[360px]": "max-width:360px",
"max-w-[768px]": "max-width:768px",
+ "max-w-[640px]": "max-width:640px",
+ "max-h-32": "max-height:8rem",
"max-h-64": "max-height:16rem",
+ "max-h-72": "max-height:18rem",
"max-h-96": "max-height:24rem",
"max-h-none": "max-height:none",
"max-h-[448px]": "max-height:448px",
@@ -282,6 +295,7 @@ STYLE_ATOMS: dict[str, str] = {
"text-xs": "font-size:.75rem;line-height:1rem",
"text-sm": "font-size:.875rem;line-height:1.25rem",
"text-base": "font-size:1rem;line-height:1.5rem",
+ "text-md": "font-size:1rem;line-height:1.5rem", # alias for text-base
"text-lg": "font-size:1.125rem;line-height:1.75rem",
"text-xl": "font-size:1.25rem;line-height:1.75rem",
"text-2xl": "font-size:1.5rem;line-height:2rem",
@@ -345,6 +359,7 @@ STYLE_ATOMS: dict[str, str] = {
"text-rose-500": "color:rgb(244 63 94)",
"text-rose-600": "color:rgb(225 29 72)",
"text-rose-700": "color:rgb(190 18 60)",
+ "text-rose-800": "color:rgb(159 18 57)",
"text-rose-800/80": "color:rgba(159,18,57,.8)",
"text-rose-900": "color:rgb(136 19 55)",
"text-orange-600": "color:rgb(234 88 12)",
@@ -355,6 +370,10 @@ STYLE_ATOMS: dict[str, str] = {
"text-yellow-700": "color:rgb(161 98 7)",
"text-green-600": "color:rgb(22 163 74)",
"text-green-800": "color:rgb(22 101 52)",
+ "text-green-900": "color:rgb(20 83 45)",
+ "text-neutral-400": "color:rgb(163 163 163)",
+ "text-neutral-500": "color:rgb(115 115 115)",
+ "text-neutral-600": "color:rgb(82 82 82)",
"text-emerald-500": "color:rgb(16 185 129)",
"text-emerald-600": "color:rgb(5 150 105)",
"text-emerald-700": "color:rgb(4 120 87)",
@@ -371,6 +390,7 @@ STYLE_ATOMS: dict[str, str] = {
"text-violet-600": "color:rgb(124 58 237)",
"text-violet-700": "color:rgb(109 40 217)",
"text-violet-800": "color:rgb(91 33 182)",
+ "text-violet-900": "color:rgb(76 29 149)",
# ── Background Colors ────────────────────────────────────────────────
"bg-transparent": "background-color:transparent",
@@ -413,6 +433,9 @@ STYLE_ATOMS: dict[str, str] = {
"bg-yellow-300": "background-color:rgb(253 224 71)",
"bg-green-50": "background-color:rgb(240 253 244)",
"bg-green-100": "background-color:rgb(220 252 231)",
+ "bg-green-200": "background-color:rgb(187 247 208)",
+ "bg-neutral-50/70": "background-color:rgba(250,250,250,.7)",
+ "bg-black/70": "background-color:rgba(0,0,0,.7)",
"bg-emerald-50": "background-color:rgb(236 253 245)",
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
"bg-emerald-100": "background-color:rgb(209 250 229)",
@@ -435,6 +458,12 @@ STYLE_ATOMS: dict[str, str] = {
"bg-violet-400": "background-color:rgb(167 139 250)",
"bg-violet-500": "background-color:rgb(139 92 246)",
"bg-violet-600": "background-color:rgb(124 58 237)",
+ "bg-violet-700": "background-color:rgb(109 40 217)",
+ "bg-amber-200": "background-color:rgb(253 230 138)",
+ "bg-blue-700": "background-color:rgb(29 78 216)",
+ "bg-emerald-700": "background-color:rgb(4 120 87)",
+ "bg-purple-700": "background-color:rgb(126 34 206)",
+ "bg-stone-50/60": "background-color:rgba(250,250,249,.6)",
# ── Border ───────────────────────────────────────────────────────────
"border": "border-width:1px",
@@ -445,6 +474,7 @@ STYLE_ATOMS: dict[str, str] = {
"border-b": "border-bottom-width:1px",
"border-b-2": "border-bottom-width:2px",
"border-r": "border-right-width:1px",
+ "border-l": "border-left-width:1px",
"border-l-4": "border-left-width:4px",
"border-dashed": "border-style:dashed",
"border-none": "border-style:none",
@@ -472,6 +502,9 @@ STYLE_ATOMS: dict[str, str] = {
"border-violet-200": "border-color:rgb(221 214 254)",
"border-violet-300": "border-color:rgb(196 181 253)",
"border-violet-400": "border-color:rgb(167 139 250)",
+ "border-neutral-200": "border-color:rgb(229 229 229)",
+ "border-red-400": "border-color:rgb(248 113 113)",
+ "border-stone-400": "border-color:rgb(168 162 158)",
"border-t-white": "border-top-color:rgb(255 255 255)",
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
"border-l-stone-400": "border-left-color:rgb(168 162 158)",
@@ -499,17 +532,26 @@ STYLE_ATOMS: dict[str, str] = {
"opacity-0": "opacity:0",
"opacity-40": "opacity:.4",
"opacity-50": "opacity:.5",
+ "opacity-90": "opacity:.9",
"opacity-100": "opacity:1",
# ── Ring / Outline ───────────────────────────────────────────────────
"outline-none": "outline:2px solid transparent;outline-offset:2px",
"ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))",
"ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))",
+ "ring-stone-300": "--tw-ring-color:rgb(214 211 209)",
+ "ring-stone-500": "--tw-ring-color:rgb(120 113 108)",
+ "ring-violet-500": "--tw-ring-color:rgb(139 92 246)",
+ "ring-blue-500": "--tw-ring-color:rgb(59 130 246)",
+ "ring-green-500": "--tw-ring-color:rgb(22 163 74)",
+ "ring-purple-500": "--tw-ring-color:rgb(147 51 234)",
# ── Overflow ─────────────────────────────────────────────────────────
"overflow-hidden": "overflow:hidden",
"overflow-x-auto": "overflow-x:auto",
"overflow-y-auto": "overflow-y:auto",
+ "overflow-visible": "overflow:visible",
+ "overflow-y-visible": "overflow-y:visible",
"overscroll-contain": "overscroll-behavior:contain",
# ── Text Decoration ──────────────────────────────────────────────────
@@ -655,8 +697,13 @@ PSEUDO_VARIANTS: dict[str, str] = {
"placeholder": "::placeholder",
"file": "::file-selector-button",
"aria-selected": "[aria-selected=true]",
+ "invalid": ":invalid",
+ "placeholder-shown": ":placeholder-shown",
"group-hover": ":is(.group:hover) &",
"group-open": ":is(.group[open]) &",
+ "group-open/cat": ":is(.group\\/cat[open]) &",
+ "group-open/filter": ":is(.group\\/filter[open]) &",
+ "group-open/root": ":is(.group\\/root[open]) &",
}
diff --git a/sx/content/highlight.py b/sx/content/highlight.py
index e8f8ef4..40e0615 100644
--- a/sx/content/highlight.py
+++ b/sx/content/highlight.py
@@ -238,7 +238,7 @@ def _tokenize_bash(code: str) -> list[tuple[str, str]]:
def highlight(code: str, language: str = "lisp"):
"""Highlight code in the given language. Returns SxExpr for wire format."""
- from shared.sx.parser import SxExpr
+ from shared.sx.parser import SxExpr, serialize
if language in ("lisp", "sx", "sexp"):
return SxExpr(highlight_sx(code))
elif language in ("python", "py"):
@@ -246,5 +246,4 @@ def highlight(code: str, language: str = "lisp"):
elif language in ("bash", "sh", "shell"):
return SxExpr(highlight_bash(code))
# Fallback: no highlighting, just escaped text
- escaped = code.replace("\\", "\\\\").replace('"', '\\"')
- return SxExpr(f'(span "{escaped}")')
+ return SxExpr("(span " + serialize(code) + ")")
diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx
index eae4de0..6a25367 100644
--- a/sx/sx/nav-data.sx
+++ b/sx/sx/nav-data.sx
@@ -97,7 +97,8 @@
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/")
- (dict :label "JavaScript" :href "/bootstrappers/javascript")))
+ (dict :label "JavaScript" :href "/bootstrappers/javascript")
+ (dict :label "Python" :href "/bootstrappers/python")))
;; Spec file registry — canonical metadata for spec viewer pages.
;; Python only handles file I/O (read-spec-file); all metadata lives here.
diff --git a/sx/sx/specs.sx b/sx/sx/specs.sx
index 08d02dc..b022ca0 100644
--- a/sx/sx/specs.sx
+++ b/sx/sx/specs.sx
@@ -268,9 +268,11 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render")))
(td :class "px-3 py-2 text-green-600" "Live"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Python")
- (td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_py.py")
- (td :class "px-3 py-2 font-mono text-sm text-stone-400" "shared/sx/")
- (td :class "px-3 py-2 text-stone-400" "Planned"))
+ (td :class "px-3 py-2 font-mono text-sm text-violet-700"
+ (a :href "/bootstrappers/python" :class "hover:underline"
+ "bootstrap_py.py"))
+ (td :class "px-3 py-2 font-mono text-sm text-stone-500" "sx_ref.py")
+ (td :class "px-3 py-2 text-green-600" "Live"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Rust")
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "bootstrap_rs.py")
@@ -320,6 +322,47 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render")))
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
(code (highlight bootstrapped-output "javascript"))))))))
+;; ---------------------------------------------------------------------------
+;; Python bootstrapper detail
+;; ---------------------------------------------------------------------------
+
+(defcomp ~bootstrapper-py-content (&key bootstrapper-source bootstrapped-output)
+ (~doc-page :title "Python Bootstrapper"
+ (div :class "space-y-8"
+
+ (div :class "space-y-3"
+ (p :class "text-stone-600"
+ "This page reads the canonical " (code :class "text-violet-700 text-sm" ".sx")
+ " spec files, runs the Python bootstrapper, and displays both the compiler source and its generated Python output. "
+ "The generated code below is live — it was produced by the bootstrapper at page load time.")
+ (p :class "text-xs text-stone-400 italic"
+ "With SX_USE_REF=1, the server-side SX evaluator running this page IS the bootstrapped output. "
+ "This page re-runs the bootstrapper to display the source and result."))
+
+ (div :class "space-y-3"
+ (div :class "flex items-baseline gap-3"
+ (h2 :class "text-2xl font-semibold text-stone-800" "Bootstrapper")
+ (span :class "text-sm text-stone-400 font-mono" "bootstrap_py.py"))
+ (p :class "text-sm text-stone-500"
+ "The compiler reads " (code :class "text-violet-700 text-sm" ".sx")
+ " spec files (eval, primitives, render, adapter-html) "
+ "and emits a standalone Python module. Platform bridge functions (type constructors, environment ops) "
+ "are emitted as native Python implementations.")
+ (div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-stone-200"
+ (pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
+ (code (highlight bootstrapper-source "python")))))
+
+ (div :class "space-y-3"
+ (div :class "flex items-baseline gap-3"
+ (h2 :class "text-2xl font-semibold text-stone-800" "Generated Output")
+ (span :class "text-sm text-stone-400 font-mono" "sx_ref.py"))
+ (p :class "text-sm text-stone-500"
+ "The Python below was generated by running the bootstrapper against the current spec files. "
+ "It is a complete server-side SX evaluator — eval, primitives, and HTML renderer.")
+ (div :class "bg-stone-100 rounded-lg p-5 max-h-96 overflow-y-auto border border-violet-300"
+ (pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
+ (code (highlight bootstrapped-output "python"))))))))
+
;; ---------------------------------------------------------------------------
;; Not found
;; ---------------------------------------------------------------------------
diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx
index c388b3e..6bd6213 100644
--- a/sx/sxc/pages/docs.sx
+++ b/sx/sxc/pages/docs.sx
@@ -368,6 +368,10 @@
:data (bootstrapper-data slug)
:content (if bootstrapper-not-found
(~spec-not-found :slug slug)
- (~bootstrapper-js-content
- :bootstrapper-source bootstrapper-source
- :bootstrapped-output bootstrapped-output)))
+ (if (= slug "python")
+ (~bootstrapper-py-content
+ :bootstrapper-source bootstrapper-source
+ :bootstrapped-output bootstrapped-output)
+ (~bootstrapper-js-content
+ :bootstrapper-source bootstrapper-source
+ :bootstrapped-output bootstrapped-output))))
diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py
index 51bc168..997abe6 100644
--- a/sx/sxc/pages/helpers.py
+++ b/sx/sxc/pages/helpers.py
@@ -135,29 +135,44 @@ def _bootstrapper_data(target: str) -> dict:
"""
import os
- if target != "javascript":
+ if target not in ("javascript", "python"):
return {"bootstrapper-not-found": True}
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
- # Read bootstrapper source
- bs_path = os.path.join(ref_dir, "bootstrap_js.py")
- try:
- with open(bs_path, encoding="utf-8") as f:
- bootstrapper_source = f.read()
- except FileNotFoundError:
- bootstrapper_source = "# bootstrapper source not found"
+ if target == "javascript":
+ # Read bootstrapper source
+ bs_path = os.path.join(ref_dir, "bootstrap_js.py")
+ try:
+ with open(bs_path, encoding="utf-8") as f:
+ bootstrapper_source = f.read()
+ except FileNotFoundError:
+ bootstrapper_source = "# bootstrapper source not found"
- # Run the bootstrap to generate JS
- from shared.sx.ref.bootstrap_js import compile_ref_to_js
- try:
- bootstrapped_output = compile_ref_to_js(
- adapters=["dom", "engine", "orchestration", "boot", "cssx"]
- )
- except Exception as e:
- bootstrapped_output = f"// bootstrap error: {e}"
+ # Run the bootstrap to generate JS
+ from shared.sx.ref.bootstrap_js import compile_ref_to_js
+ try:
+ bootstrapped_output = compile_ref_to_js(
+ adapters=["dom", "engine", "orchestration", "boot", "cssx"]
+ )
+ except Exception as e:
+ bootstrapped_output = f"// bootstrap error: {e}"
+
+ elif target == "python":
+ bs_path = os.path.join(ref_dir, "bootstrap_py.py")
+ try:
+ with open(bs_path, encoding="utf-8") as f:
+ bootstrapper_source = f.read()
+ except FileNotFoundError:
+ bootstrapper_source = "# bootstrapper source not found"
+
+ from shared.sx.ref.bootstrap_py import compile_ref_to_py
+ try:
+ bootstrapped_output = compile_ref_to_py()
+ except Exception as e:
+ bootstrapped_output = f"# bootstrap error: {e}"
return {
"bootstrapper-not-found": None,
@@ -176,7 +191,7 @@ def _attr_detail_data(slug: str) -> dict:
- attr-not-found (truthy if not found)
"""
from content.pages import ATTR_DETAILS
- from shared.sx.helpers import SxExpr
+ from shared.sx.helpers import sx_call
detail = ATTR_DETAILS.get(slug)
if not detail:
@@ -193,7 +208,7 @@ def _attr_detail_data(slug: str) -> dict:
"attr-description": detail["description"],
"attr-example": detail["example"],
"attr-handler": detail.get("handler"),
- "attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
+ "attr-demo": sx_call(demo_name) if demo_name else None,
"attr-wire-id": wire_id,
}
@@ -201,7 +216,7 @@ def _attr_detail_data(slug: str) -> dict:
def _header_detail_data(slug: str) -> dict:
"""Return header detail data for a specific header slug."""
from content.pages import HEADER_DETAILS
- from shared.sx.helpers import SxExpr
+ from shared.sx.helpers import sx_call
detail = HEADER_DETAILS.get(slug)
if not detail:
@@ -214,14 +229,14 @@ def _header_detail_data(slug: str) -> dict:
"header-direction": detail["direction"],
"header-description": detail["description"],
"header-example": detail.get("example"),
- "header-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
+ "header-demo": sx_call(demo_name) if demo_name else None,
}
def _event_detail_data(slug: str) -> dict:
"""Return event detail data for a specific event slug."""
from content.pages import EVENT_DETAILS
- from shared.sx.helpers import SxExpr
+ from shared.sx.helpers import sx_call
detail = EVENT_DETAILS.get(slug)
if not detail:
@@ -233,5 +248,5 @@ def _event_detail_data(slug: str) -> dict:
"event-title": slug,
"event-description": detail["description"],
"event-example": detail.get("example"),
- "event-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
+ "event-demo": sx_call(demo_name) if demo_name else None,
}