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