Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s

Add boundary.sx declaring all 34 I/O primitives, 32 page helpers, and 9
allowed boundary types. Runtime validation in boundary.py checks every
registration against the spec — undeclared primitives/helpers crash at
startup with SX_BOUNDARY_STRICT=1 (now set in both dev and prod).

Key changes:
- Move 5 I/O-in-disguise primitives (app-url, asset-url, config,
  jinja-global, relations-from) from primitives.py to primitives_io.py
- Remove duplicate url-for/route-prefix from primitives.py (already in IO)
- Fix parse-datetime to return ISO string instead of raw datetime
- Add datetime→isoformat conversion in _convert_result at the edge
- Wrap page helper return values with boundary type validation
- Replace all SxExpr(f"...") patterns with sx_call() or _sx_fragment()
- Add assert declaration to primitives.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 23:50:02 +00:00
parent 54adc9c216
commit 04366990ec
21 changed files with 1342 additions and 415 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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) {

144
shared/sx/boundary.py Normal file
View File

@@ -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)

View File

@@ -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 <head>.
body_sx = "(<> " + meta + " " + body_sx + ")"
body_sx = _sx_fragment(meta, body_sx)
return sx_page(ctx, body_sx, meta_html=meta_html)

View File

@@ -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]:

View File

@@ -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_~*+\-><=/!?.:&]*")

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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()

85
shared/sx/ref/BOUNDARY.md Normal file
View File

@@ -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<SxValue>` |
| dict | `dict` | `Object` / `Map` | `HashMap<String, SxValue>` |
| 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)
```

456
shared/sx/ref/boundary.sx Normal file
View File

@@ -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"))

View File

@@ -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()

View File

@@ -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)

View File

@@ -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
;; --------------------------------------------------------------------------

View File

@@ -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
;; --------------------------------------------------------------------------

View File

@@ -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]) &",
}

View File

@@ -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) + ")")

View File

@@ -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.

View File

@@ -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
;; ---------------------------------------------------------------------------

View File

@@ -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))))

View File

@@ -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,
}