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"
|
RELOAD: "true"
|
||||||
WORKERS: "1"
|
WORKERS: "1"
|
||||||
SX_USE_REF: "1"
|
SX_USE_REF: "1"
|
||||||
|
SX_BOUNDARY_STRICT: "1"
|
||||||
|
|
||||||
x-sibling-models: &sibling-models
|
x-sibling-models: &sibling-models
|
||||||
# Every app needs all sibling __init__.py + models/ for cross-domain SQLAlchemy imports
|
# 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_MARKET: market.rose-ash.com
|
||||||
AP_DOMAIN_EVENTS: events.rose-ash.com
|
AP_DOMAIN_EVENTS: events.rose-ash.com
|
||||||
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
|
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
|
||||||
|
SX_BOUNDARY_STRICT: "1"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
blog:
|
blog:
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
var RE_COMMENT = /;[^\n]*/y;
|
var RE_COMMENT = /;[^\n]*/y;
|
||||||
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/y;
|
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/y;
|
||||||
var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/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;
|
var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
|
||||||
|
|
||||||
function Tokenizer(text) {
|
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
|
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:
|
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||||
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
||||||
fn = ctx.get(key)
|
fn = ctx.get(key)
|
||||||
@@ -51,8 +61,7 @@ def _as_sx(val: Any) -> SxExpr | None:
|
|||||||
if isinstance(val, SxExpr):
|
if isinstance(val, SxExpr):
|
||||||
return val if val.source else None
|
return val if val.source else None
|
||||||
html = str(val)
|
html = str(val)
|
||||||
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
|
return sx_call("rich-text", html=html)
|
||||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
|
||||||
|
|
||||||
|
|
||||||
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
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:
|
def mobile_menu_sx(*sections: str) -> SxExpr:
|
||||||
"""Assemble mobile menu from pre-built sections (deepest first)."""
|
"""Assemble mobile menu from pre-built sections (deepest first)."""
|
||||||
parts = [s for s in sections if s]
|
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:
|
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)
|
is_admin_page=is_admin_page or None)
|
||||||
if admin_nav:
|
if admin_nav:
|
||||||
parts.append(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,
|
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,
|
parts.append(await _render_to_sx("nav-link", href=href, label=label,
|
||||||
select_colours=select_colours,
|
select_colours=select_colours,
|
||||||
is_selected=is_sel or None))
|
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:
|
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
|
||||||
"""Wrap inner sx in a header-child div."""
|
"""Wrap inner sx in a header-child div."""
|
||||||
return await _render_to_sx("header-child-sx",
|
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:
|
content: str = "", menu: str = "") -> str:
|
||||||
"""Build OOB response as sx wire format."""
|
"""Build OOB response as sx wire format."""
|
||||||
return await _render_to_sx("oob-sx",
|
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,
|
filter=SxExpr(filter) if filter else None,
|
||||||
aside=SxExpr(aside) if aside else None,
|
aside=SxExpr(aside) if aside else None,
|
||||||
menu=SxExpr(menu) if menu 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:
|
if not menu:
|
||||||
menu = await mobile_root_nav_sx(ctx)
|
menu = await mobile_root_nav_sx(ctx)
|
||||||
body_sx = await _render_to_sx("app-body",
|
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,
|
filter=SxExpr(filter) if filter else None,
|
||||||
aside=SxExpr(aside) if aside else None,
|
aside=SxExpr(aside) if aside else None,
|
||||||
menu=SxExpr(menu) if menu 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:
|
if meta:
|
||||||
# Wrap body + meta in a fragment so sx.js renders both;
|
# Wrap body + meta in a fragment so sx.js renders both;
|
||||||
# auto-hoist moves meta/title/link elements to <head>.
|
# 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)
|
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
|
:auth :public
|
||||||
:content (docs-content slug))
|
: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:
|
if service not in _PAGE_HELPERS:
|
||||||
_PAGE_HELPERS[service] = {}
|
_PAGE_HELPERS[service] = {}
|
||||||
_PAGE_HELPERS[service].update(helpers)
|
_PAGE_HELPERS[service].update(wrapped)
|
||||||
|
|
||||||
|
|
||||||
def get_page_helpers(service: str) -> dict[str, Any]:
|
def get_page_helpers(service: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class Tokenizer:
|
|||||||
COMMENT = re.compile(r";[^\n]*")
|
COMMENT = re.compile(r";[^\n]*")
|
||||||
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||||
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
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,
|
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
|
||||||
# <> for the fragment symbol, and & for &key/&rest.
|
# <> for the fragment symbol, and & for &key/&rest.
|
||||||
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
|
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)
|
return "".join(str(a) for a in args)
|
||||||
"""
|
"""
|
||||||
def decorator(fn: Callable) -> Callable:
|
def decorator(fn: Callable) -> Callable:
|
||||||
|
from .boundary import validate_primitive
|
||||||
|
validate_primitive(name)
|
||||||
_PRIMITIVES[name] = fn
|
_PRIMITIVES[name] = fn
|
||||||
return fn
|
return fn
|
||||||
return decorator
|
return decorator
|
||||||
@@ -431,60 +433,6 @@ def prim_into(target: Any, coll: Any) -> Any:
|
|||||||
raise ValueError(f"into: unsupported target type {type(target).__name__}")
|
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
|
# Format helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -520,11 +468,15 @@ def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
|
|||||||
|
|
||||||
@register_primitive("parse-datetime")
|
@register_primitive("parse-datetime")
|
||||||
def prim_parse_datetime(val: Any) -> Any:
|
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
|
from datetime import datetime
|
||||||
if not val or val is NIL:
|
if not val or val is NIL:
|
||||||
return 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")
|
@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 ""))
|
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
|
# Style primitives
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
|||||||
"events-slot-ctx",
|
"events-slot-ctx",
|
||||||
"events-ticket-type-ctx",
|
"events-ticket-type-ctx",
|
||||||
"market-header-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:
|
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:
|
if result is None:
|
||||||
from .types import NIL
|
from .types import NIL
|
||||||
return 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):
|
if isinstance(result, dict):
|
||||||
return {k: _convert_result(v) for k, v in result.items()}
|
return {k: _convert_result(v) for k, v in result.items()}
|
||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
@@ -273,7 +287,7 @@ def _convert_result(result: Any) -> Any:
|
|||||||
return [
|
return [
|
||||||
_dto_to_dict(item)
|
_dto_to_dict(item)
|
||||||
if hasattr(item, "__dataclass_fields__") or hasattr(item, "_asdict")
|
if hasattr(item, "__dataclass_fields__") or hasattr(item, "_asdict")
|
||||||
else item
|
else _convert_result(item)
|
||||||
for item in result
|
for item in result
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
@@ -474,14 +488,14 @@ async def _io_account_nav_ctx(
|
|||||||
from quart import g
|
from quart import g
|
||||||
from .types import NIL
|
from .types import NIL
|
||||||
from .parser import SxExpr
|
from .parser import SxExpr
|
||||||
|
from .helpers import sx_call
|
||||||
val = getattr(g, "account_nav", None)
|
val = getattr(g, "account_nav", None)
|
||||||
if not val:
|
if not val:
|
||||||
return NIL
|
return NIL
|
||||||
if isinstance(val, SxExpr):
|
if isinstance(val, SxExpr):
|
||||||
return val
|
return val
|
||||||
# HTML string → wrap for SX rendering
|
# HTML string → wrap for SX rendering
|
||||||
escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
|
return sx_call("rich-text", html=str(val))
|
||||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
|
||||||
|
|
||||||
|
|
||||||
async def _io_app_rights(
|
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(
|
async def _io_market_header_ctx(
|
||||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -966,4 +1041,20 @@ _IO_HANDLERS: dict[str, Any] = {
|
|||||||
"events-slot-ctx": _io_events_slot_ctx,
|
"events-slot-ctx": _io_events_slot_ctx,
|
||||||
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
|
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
|
||||||
"market-header-ctx": _io_market_header_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
|
(cond
|
||||||
(nil? variant)
|
(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)
|
(dict-has? _responsive-breakpoints variant)
|
||||||
(append! media-rules
|
(append! media-rules
|
||||||
@@ -222,23 +225,23 @@
|
|||||||
(fn (mr)
|
(fn (mr)
|
||||||
(set! hash-input
|
(set! hash-input
|
||||||
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
|
(str hash-input "@" (first mr) "{" (nth mr 1) "}")))
|
||||||
(chunk-every media-rules 2))
|
media-rules)
|
||||||
(for-each
|
(for-each
|
||||||
(fn (pr)
|
(fn (pr)
|
||||||
(set! hash-input
|
(set! hash-input
|
||||||
(str hash-input (first pr) "{" (nth pr 1) "}")))
|
(str hash-input (first pr) "{" (nth pr 1) "}")))
|
||||||
(chunk-every pseudo-rules 2))
|
pseudo-rules)
|
||||||
(for-each
|
(for-each
|
||||||
(fn (kf)
|
(fn (kf)
|
||||||
(set! hash-input (str hash-input (nth kf 1))))
|
(set! hash-input (str hash-input (nth kf 1))))
|
||||||
(chunk-every kf-needed 2))
|
kf-needed)
|
||||||
|
|
||||||
(let ((cn (str "sx-" (hash-style hash-input)))
|
(let ((cn (str "sx-" (hash-style hash-input)))
|
||||||
(sv (make-style-value cn
|
(sv (make-style-value cn
|
||||||
(join ";" base-decls)
|
(join ";" base-decls)
|
||||||
(chunk-every media-rules 2)
|
media-rules
|
||||||
(chunk-every pseudo-rules 2)
|
pseudo-rules
|
||||||
(chunk-every kf-needed 2))))
|
kf-needed)))
|
||||||
(dict-set! _style-cache key sv)
|
(dict-set! _style-cache key sv)
|
||||||
;; Inject CSS rules
|
;; Inject CSS rules
|
||||||
(inject-style-value sv atoms)
|
(inject-style-value sv atoms)
|
||||||
|
|||||||
@@ -4,10 +4,16 @@
|
|||||||
;; Defines how SX source text is tokenized and parsed into AST.
|
;; Defines how SX source text is tokenized and parsed into AST.
|
||||||
;; The parser is intentionally simple — s-expressions need minimal parsing.
|
;; 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:
|
;; Grammar:
|
||||||
;; program → expr*
|
;; program → expr*
|
||||||
;; expr → atom | list | quote-sugar
|
;; expr → atom | list | vector | map | quote-sugar
|
||||||
;; list → '(' expr* ')'
|
;; list → '(' expr* ')'
|
||||||
|
;; vector → '[' expr* ']' (sugar for list)
|
||||||
|
;; map → '{' (key expr)* '}'
|
||||||
;; atom → string | number | keyword | symbol | boolean | nil
|
;; atom → string | number | keyword | symbol | boolean | nil
|
||||||
;; string → '"' (char | escape)* '"'
|
;; string → '"' (char | escape)* '"'
|
||||||
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
|
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
|
||||||
@@ -15,316 +21,256 @@
|
|||||||
;; symbol → ident
|
;; symbol → ident
|
||||||
;; boolean → 'true' | 'false'
|
;; boolean → 'true' | 'false'
|
||||||
;; nil → 'nil'
|
;; nil → 'nil'
|
||||||
;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
|
;; ident → ident-start ident-char*
|
||||||
;; comment → ';' to end of line (discarded)
|
;; comment → ';' to end of line (discarded)
|
||||||
;;
|
;;
|
||||||
;; Dict literal:
|
|
||||||
;; {key val ...} → dict object (keys are keywords or expressions)
|
|
||||||
;;
|
|
||||||
;; Quote sugar:
|
;; Quote sugar:
|
||||||
;; `(expr) → (quasiquote expr)
|
;; `expr → (quasiquote expr)
|
||||||
;; ,(expr) → (unquote expr)
|
;; ,expr → (unquote expr)
|
||||||
;; ,@(expr) → (splice-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.
|
;; Returns a list of top-level AST expressions.
|
||||||
;; Each token is a (type value line col) tuple.
|
|
||||||
|
|
||||||
(define tokenize
|
(define sx-parse
|
||||||
(fn (source)
|
(fn (source)
|
||||||
(let ((pos 0)
|
(let ((pos 0)
|
||||||
(line 1)
|
|
||||||
(col 1)
|
|
||||||
(tokens (list))
|
|
||||||
(len-src (len source)))
|
(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 ()
|
(fn ()
|
||||||
(when (< pos len-src)
|
(when (< pos len-src)
|
||||||
(let ((ch (nth source pos)))
|
(let ((ch (nth source pos)))
|
||||||
(cond
|
(cond
|
||||||
;; Whitespace — skip
|
;; Whitespace
|
||||||
(whitespace? ch)
|
(or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
|
||||||
(do (advance-pos!) (scan-next))
|
(do (set! pos (inc pos)) (skip-ws))
|
||||||
|
|
||||||
;; Comment — skip to end of line
|
;; Comment — skip to end of line
|
||||||
(= ch ";")
|
(= 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
|
;; String
|
||||||
(= ch "\"")
|
(= ch "\"")
|
||||||
(do (append! tokens (scan-string)) (scan-next))
|
(read-string)
|
||||||
|
|
||||||
;; 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))))
|
|
||||||
|
|
||||||
;; Keyword
|
;; Keyword
|
||||||
(= ch ":")
|
(= 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)
|
;; Number (or negative number)
|
||||||
(or (digit? ch)
|
(or (and (>= ch "0") (<= ch "9"))
|
||||||
(and (= ch "-") (< (inc pos) len-src)
|
(and (= ch "-")
|
||||||
(digit? (nth source (inc pos)))))
|
(< (inc pos) len-src)
|
||||||
(do (append! tokens (scan-number)) (scan-next))
|
(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)
|
(ident-start? ch)
|
||||||
(do (append! tokens (scan-symbol)) (scan-next))
|
(read-symbol)
|
||||||
|
|
||||||
;; Unknown — skip
|
;; Unexpected
|
||||||
:else
|
:else
|
||||||
(do (advance-pos!) (scan-next)))))))
|
(error (str "Unexpected character: " ch)))))))
|
||||||
(scan-next)
|
|
||||||
tokens)))
|
|
||||||
|
|
||||||
|
;; -- Entry point: parse all top-level expressions --
|
||||||
;; --------------------------------------------------------------------------
|
(let ((exprs (list)))
|
||||||
;; Token scanners (pseudo-code — each target implements natively)
|
(define parse-loop
|
||||||
;; --------------------------------------------------------------------------
|
(fn ()
|
||||||
|
(skip-ws)
|
||||||
(define scan-string
|
(when (< pos len-src)
|
||||||
(fn ()
|
(append! exprs (read-expr))
|
||||||
;; Scan from opening " to closing ", handling escape sequences.
|
(parse-loop))))
|
||||||
;; Returns ("string" value line col).
|
(parse-loop)
|
||||||
;; Escape sequences: \" \\ \n \t \r
|
exprs))))
|
||||||
(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)))
|
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
;; Serializer — AST → SX source text
|
;; Serializer — AST → SX source text
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
(define serialize
|
(define sx-serialize
|
||||||
(fn (val)
|
(fn (val)
|
||||||
(case (type-of val)
|
(case (type-of val)
|
||||||
"nil" "nil"
|
"nil" "nil"
|
||||||
@@ -333,46 +279,41 @@
|
|||||||
"string" (str "\"" (escape-string val) "\"")
|
"string" (str "\"" (escape-string val) "\"")
|
||||||
"symbol" (symbol-name val)
|
"symbol" (symbol-name val)
|
||||||
"keyword" (str ":" (keyword-name val))
|
"keyword" (str ":" (keyword-name val))
|
||||||
"list" (str "(" (join " " (map serialize val)) ")")
|
"list" (str "(" (join " " (map sx-serialize val)) ")")
|
||||||
"dict" (serialize-dict val)
|
"dict" (sx-serialize-dict val)
|
||||||
"sx-expr" (sx-expr-source val)
|
"sx-expr" (sx-expr-source val)
|
||||||
:else (str val))))
|
:else (str val))))
|
||||||
|
|
||||||
|
|
||||||
(define serialize-dict
|
(define sx-serialize-dict
|
||||||
(fn (d)
|
(fn (d)
|
||||||
(str "(dict "
|
(str "{"
|
||||||
(join " "
|
(join " "
|
||||||
(reduce
|
(reduce
|
||||||
(fn (acc key)
|
(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)
|
(list)
|
||||||
(keys d)))
|
(keys d)))
|
||||||
")")))
|
"}")))
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
;; Platform parser interface
|
;; Platform parser interface
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
;;
|
;;
|
||||||
;; Character classification:
|
;; Character classification (implemented natively per target):
|
||||||
;; (whitespace? ch) → boolean
|
;; (ident-start? ch) → boolean
|
||||||
;; (digit? ch) → boolean
|
;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
|
||||||
;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.)
|
|
||||||
;; (ident-char? ch) → boolean (ident-start + digits, ., :)
|
|
||||||
;;
|
;;
|
||||||
;; Constructors:
|
;; (ident-char? ch) → boolean
|
||||||
;; (make-symbol name) → Symbol value
|
;; True for: ident-start chars plus: 0-9 . : / [ ] # ,
|
||||||
;; (make-keyword name) → Keyword value
|
;;
|
||||||
;; (parse-number s) → number (int or float from string)
|
;; 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:
|
;; String utilities:
|
||||||
;; (escape-string s) → string with " and \ escaped
|
;; (escape-string s) → string with " and \ escaped
|
||||||
;; (sx-expr-source e) → unwrap SxExpr to its source string
|
;; (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
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -443,6 +443,16 @@
|
|||||||
:doc "Split comma-separated ID string into list of trimmed non-empty strings.")
|
: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
|
;; CSSX — style system primitives
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"flex": "display:flex",
|
"flex": "display:flex",
|
||||||
"inline-flex": "display:inline-flex",
|
"inline-flex": "display:inline-flex",
|
||||||
"table": "display:table",
|
"table": "display:table",
|
||||||
|
"table-row": "display:table-row",
|
||||||
"grid": "display:grid",
|
"grid": "display:grid",
|
||||||
"contents": "display:contents",
|
"contents": "display:contents",
|
||||||
"hidden": "display:none",
|
"hidden": "display:none",
|
||||||
@@ -84,6 +85,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"flex-row": "flex-direction:row",
|
"flex-row": "flex-direction:row",
|
||||||
"flex-col": "flex-direction:column",
|
"flex-col": "flex-direction:column",
|
||||||
"flex-wrap": "flex-wrap:wrap",
|
"flex-wrap": "flex-wrap:wrap",
|
||||||
|
"flex-0": "flex:0",
|
||||||
"flex-1": "flex:1 1 0%",
|
"flex-1": "flex:1 1 0%",
|
||||||
"flex-shrink-0": "flex-shrink:0",
|
"flex-shrink-0": "flex-shrink:0",
|
||||||
"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-2": "margin-top:.5rem",
|
||||||
"mt-3": "margin-top:.75rem",
|
"mt-3": "margin-top:.75rem",
|
||||||
"mt-4": "margin-top:1rem",
|
"mt-4": "margin-top:1rem",
|
||||||
|
"mt-5": "margin-top:1.25rem",
|
||||||
"mt-6": "margin-top:1.5rem",
|
"mt-6": "margin-top:1.5rem",
|
||||||
"mt-8": "margin-top:2rem",
|
"mt-8": "margin-top:2rem",
|
||||||
"mt-[8px]": "margin-top:8px",
|
"mt-[8px]": "margin-top:8px",
|
||||||
@@ -196,6 +199,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"pb-8": "padding-bottom:2rem",
|
"pb-8": "padding-bottom:2rem",
|
||||||
"pb-[48px]": "padding-bottom:48px",
|
"pb-[48px]": "padding-bottom:48px",
|
||||||
"pl-2": "padding-left:.5rem",
|
"pl-2": "padding-left:.5rem",
|
||||||
|
"pl-3": "padding-left:.75rem",
|
||||||
"pl-5": "padding-left:1.25rem",
|
"pl-5": "padding-left:1.25rem",
|
||||||
"pl-6": "padding-left:1.5rem",
|
"pl-6": "padding-left:1.5rem",
|
||||||
"pr-1": "padding-right:.25rem",
|
"pr-1": "padding-right:.25rem",
|
||||||
@@ -216,11 +220,15 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"w-10": "width:2.5rem",
|
"w-10": "width:2.5rem",
|
||||||
"w-11": "width:2.75rem",
|
"w-11": "width:2.75rem",
|
||||||
"w-12": "width:3rem",
|
"w-12": "width:3rem",
|
||||||
|
"w-14": "width:3.5rem",
|
||||||
"w-16": "width:4rem",
|
"w-16": "width:4rem",
|
||||||
"w-20": "width:5rem",
|
"w-20": "width:5rem",
|
||||||
"w-24": "width:6rem",
|
"w-24": "width:6rem",
|
||||||
"w-28": "width:7rem",
|
"w-28": "width:7rem",
|
||||||
|
"w-32": "width:8rem",
|
||||||
|
"w-40": "width:10rem",
|
||||||
"w-48": "width:12rem",
|
"w-48": "width:12rem",
|
||||||
|
"w-56": "width:14rem",
|
||||||
"w-1/2": "width:50%",
|
"w-1/2": "width:50%",
|
||||||
"w-1/3": "width:33.333333%",
|
"w-1/3": "width:33.333333%",
|
||||||
"w-1/4": "width:25%",
|
"w-1/4": "width:25%",
|
||||||
@@ -241,6 +249,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"h-10": "height:2.5rem",
|
"h-10": "height:2.5rem",
|
||||||
"h-12": "height:3rem",
|
"h-12": "height:3rem",
|
||||||
"h-14": "height:3.5rem",
|
"h-14": "height:3.5rem",
|
||||||
|
"h-14": "height:3.5rem",
|
||||||
"h-16": "height:4rem",
|
"h-16": "height:4rem",
|
||||||
"h-24": "height:6rem",
|
"h-24": "height:6rem",
|
||||||
"h-28": "height:7rem",
|
"h-28": "height:7rem",
|
||||||
@@ -268,11 +277,15 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"max-w-3xl": "max-width:48rem",
|
"max-w-3xl": "max-width:48rem",
|
||||||
"max-w-4xl": "max-width:56rem",
|
"max-w-4xl": "max-width:56rem",
|
||||||
"max-w-full": "max-width:100%",
|
"max-w-full": "max-width:100%",
|
||||||
|
"max-w-0": "max-width:0",
|
||||||
"max-w-none": "max-width:none",
|
"max-w-none": "max-width:none",
|
||||||
"max-w-screen-2xl": "max-width:1536px",
|
"max-w-screen-2xl": "max-width:1536px",
|
||||||
"max-w-[360px]": "max-width:360px",
|
"max-w-[360px]": "max-width:360px",
|
||||||
"max-w-[768px]": "max-width:768px",
|
"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-64": "max-height:16rem",
|
||||||
|
"max-h-72": "max-height:18rem",
|
||||||
"max-h-96": "max-height:24rem",
|
"max-h-96": "max-height:24rem",
|
||||||
"max-h-none": "max-height:none",
|
"max-h-none": "max-height:none",
|
||||||
"max-h-[448px]": "max-height:448px",
|
"max-h-[448px]": "max-height:448px",
|
||||||
@@ -282,6 +295,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"text-xs": "font-size:.75rem;line-height:1rem",
|
"text-xs": "font-size:.75rem;line-height:1rem",
|
||||||
"text-sm": "font-size:.875rem;line-height:1.25rem",
|
"text-sm": "font-size:.875rem;line-height:1.25rem",
|
||||||
"text-base": "font-size:1rem;line-height:1.5rem",
|
"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-lg": "font-size:1.125rem;line-height:1.75rem",
|
||||||
"text-xl": "font-size:1.25rem;line-height:1.75rem",
|
"text-xl": "font-size:1.25rem;line-height:1.75rem",
|
||||||
"text-2xl": "font-size:1.5rem;line-height:2rem",
|
"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-500": "color:rgb(244 63 94)",
|
||||||
"text-rose-600": "color:rgb(225 29 72)",
|
"text-rose-600": "color:rgb(225 29 72)",
|
||||||
"text-rose-700": "color:rgb(190 18 60)",
|
"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-800/80": "color:rgba(159,18,57,.8)",
|
||||||
"text-rose-900": "color:rgb(136 19 55)",
|
"text-rose-900": "color:rgb(136 19 55)",
|
||||||
"text-orange-600": "color:rgb(234 88 12)",
|
"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-yellow-700": "color:rgb(161 98 7)",
|
||||||
"text-green-600": "color:rgb(22 163 74)",
|
"text-green-600": "color:rgb(22 163 74)",
|
||||||
"text-green-800": "color:rgb(22 101 52)",
|
"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-500": "color:rgb(16 185 129)",
|
||||||
"text-emerald-600": "color:rgb(5 150 105)",
|
"text-emerald-600": "color:rgb(5 150 105)",
|
||||||
"text-emerald-700": "color:rgb(4 120 87)",
|
"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-600": "color:rgb(124 58 237)",
|
||||||
"text-violet-700": "color:rgb(109 40 217)",
|
"text-violet-700": "color:rgb(109 40 217)",
|
||||||
"text-violet-800": "color:rgb(91 33 182)",
|
"text-violet-800": "color:rgb(91 33 182)",
|
||||||
|
"text-violet-900": "color:rgb(76 29 149)",
|
||||||
|
|
||||||
# ── Background Colors ────────────────────────────────────────────────
|
# ── Background Colors ────────────────────────────────────────────────
|
||||||
"bg-transparent": "background-color:transparent",
|
"bg-transparent": "background-color:transparent",
|
||||||
@@ -413,6 +433,9 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"bg-yellow-300": "background-color:rgb(253 224 71)",
|
"bg-yellow-300": "background-color:rgb(253 224 71)",
|
||||||
"bg-green-50": "background-color:rgb(240 253 244)",
|
"bg-green-50": "background-color:rgb(240 253 244)",
|
||||||
"bg-green-100": "background-color:rgb(220 252 231)",
|
"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": "background-color:rgb(236 253 245)",
|
||||||
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
|
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
|
||||||
"bg-emerald-100": "background-color:rgb(209 250 229)",
|
"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-400": "background-color:rgb(167 139 250)",
|
||||||
"bg-violet-500": "background-color:rgb(139 92 246)",
|
"bg-violet-500": "background-color:rgb(139 92 246)",
|
||||||
"bg-violet-600": "background-color:rgb(124 58 237)",
|
"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": "border-width:1px",
|
"border": "border-width:1px",
|
||||||
@@ -445,6 +474,7 @@ STYLE_ATOMS: dict[str, str] = {
|
|||||||
"border-b": "border-bottom-width:1px",
|
"border-b": "border-bottom-width:1px",
|
||||||
"border-b-2": "border-bottom-width:2px",
|
"border-b-2": "border-bottom-width:2px",
|
||||||
"border-r": "border-right-width:1px",
|
"border-r": "border-right-width:1px",
|
||||||
|
"border-l": "border-left-width:1px",
|
||||||
"border-l-4": "border-left-width:4px",
|
"border-l-4": "border-left-width:4px",
|
||||||
"border-dashed": "border-style:dashed",
|
"border-dashed": "border-style:dashed",
|
||||||
"border-none": "border-style:none",
|
"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-200": "border-color:rgb(221 214 254)",
|
||||||
"border-violet-300": "border-color:rgb(196 181 253)",
|
"border-violet-300": "border-color:rgb(196 181 253)",
|
||||||
"border-violet-400": "border-color:rgb(167 139 250)",
|
"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-white": "border-top-color:rgb(255 255 255)",
|
||||||
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
|
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
|
||||||
"border-l-stone-400": "border-left-color:rgb(168 162 158)",
|
"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-0": "opacity:0",
|
||||||
"opacity-40": "opacity:.4",
|
"opacity-40": "opacity:.4",
|
||||||
"opacity-50": "opacity:.5",
|
"opacity-50": "opacity:.5",
|
||||||
|
"opacity-90": "opacity:.9",
|
||||||
"opacity-100": "opacity:1",
|
"opacity-100": "opacity:1",
|
||||||
|
|
||||||
# ── Ring / Outline ───────────────────────────────────────────────────
|
# ── Ring / Outline ───────────────────────────────────────────────────
|
||||||
"outline-none": "outline:2px solid transparent;outline-offset:2px",
|
"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-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-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 ─────────────────────────────────────────────────────────
|
||||||
"overflow-hidden": "overflow:hidden",
|
"overflow-hidden": "overflow:hidden",
|
||||||
"overflow-x-auto": "overflow-x:auto",
|
"overflow-x-auto": "overflow-x:auto",
|
||||||
"overflow-y-auto": "overflow-y:auto",
|
"overflow-y-auto": "overflow-y:auto",
|
||||||
|
"overflow-visible": "overflow:visible",
|
||||||
|
"overflow-y-visible": "overflow-y:visible",
|
||||||
"overscroll-contain": "overscroll-behavior:contain",
|
"overscroll-contain": "overscroll-behavior:contain",
|
||||||
|
|
||||||
# ── Text Decoration ──────────────────────────────────────────────────
|
# ── Text Decoration ──────────────────────────────────────────────────
|
||||||
@@ -655,8 +697,13 @@ PSEUDO_VARIANTS: dict[str, str] = {
|
|||||||
"placeholder": "::placeholder",
|
"placeholder": "::placeholder",
|
||||||
"file": "::file-selector-button",
|
"file": "::file-selector-button",
|
||||||
"aria-selected": "[aria-selected=true]",
|
"aria-selected": "[aria-selected=true]",
|
||||||
|
"invalid": ":invalid",
|
||||||
|
"placeholder-shown": ":placeholder-shown",
|
||||||
"group-hover": ":is(.group:hover) &",
|
"group-hover": ":is(.group:hover) &",
|
||||||
"group-open": ":is(.group[open]) &",
|
"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"):
|
def highlight(code: str, language: str = "lisp"):
|
||||||
"""Highlight code in the given language. Returns SxExpr for wire format."""
|
"""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"):
|
if language in ("lisp", "sx", "sexp"):
|
||||||
return SxExpr(highlight_sx(code))
|
return SxExpr(highlight_sx(code))
|
||||||
elif language in ("python", "py"):
|
elif language in ("python", "py"):
|
||||||
@@ -246,5 +246,4 @@ def highlight(code: str, language: str = "lisp"):
|
|||||||
elif language in ("bash", "sh", "shell"):
|
elif language in ("bash", "sh", "shell"):
|
||||||
return SxExpr(highlight_bash(code))
|
return SxExpr(highlight_bash(code))
|
||||||
# Fallback: no highlighting, just escaped text
|
# Fallback: no highlighting, just escaped text
|
||||||
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
|
return SxExpr("(span " + serialize(code) + ")")
|
||||||
return SxExpr(f'(span "{escaped}")')
|
|
||||||
|
|||||||
@@ -97,7 +97,8 @@
|
|||||||
|
|
||||||
(define bootstrappers-nav-items (list
|
(define bootstrappers-nav-items (list
|
||||||
(dict :label "Overview" :href "/bootstrappers/")
|
(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.
|
;; Spec file registry — canonical metadata for spec viewer pages.
|
||||||
;; Python only handles file I/O (read-spec-file); all metadata lives here.
|
;; 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"))
|
(td :class "px-3 py-2 text-green-600" "Live"))
|
||||||
(tr :class "border-b border-stone-100"
|
(tr :class "border-b border-stone-100"
|
||||||
(td :class "px-3 py-2 text-stone-700" "Python")
|
(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-violet-700"
|
||||||
(td :class "px-3 py-2 font-mono text-sm text-stone-400" "shared/sx/")
|
(a :href "/bootstrappers/python" :class "hover:underline"
|
||||||
(td :class "px-3 py-2 text-stone-400" "Planned"))
|
"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"
|
(tr :class "border-b border-stone-100"
|
||||||
(td :class "px-3 py-2 text-stone-700" "Rust")
|
(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")
|
(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"
|
(pre :class "text-xs leading-relaxed whitespace-pre-wrap break-words"
|
||||||
(code (highlight bootstrapped-output "javascript"))))))))
|
(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
|
;; Not found
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -368,6 +368,10 @@
|
|||||||
:data (bootstrapper-data slug)
|
:data (bootstrapper-data slug)
|
||||||
:content (if bootstrapper-not-found
|
:content (if bootstrapper-not-found
|
||||||
(~spec-not-found :slug slug)
|
(~spec-not-found :slug slug)
|
||||||
(~bootstrapper-js-content
|
(if (= slug "python")
|
||||||
:bootstrapper-source bootstrapper-source
|
(~bootstrapper-py-content
|
||||||
:bootstrapped-output bootstrapped-output)))
|
: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
|
import os
|
||||||
|
|
||||||
if target != "javascript":
|
if target not in ("javascript", "python"):
|
||||||
return {"bootstrapper-not-found": True}
|
return {"bootstrapper-not-found": True}
|
||||||
|
|
||||||
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
|
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
|
||||||
if not os.path.isdir(ref_dir):
|
if not os.path.isdir(ref_dir):
|
||||||
ref_dir = "/app/shared/sx/ref"
|
ref_dir = "/app/shared/sx/ref"
|
||||||
|
|
||||||
# Read bootstrapper source
|
if target == "javascript":
|
||||||
bs_path = os.path.join(ref_dir, "bootstrap_js.py")
|
# Read bootstrapper source
|
||||||
try:
|
bs_path = os.path.join(ref_dir, "bootstrap_js.py")
|
||||||
with open(bs_path, encoding="utf-8") as f:
|
try:
|
||||||
bootstrapper_source = f.read()
|
with open(bs_path, encoding="utf-8") as f:
|
||||||
except FileNotFoundError:
|
bootstrapper_source = f.read()
|
||||||
bootstrapper_source = "# bootstrapper source not found"
|
except FileNotFoundError:
|
||||||
|
bootstrapper_source = "# bootstrapper source not found"
|
||||||
|
|
||||||
# Run the bootstrap to generate JS
|
# Run the bootstrap to generate JS
|
||||||
from shared.sx.ref.bootstrap_js import compile_ref_to_js
|
from shared.sx.ref.bootstrap_js import compile_ref_to_js
|
||||||
try:
|
try:
|
||||||
bootstrapped_output = compile_ref_to_js(
|
bootstrapped_output = compile_ref_to_js(
|
||||||
adapters=["dom", "engine", "orchestration", "boot", "cssx"]
|
adapters=["dom", "engine", "orchestration", "boot", "cssx"]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
bootstrapped_output = f"// bootstrap error: {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 {
|
return {
|
||||||
"bootstrapper-not-found": None,
|
"bootstrapper-not-found": None,
|
||||||
@@ -176,7 +191,7 @@ def _attr_detail_data(slug: str) -> dict:
|
|||||||
- attr-not-found (truthy if not found)
|
- attr-not-found (truthy if not found)
|
||||||
"""
|
"""
|
||||||
from content.pages import ATTR_DETAILS
|
from content.pages import ATTR_DETAILS
|
||||||
from shared.sx.helpers import SxExpr
|
from shared.sx.helpers import sx_call
|
||||||
|
|
||||||
detail = ATTR_DETAILS.get(slug)
|
detail = ATTR_DETAILS.get(slug)
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -193,7 +208,7 @@ def _attr_detail_data(slug: str) -> dict:
|
|||||||
"attr-description": detail["description"],
|
"attr-description": detail["description"],
|
||||||
"attr-example": detail["example"],
|
"attr-example": detail["example"],
|
||||||
"attr-handler": detail.get("handler"),
|
"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,
|
"attr-wire-id": wire_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +216,7 @@ def _attr_detail_data(slug: str) -> dict:
|
|||||||
def _header_detail_data(slug: str) -> dict:
|
def _header_detail_data(slug: str) -> dict:
|
||||||
"""Return header detail data for a specific header slug."""
|
"""Return header detail data for a specific header slug."""
|
||||||
from content.pages import HEADER_DETAILS
|
from content.pages import HEADER_DETAILS
|
||||||
from shared.sx.helpers import SxExpr
|
from shared.sx.helpers import sx_call
|
||||||
|
|
||||||
detail = HEADER_DETAILS.get(slug)
|
detail = HEADER_DETAILS.get(slug)
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -214,14 +229,14 @@ def _header_detail_data(slug: str) -> dict:
|
|||||||
"header-direction": detail["direction"],
|
"header-direction": detail["direction"],
|
||||||
"header-description": detail["description"],
|
"header-description": detail["description"],
|
||||||
"header-example": detail.get("example"),
|
"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:
|
def _event_detail_data(slug: str) -> dict:
|
||||||
"""Return event detail data for a specific event slug."""
|
"""Return event detail data for a specific event slug."""
|
||||||
from content.pages import EVENT_DETAILS
|
from content.pages import EVENT_DETAILS
|
||||||
from shared.sx.helpers import SxExpr
|
from shared.sx.helpers import sx_call
|
||||||
|
|
||||||
detail = EVENT_DETAILS.get(slug)
|
detail = EVENT_DETAILS.get(slug)
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -233,5 +248,5 @@ def _event_detail_data(slug: str) -> dict:
|
|||||||
"event-title": slug,
|
"event-title": slug,
|
||||||
"event-description": detail["description"],
|
"event-description": detail["description"],
|
||||||
"event-example": detail.get("example"),
|
"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