Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
144
shared/sx/boundary.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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_~*+\-><=/!?.:&]*")
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
85
shared/sx/ref/BOUNDARY.md
Normal 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
456
shared/sx/ref/boundary.sx
Normal 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"))
|
||||
107
shared/sx/ref/boundary_parser.py
Normal file
107
shared/sx/ref/boundary_parser.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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]) &",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) + ")")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user