Compare commits
34 Commits
3cbdfd8f7f
...
56589a81b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 56589a81b2 | |||
| 06adbdcd59 | |||
| 7efd1b401b | |||
| a496ee6ae6 | |||
| 6bda2bafa2 | |||
| 3103d7ff9d | |||
| 8683cf24c3 | |||
| efc7e340da | |||
| 09164e32ad | |||
| 189a0258d9 | |||
| 9a0173419a | |||
| 50a184faf2 | |||
| 4709c6bf49 | |||
| e15b5c9dbc | |||
| c55f0956bc | |||
| 5b70cd5cfc | |||
| 0da5dc41e1 | |||
| 57ff7705c7 | |||
| c344b0d7b0 | |||
| baa9d66a59 | |||
| cf2e386cda | |||
| fe289287ec | |||
| 26320abd64 | |||
| a97f4c0e39 | |||
| 391a0c675b | |||
| 145028ccc0 | |||
| c7c824c488 | |||
| 7f665d874c | |||
| 599964c39c | |||
| b2aaa3786d | |||
| 2d38a76f0b | |||
| 5f20a16aa0 | |||
| dba5bf05fa | |||
| 4c1853bc7b |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ import contextvars
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
|
||||
from .types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol
|
||||
|
||||
# When True, _aser expands known components server-side instead of serializing
|
||||
# them for client rendering. Set during page slot evaluation so Python-only
|
||||
@@ -1219,6 +1219,8 @@ async def _eval_slot_inner(
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
elif isinstance(comp, Island):
|
||||
pass # Islands serialize as SX for client-side rendering
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger("sx.eval").error(
|
||||
@@ -1596,6 +1598,14 @@ async def _assf_define(expr, env, ctx):
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_defisland(expr, env, ctx):
|
||||
"""Evaluate defisland AND serialize it — client needs the definition."""
|
||||
import logging
|
||||
logging.getLogger("sx.eval").info("_assf_defisland called for: %s", expr[1] if len(expr) > 1 else expr)
|
||||
await async_eval(expr, env, ctx)
|
||||
return serialize(expr)
|
||||
|
||||
|
||||
async def _assf_lambda(expr, env, ctx):
|
||||
return await _asf_lambda(expr, env, ctx)
|
||||
|
||||
@@ -1703,6 +1713,7 @@ _ASER_FORMS: dict[str, Any] = {
|
||||
"defcomp": _assf_define,
|
||||
"defmacro": _assf_define,
|
||||
"defhandler": _assf_define,
|
||||
"defisland": _assf_defisland,
|
||||
"begin": _assf_begin,
|
||||
"do": _assf_begin,
|
||||
"quote": _assf_quote,
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Macro, Symbol
|
||||
from .types import Component, Island, Macro, Symbol
|
||||
|
||||
|
||||
def _use_ref() -> bool:
|
||||
@@ -50,7 +50,7 @@ def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
|
||||
return
|
||||
seen.add(n)
|
||||
val = env.get(n)
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, (Component, Island)):
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
elif isinstance(val, Macro):
|
||||
@@ -64,7 +64,7 @@ def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
|
||||
|
||||
def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
|
||||
for key, val in env.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, (Component, Island)):
|
||||
val.deps = _transitive_deps_fallback(key, env)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ def _transitive_io_refs_fallback(
|
||||
return
|
||||
seen.add(n)
|
||||
val = env.get(n)
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, (Component, Island)):
|
||||
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
|
||||
for dep in _scan_ast(val.body):
|
||||
walk(dep)
|
||||
@@ -120,7 +120,7 @@ def _compute_all_io_refs_fallback(
|
||||
env: dict[str, Any], io_names: set[str]
|
||||
) -> None:
|
||||
for key, val in env.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, (Component, Island)):
|
||||
val.io_refs = _transitive_io_refs_fallback(key, env, io_names)
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
|
||||
for name in direct:
|
||||
all_needed.add(name)
|
||||
val = env.get(name)
|
||||
if isinstance(val, Component) and val.deps:
|
||||
if isinstance(val, (Component, Island)) and val.deps:
|
||||
all_needed.update(val.deps)
|
||||
else:
|
||||
all_needed.update(_transitive_deps_fallback(name, env))
|
||||
|
||||
@@ -33,7 +33,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
|
||||
from .types import Component, Continuation, HandlerDef, Island, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
|
||||
from .primitives import _PRIMITIVES
|
||||
|
||||
|
||||
@@ -147,13 +147,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
|
||||
fn = _trampoline(_eval(head, env))
|
||||
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
|
||||
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
|
||||
return fn(*args)
|
||||
|
||||
if isinstance(fn, Lambda):
|
||||
return _call_lambda(fn, args, env)
|
||||
|
||||
if isinstance(fn, Component):
|
||||
if isinstance(fn, (Component, Island)):
|
||||
return _call_component(fn, expr[1:], env)
|
||||
|
||||
raise EvalError(f"Not callable: {fn!r}")
|
||||
@@ -555,6 +555,51 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
|
||||
return comp
|
||||
|
||||
|
||||
def _sf_defisland(expr: list, env: dict) -> Island:
|
||||
"""``(defisland ~name (&key ...) body)``"""
|
||||
if len(expr) < 4:
|
||||
raise EvalError("defisland requires name, params, and body")
|
||||
name_sym = expr[1]
|
||||
if not isinstance(name_sym, Symbol):
|
||||
raise EvalError(f"defisland name must be symbol, got {type(name_sym).__name__}")
|
||||
comp_name = name_sym.name.lstrip("~")
|
||||
|
||||
params_expr = expr[2]
|
||||
if not isinstance(params_expr, list):
|
||||
raise EvalError("defisland params must be a list")
|
||||
|
||||
params: list[str] = []
|
||||
has_children = False
|
||||
in_key = False
|
||||
for p in params_expr:
|
||||
if isinstance(p, Symbol):
|
||||
if p.name == "&key":
|
||||
in_key = True
|
||||
continue
|
||||
if p.name == "&rest":
|
||||
has_children = True
|
||||
continue
|
||||
if in_key or has_children:
|
||||
if not has_children:
|
||||
params.append(p.name)
|
||||
else:
|
||||
params.append(p.name)
|
||||
elif isinstance(p, str):
|
||||
params.append(p)
|
||||
|
||||
body = expr[-1]
|
||||
|
||||
island = Island(
|
||||
name=comp_name,
|
||||
params=params,
|
||||
has_children=has_children,
|
||||
body=body,
|
||||
closure=dict(env),
|
||||
)
|
||||
env[name_sym.name] = island
|
||||
return island
|
||||
|
||||
|
||||
def _defcomp_kwarg(expr: list, key: str, default: str) -> str:
|
||||
"""Extract a keyword annotation from defcomp, e.g. :affinity :client."""
|
||||
# Scan from index 3 to second-to-last for :key value pairs
|
||||
@@ -592,7 +637,7 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
|
||||
else:
|
||||
fn = _trampoline(_eval(form, env))
|
||||
args = [result]
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
|
||||
result = fn(*args)
|
||||
elif isinstance(fn, Lambda):
|
||||
result = _trampoline(_call_lambda(fn, args, env))
|
||||
@@ -1021,6 +1066,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
||||
"define": _sf_define,
|
||||
"defstyle": _sf_defstyle,
|
||||
"defcomp": _sf_defcomp,
|
||||
"defisland": _sf_defisland,
|
||||
"defrelation": _sf_defrelation,
|
||||
"begin": _sf_begin,
|
||||
"do": _sf_begin,
|
||||
|
||||
@@ -313,7 +313,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
|
||||
# Wrap body + meta in a fragment so sx.js renders both;
|
||||
# auto-hoist moves meta/title/link elements to <head>.
|
||||
body_sx = _sx_fragment(meta, body_sx)
|
||||
return sx_page(ctx, body_sx, meta_html=meta_html)
|
||||
return await sx_page(ctx, body_sx, meta_html=meta_html)
|
||||
|
||||
|
||||
def _build_component_ast(__name: str, **kwargs: Any) -> list:
|
||||
@@ -473,7 +473,7 @@ def components_for_request(source: str = "",
|
||||
from quart import request
|
||||
from .jinja_bridge import _COMPONENT_ENV
|
||||
from .deps import components_needed
|
||||
from .types import Component, Macro
|
||||
from .types import Component, Island, Macro
|
||||
from .parser import serialize
|
||||
|
||||
# Determine which components the page needs
|
||||
@@ -493,7 +493,19 @@ def components_for_request(source: str = "",
|
||||
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, Island):
|
||||
comp_name = f"~{val.name}"
|
||||
if needed is not None and comp_name not in needed and key not in needed:
|
||||
continue
|
||||
if comp_name in loaded or val.name in loaded:
|
||||
continue
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
||||
elif isinstance(val, Component):
|
||||
comp_name = f"~{val.name}"
|
||||
# Skip if not needed for this page
|
||||
if needed is not None and comp_name not in needed and key not in needed:
|
||||
@@ -608,50 +620,8 @@ def sx_response(source: str, status: int = 200,
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sx wire-format full page shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SX_PAGE_TEMPLATE = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>{title}</title>
|
||||
{meta_html}
|
||||
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
|
||||
<meta name="csrf-token" content="{csrf}">
|
||||
<style id="sx-css">{sx_css}</style>
|
||||
<meta name="sx-css-classes" content="{sx_css_classes}">
|
||||
<script src="https://unpkg.com/prismjs/prism.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){{document.documentElement.classList.add('hover-capable')}}</script>
|
||||
<script>document.addEventListener('click',function(e){{var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')}})</script>
|
||||
<style>
|
||||
details[data-toggle-group="mobile-panels"]>summary{{list-style:none}}
|
||||
details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{{display:none}}
|
||||
@media(min-width:768px){{.nav-group:focus-within .submenu,.nav-group:hover .submenu{{display:block}}}}
|
||||
img{{max-width:100%;height:auto}}
|
||||
.clamp-2{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
|
||||
.clamp-3{{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
|
||||
.no-scrollbar::-webkit-scrollbar{{display:none}}.no-scrollbar{{-ms-overflow-style:none;scrollbar-width:none}}
|
||||
details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.group>summary::-webkit-details-marker{{display:none}}
|
||||
.sx-indicator{{display:none}}.sx-request .sx-indicator{{display:inline-flex}}
|
||||
.sx-error .sx-indicator{{display:none}}.sx-loading .sx-indicator{{display:inline-flex}}
|
||||
.js-wrap.open .js-pop{{display:block}}.js-wrap.open .js-backdrop{{display:block}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
|
||||
<script type="text/sx-pages">{pages_sx}</script>
|
||||
<script type="text/sx" data-mount="body">{page_sx}</script>
|
||||
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
|
||||
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
|
||||
</body>
|
||||
</html>"""
|
||||
# The page shell is defined as ~sx-page-shell in shared/sx/templates/shell.sx
|
||||
# and rendered via render_to_html. No HTML string templates in Python.
|
||||
|
||||
|
||||
def _build_pages_sx(service: str) -> str:
|
||||
@@ -781,13 +751,16 @@ def _sx_literal(v: object) -> str:
|
||||
|
||||
|
||||
|
||||
def sx_page(ctx: dict, page_sx: str, *,
|
||||
async def sx_page(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "") -> str:
|
||||
"""Return a minimal HTML shell that boots the page from sx source.
|
||||
|
||||
The browser loads component definitions and page sx, then sx.js
|
||||
renders everything client-side. CSS rules are scanned from the sx
|
||||
source and component defs, then injected as a <style> block.
|
||||
|
||||
The shell is rendered from the ~sx-page-shell SX component
|
||||
(shared/sx/templates/shell.sx).
|
||||
"""
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page
|
||||
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
@@ -838,7 +811,8 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
if pages_sx:
|
||||
_plog.debug("sx_page: pages_sx first 200 chars: %s", pages_sx[:200])
|
||||
|
||||
return _SX_PAGE_TEMPLATE.format(
|
||||
return await render_to_html(
|
||||
"sx-page-shell",
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
meta_html=meta_html,
|
||||
|
||||
@@ -27,7 +27,7 @@ from __future__ import annotations
|
||||
import contextvars
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
|
||||
from .types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol
|
||||
from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline
|
||||
|
||||
def _eval(expr, env):
|
||||
@@ -411,6 +411,64 @@ def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
|
||||
return _render(comp.body, local)
|
||||
|
||||
|
||||
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
|
||||
"""Render an island as static HTML with hydration attributes.
|
||||
|
||||
Produces: <div data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</div>
|
||||
The client hydrates this into a reactive island.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
children: list[Any] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
kwargs[arg.name] = _eval(args[i + 1], env)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
|
||||
local = dict(island.closure)
|
||||
local.update(env)
|
||||
for p in island.params:
|
||||
if p in kwargs:
|
||||
local[p] = kwargs[p]
|
||||
else:
|
||||
local[p] = NIL
|
||||
if island.has_children:
|
||||
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
|
||||
|
||||
body_html = _render(island.body, local)
|
||||
|
||||
# Serialize state for hydration — only keyword args
|
||||
state = {}
|
||||
for k, v in kwargs.items():
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
state[k] = v
|
||||
elif v is NIL or v is None:
|
||||
state[k] = None
|
||||
elif isinstance(v, list):
|
||||
state[k] = v
|
||||
elif isinstance(v, dict):
|
||||
state[k] = v
|
||||
else:
|
||||
state[k] = str(v)
|
||||
|
||||
state_json = _escape_attr(_json.dumps(state, separators=(",", ":"))) if state else ""
|
||||
island_name = _escape_attr(island.name)
|
||||
|
||||
parts = [f'<div data-sx-island="{island_name}"']
|
||||
if state_json:
|
||||
parts.append(f' data-sx-state="{state_json}"')
|
||||
parts.append(">")
|
||||
parts.append(body_html)
|
||||
parts.append("</div>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_list(expr: list, env: dict[str, Any]) -> str:
|
||||
"""Render a list expression — could be an HTML element, special form,
|
||||
component call, or data list."""
|
||||
@@ -464,9 +522,11 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
|
||||
if name in HTML_TAGS:
|
||||
return _render_element(name, expr[1:], env)
|
||||
|
||||
# --- Component (~prefix) → render-aware component call ------------
|
||||
# --- Component/Island (~prefix) → render-aware call ----------------
|
||||
if name.startswith("~"):
|
||||
val = env.get(name)
|
||||
if isinstance(val, Island):
|
||||
return _render_island(val, expr[1:], env)
|
||||
if isinstance(val, Component):
|
||||
return _render_component(val, expr[1:], env)
|
||||
# Fall through to evaluation
|
||||
|
||||
@@ -25,7 +25,7 @@ import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import NIL, Component, Keyword, Macro, Symbol
|
||||
from .types import NIL, Component, Island, Keyword, Macro, Symbol
|
||||
from .parser import parse
|
||||
import os as _os
|
||||
if _os.environ.get("SX_USE_REF") == "1":
|
||||
@@ -64,7 +64,14 @@ def _compute_component_hash() -> None:
|
||||
parts = []
|
||||
for key in sorted(_COMPONENT_ENV):
|
||||
val = _COMPONENT_ENV[key]
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, Island):
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body)
|
||||
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
||||
elif isinstance(val, Component):
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
@@ -198,7 +205,7 @@ def register_components(sx_source: str) -> None:
|
||||
# Slightly over-counts per component but safe and avoids re-scanning at request time.
|
||||
all_classes: set[str] | None = None
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if key not in existing and isinstance(val, Component):
|
||||
if key not in existing and isinstance(val, (Component, Island)):
|
||||
if all_classes is None:
|
||||
all_classes = scan_classes_from_sx(sx_source)
|
||||
val.css_classes = set(all_classes)
|
||||
@@ -307,7 +314,16 @@ def client_components_tag(*names: str) -> str:
|
||||
from .parser import serialize
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, Island):
|
||||
if names and val.name not in names and key.lstrip("~") not in names:
|
||||
continue
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
||||
elif isinstance(val, Component):
|
||||
if names and val.name not in names and key.lstrip("~") not in names:
|
||||
continue
|
||||
# Reconstruct defcomp source from the Component object
|
||||
@@ -365,7 +381,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
|
||||
# Also include macros — they're needed for client-side expansion
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, Island):
|
||||
if f"~{val.name}" in needed or key in needed:
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
||||
elif isinstance(val, Component):
|
||||
if f"~{val.name}" in needed or key in needed:
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
@@ -412,7 +436,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
|
||||
classes: set[str] = set()
|
||||
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if isinstance(val, Component):
|
||||
if isinstance(val, (Component, Island)):
|
||||
if (f"~{val.name}" in needed or key in needed) and val.css_classes:
|
||||
classes.update(val.css_classes)
|
||||
|
||||
|
||||
@@ -887,6 +887,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||
# Mount service worker at root scope for offline data layer
|
||||
mount_service_worker(app)
|
||||
|
||||
# Mount action endpoint for Phase 7c: optimistic data mutations
|
||||
mount_action_endpoint(app, service_name)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -1225,3 +1228,56 @@ def mount_service_worker(app: Any) -> None:
|
||||
|
||||
app.add_url_rule("/sx-sw.js", endpoint="sx_service_worker", view_func=serve_sw)
|
||||
logger.info("Mounted service worker at /sx-sw.js")
|
||||
|
||||
|
||||
def mount_action_endpoint(app: Any, service_name: str) -> None:
|
||||
"""Mount /sx/action/<name> endpoint for client-side data mutations.
|
||||
|
||||
The client can POST to trigger a named action (registered via
|
||||
register_page_helpers with an 'action:' prefix). The action receives
|
||||
the JSON payload, performs the mutation, and returns the new page data
|
||||
as SX wire format.
|
||||
|
||||
This is the server counterpart to submit-mutation in orchestration.sx.
|
||||
"""
|
||||
from quart import make_response, request, abort as quart_abort
|
||||
from .parser import serialize
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
@csrf_exempt
|
||||
async def action_handler(name: str) -> Any:
|
||||
# Look up action helper
|
||||
helpers = get_page_helpers(service_name)
|
||||
action_fn = helpers.get(f"action:{name}")
|
||||
if action_fn is None:
|
||||
quart_abort(404)
|
||||
|
||||
# Parse JSON payload
|
||||
import asyncio as _asyncio
|
||||
data = await request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
result = action_fn(**data)
|
||||
if _asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
except Exception as e:
|
||||
logger.warning("Action %s failed: %s", name, e)
|
||||
resp = await make_response(f'(dict "error" "{e}")', 500)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
result_sx = serialize(result) if result is not None else "nil"
|
||||
resp = await make_response(result_sx, 200)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
action_handler.__name__ = "sx_action"
|
||||
action_handler.__qualname__ = "sx_action"
|
||||
|
||||
app.add_url_rule(
|
||||
"/sx/action/<name>",
|
||||
endpoint="sx_action",
|
||||
view_func=action_handler,
|
||||
methods=["POST"],
|
||||
)
|
||||
logger.info("Mounted action endpoint for %s at /sx/action/<name>", service_name)
|
||||
|
||||
@@ -102,6 +102,12 @@
|
||||
(contains? HTML_TAGS name)
|
||||
(render-dom-element name args env ns)
|
||||
|
||||
;; Island (~name) — reactive component
|
||||
(and (starts-with? name "~")
|
||||
(env-has? env name)
|
||||
(island? (env-get env name)))
|
||||
(render-dom-island (env-get env name) args env ns)
|
||||
|
||||
;; Component (~name)
|
||||
(starts-with? name "~")
|
||||
(let ((comp (env-get env name)))
|
||||
@@ -119,6 +125,13 @@
|
||||
ns
|
||||
(render-dom-element name args env ns)
|
||||
|
||||
;; deref in island scope → reactive text node
|
||||
(and (= name "deref") *island-scope*)
|
||||
(let ((sig-or-val (trampoline (eval-expr (first args) env))))
|
||||
(if (signal? sig-or-val)
|
||||
(reactive-text sig-or-val)
|
||||
(create-text-node (str (deref sig-or-val)))))
|
||||
|
||||
;; Fallback — evaluate then render
|
||||
:else
|
||||
(render-to-dom (trampoline (eval-expr expr env)) env ns)))
|
||||
@@ -156,23 +169,42 @@
|
||||
(< (inc (get state "i")) (len args)))
|
||||
;; Keyword arg → attribute
|
||||
(let ((attr-name (keyword-name arg))
|
||||
(attr-val (trampoline
|
||||
(eval-expr
|
||||
(nth args (inc (get state "i")))
|
||||
env))))
|
||||
(attr-expr (nth args (inc (get state "i")))))
|
||||
(cond
|
||||
;; nil or false → skip
|
||||
(or (nil? attr-val) (= attr-val false))
|
||||
nil
|
||||
;; Boolean attr
|
||||
(contains? BOOLEAN_ATTRS attr-name)
|
||||
(when attr-val (dom-set-attr el attr-name ""))
|
||||
;; true → empty attr
|
||||
(= attr-val true)
|
||||
(dom-set-attr el attr-name "")
|
||||
;; Normal attr
|
||||
;; Event handler: evaluate eagerly, bind listener
|
||||
(starts-with? attr-name "on-")
|
||||
(let ((attr-val (trampoline (eval-expr attr-expr env))))
|
||||
(when (callable? attr-val)
|
||||
(dom-listen el (slice attr-name 3) attr-val)))
|
||||
;; Two-way input binding: :bind signal
|
||||
(= attr-name "bind")
|
||||
(let ((attr-val (trampoline (eval-expr attr-expr env))))
|
||||
(when (signal? attr-val) (bind-input el attr-val)))
|
||||
;; ref: set ref.current to this element
|
||||
(= attr-name "ref")
|
||||
(let ((attr-val (trampoline (eval-expr attr-expr env))))
|
||||
(dict-set! attr-val "current" el))
|
||||
;; key: reconciliation hint, evaluate eagerly (not reactive)
|
||||
(= attr-name "key")
|
||||
(let ((attr-val (trampoline (eval-expr attr-expr env))))
|
||||
(dom-set-attr el "key" (str attr-val)))
|
||||
;; Inside island scope: reactive attribute binding.
|
||||
;; The effect tracks signal deps automatically — if none
|
||||
;; are deref'd, it fires once and never again (safe).
|
||||
*island-scope*
|
||||
(reactive-attr el attr-name
|
||||
(fn () (trampoline (eval-expr attr-expr env))))
|
||||
;; Static attribute (outside islands)
|
||||
:else
|
||||
(dom-set-attr el attr-name (str attr-val)))
|
||||
(let ((attr-val (trampoline (eval-expr attr-expr env))))
|
||||
(cond
|
||||
(or (nil? attr-val) (= attr-val false)) nil
|
||||
(contains? BOOLEAN_ATTRS attr-name)
|
||||
(when attr-val (dom-set-attr el attr-name ""))
|
||||
(= attr-val true)
|
||||
(dom-set-attr el attr-name "")
|
||||
:else
|
||||
(dom-set-attr el attr-name (str attr-val)))))
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
|
||||
;; Positional arg → child
|
||||
@@ -284,8 +316,9 @@
|
||||
|
||||
(define RENDER_DOM_FORMS
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defmacro" "defstyle" "defhandler"
|
||||
"map" "map-indexed" "filter" "for-each"))
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"map" "map-indexed" "filter" "for-each" "portal"
|
||||
"error-boundary"))
|
||||
|
||||
(define render-dom-form?
|
||||
(fn (name)
|
||||
@@ -294,32 +327,131 @@
|
||||
(define dispatch-render-form
|
||||
(fn (name expr env ns)
|
||||
(cond
|
||||
;; if
|
||||
;; if — reactive inside islands (re-renders when signal deps change)
|
||||
(= name "if")
|
||||
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
|
||||
(if cond-val
|
||||
(render-to-dom (nth expr 2) env ns)
|
||||
(if (> (len expr) 3)
|
||||
(render-to-dom (nth expr 3) env ns)
|
||||
(create-fragment))))
|
||||
(if *island-scope*
|
||||
(let ((marker (create-comment "r-if"))
|
||||
(current-nodes (list))
|
||||
(initial-result nil))
|
||||
;; Effect runs synchronously on first call, tracking signal deps.
|
||||
;; On first run, store result in initial-result (marker has no parent yet).
|
||||
;; On subsequent runs, swap DOM nodes after marker.
|
||||
(effect (fn ()
|
||||
(let ((result (let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
|
||||
(if cond-val
|
||||
(render-to-dom (nth expr 2) env ns)
|
||||
(if (> (len expr) 3)
|
||||
(render-to-dom (nth expr 3) env ns)
|
||||
(create-fragment))))))
|
||||
(if (dom-parent marker)
|
||||
;; Marker is in DOM — swap nodes
|
||||
(do
|
||||
(for-each (fn (n) (dom-remove n)) current-nodes)
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? result)
|
||||
(dom-child-nodes result)
|
||||
(list result)))
|
||||
(dom-insert-after marker result))
|
||||
;; Marker not yet in DOM (first run) — just save result
|
||||
(set! initial-result result)))))
|
||||
;; Return fragment: marker + initial render result
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? initial-result)
|
||||
(dom-child-nodes initial-result)
|
||||
(list initial-result)))
|
||||
(dom-append frag initial-result))
|
||||
frag))
|
||||
;; Static if
|
||||
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
|
||||
(if cond-val
|
||||
(render-to-dom (nth expr 2) env ns)
|
||||
(if (> (len expr) 3)
|
||||
(render-to-dom (nth expr 3) env ns)
|
||||
(create-fragment)))))
|
||||
|
||||
;; when
|
||||
;; when — reactive inside islands
|
||||
(= name "when")
|
||||
(if (not (trampoline (eval-expr (nth expr 1) env)))
|
||||
(create-fragment)
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) env ns)))
|
||||
(range 2 (len expr)))
|
||||
frag))
|
||||
(if *island-scope*
|
||||
(let ((marker (create-comment "r-when"))
|
||||
(current-nodes (list))
|
||||
(initial-result nil))
|
||||
(effect (fn ()
|
||||
(if (dom-parent marker)
|
||||
;; In DOM — swap nodes
|
||||
(do
|
||||
(for-each (fn (n) (dom-remove n)) current-nodes)
|
||||
(set! current-nodes (list))
|
||||
(when (trampoline (eval-expr (nth expr 1) env))
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) env ns)))
|
||||
(range 2 (len expr)))
|
||||
(set! current-nodes (dom-child-nodes frag))
|
||||
(dom-insert-after marker frag))))
|
||||
;; First run — save result for fragment
|
||||
(when (trampoline (eval-expr (nth expr 1) env))
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) env ns)))
|
||||
(range 2 (len expr)))
|
||||
(set! current-nodes (dom-child-nodes frag))
|
||||
(set! initial-result frag))))))
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag))
|
||||
;; Static when
|
||||
(if (not (trampoline (eval-expr (nth expr 1) env)))
|
||||
(create-fragment)
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) env ns)))
|
||||
(range 2 (len expr)))
|
||||
frag)))
|
||||
|
||||
;; cond
|
||||
;; cond — reactive inside islands
|
||||
(= name "cond")
|
||||
(let ((branch (eval-cond (rest expr) env)))
|
||||
(if branch
|
||||
(render-to-dom branch env ns)
|
||||
(create-fragment)))
|
||||
(if *island-scope*
|
||||
(let ((marker (create-comment "r-cond"))
|
||||
(current-nodes (list))
|
||||
(initial-result nil))
|
||||
(effect (fn ()
|
||||
(let ((branch (eval-cond (rest expr) env)))
|
||||
(if (dom-parent marker)
|
||||
;; In DOM — swap nodes
|
||||
(do
|
||||
(for-each (fn (n) (dom-remove n)) current-nodes)
|
||||
(set! current-nodes (list))
|
||||
(when branch
|
||||
(let ((result (render-to-dom branch env ns)))
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? result)
|
||||
(dom-child-nodes result)
|
||||
(list result)))
|
||||
(dom-insert-after marker result))))
|
||||
;; First run — save result for fragment
|
||||
(when branch
|
||||
(let ((result (render-to-dom branch env ns)))
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? result)
|
||||
(dom-child-nodes result)
|
||||
(list result)))
|
||||
(set! initial-result result)))))))
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag))
|
||||
;; Static cond
|
||||
(let ((branch (eval-cond (rest expr) env)))
|
||||
(if branch
|
||||
(render-to-dom branch env ns)
|
||||
(create-fragment))))
|
||||
|
||||
;; case
|
||||
(= name "case")
|
||||
@@ -348,19 +480,41 @@
|
||||
(definition-form? name)
|
||||
(do (trampoline (eval-expr expr env)) (create-fragment))
|
||||
|
||||
;; map
|
||||
;; map — reactive-list when mapping over a signal inside an island
|
||||
(= name "map")
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(coll (trampoline (eval-expr (nth expr 2) env)))
|
||||
(frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (item)
|
||||
(let ((val (if (lambda? f)
|
||||
(render-lambda-dom f (list item) env ns)
|
||||
(render-to-dom (apply f (list item)) env ns))))
|
||||
(dom-append frag val)))
|
||||
coll)
|
||||
frag)
|
||||
(let ((coll-expr (nth expr 2)))
|
||||
(if (and *island-scope*
|
||||
(= (type-of coll-expr) "list")
|
||||
(> (len coll-expr) 1)
|
||||
(= (first coll-expr) "deref"))
|
||||
;; Reactive path: pass signal to reactive-list
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(sig (trampoline (eval-expr (nth coll-expr 1) env))))
|
||||
(if (signal? sig)
|
||||
(reactive-list f sig env ns)
|
||||
;; deref on non-signal: fall through to static
|
||||
(let ((coll (deref sig))
|
||||
(frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (item)
|
||||
(let ((val (if (lambda? f)
|
||||
(render-lambda-dom f (list item) env ns)
|
||||
(render-to-dom (apply f (list item)) env ns))))
|
||||
(dom-append frag val)))
|
||||
coll)
|
||||
frag)))
|
||||
;; Static path: no island scope or no deref
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
(coll (trampoline (eval-expr (nth expr 2) env)))
|
||||
(frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (item)
|
||||
(let ((val (if (lambda? f)
|
||||
(render-lambda-dom f (list item) env ns)
|
||||
(render-to-dom (apply f (list item)) env ns))))
|
||||
(dom-append frag val)))
|
||||
coll)
|
||||
frag)))
|
||||
|
||||
;; map-indexed
|
||||
(= name "map-indexed")
|
||||
@@ -380,6 +534,14 @@
|
||||
(= name "filter")
|
||||
(render-to-dom (trampoline (eval-expr expr env)) env ns)
|
||||
|
||||
;; portal — render children into a remote target element
|
||||
(= name "portal")
|
||||
(render-dom-portal (rest expr) env ns)
|
||||
|
||||
;; error-boundary — catch errors, render fallback
|
||||
(= name "error-boundary")
|
||||
(render-dom-error-boundary (rest expr) env ns)
|
||||
|
||||
;; for-each (render variant)
|
||||
(= name "for-each")
|
||||
(let ((f (trampoline (eval-expr (nth expr 1) env)))
|
||||
@@ -414,6 +576,360 @@
|
||||
(render-to-dom (lambda-body f) local ns))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-dom-island — render a reactive island
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Islands render like components but wrapped in a reactive context.
|
||||
;; The island container element gets data-sx-island and data-sx-state
|
||||
;; attributes for identification and hydration.
|
||||
;;
|
||||
;; Inside the island body, deref calls create reactive DOM subscriptions:
|
||||
;; - Text bindings: (deref sig) in text position → reactive text node
|
||||
;; - Attribute bindings: (deref sig) in attr → reactive attribute
|
||||
;; - Conditional fragments: (when (deref sig) ...) → reactive show/hide
|
||||
|
||||
(define render-dom-island
|
||||
(fn (island args env ns)
|
||||
;; Parse kwargs and children (same as component)
|
||||
(let ((kwargs (dict))
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((val (trampoline
|
||||
(eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(dict-set! kwargs (keyword-name arg) val)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
|
||||
;; Build island env: closure + caller env + params
|
||||
(let ((local (env-merge (component-closure island) env))
|
||||
(island-name (component-name island)))
|
||||
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn (p)
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params island))
|
||||
|
||||
;; If island accepts children, pre-render them to a fragment
|
||||
(when (component-has-children? island)
|
||||
(let ((child-frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (c) (dom-append child-frag (render-to-dom c env ns)))
|
||||
children)
|
||||
(env-set! local "children" child-frag)))
|
||||
|
||||
;; Create the island container element
|
||||
(let ((container (dom-create-element "div" nil))
|
||||
(disposers (list)))
|
||||
|
||||
;; Mark as island
|
||||
(dom-set-attr container "data-sx-island" island-name)
|
||||
|
||||
;; Render island body inside a scope that tracks disposers
|
||||
(let ((body-dom
|
||||
(with-island-scope
|
||||
(fn (disposable) (append! disposers disposable))
|
||||
(fn () (render-to-dom (component-body island) local ns)))))
|
||||
(dom-append container body-dom)
|
||||
|
||||
;; Store disposers on the container for cleanup
|
||||
(dom-set-data container "sx-disposers" disposers)
|
||||
|
||||
container))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Reactive DOM rendering helpers
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; These functions create reactive bindings between signals and DOM nodes.
|
||||
;; They are called by the platform's renderDOM when it detects deref
|
||||
;; calls inside an island context.
|
||||
|
||||
;; reactive-text — create a text node bound to a signal
|
||||
;; Used when (deref sig) appears in a text position inside an island.
|
||||
(define reactive-text
|
||||
(fn (sig)
|
||||
(let ((node (create-text-node (str (deref sig)))))
|
||||
(effect (fn ()
|
||||
(dom-set-text-content node (str (deref sig)))))
|
||||
node)))
|
||||
|
||||
;; reactive-attr — bind an element attribute to a signal expression
|
||||
;; Used when an attribute value contains (deref sig) inside an island.
|
||||
(define reactive-attr
|
||||
(fn (el attr-name compute-fn)
|
||||
(effect (fn ()
|
||||
(let ((val (compute-fn)))
|
||||
(cond
|
||||
(or (nil? val) (= val false))
|
||||
(dom-remove-attr el attr-name)
|
||||
(= val true)
|
||||
(dom-set-attr el attr-name "")
|
||||
:else
|
||||
(dom-set-attr el attr-name (str val))))))))
|
||||
|
||||
;; reactive-fragment — conditionally render a fragment based on a signal
|
||||
;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island.
|
||||
(define reactive-fragment
|
||||
(fn (test-fn render-fn env ns)
|
||||
(let ((marker (create-comment "island-fragment"))
|
||||
(current-nodes (list)))
|
||||
(effect (fn ()
|
||||
;; Remove previous nodes
|
||||
(for-each (fn (n) (dom-remove n)) current-nodes)
|
||||
(set! current-nodes (list))
|
||||
;; If test passes, render and insert after marker
|
||||
(when (test-fn)
|
||||
(let ((frag (render-fn)))
|
||||
(set! current-nodes (dom-child-nodes frag))
|
||||
(dom-insert-after marker frag)))))
|
||||
marker)))
|
||||
|
||||
;; reactive-list — render a keyed list bound to a signal
|
||||
;; Used for (map fn (deref items)) inside an island.
|
||||
;;
|
||||
;; Keyed reconciliation: if rendered elements have a "key" attribute,
|
||||
;; existing DOM nodes are reused across updates. Only additions, removals,
|
||||
;; and reorderings touch the DOM. Without keys, falls back to clear+rerender.
|
||||
|
||||
(define render-list-item
|
||||
(fn (map-fn item env ns)
|
||||
(if (lambda? map-fn)
|
||||
(render-lambda-dom map-fn (list item) env ns)
|
||||
(render-to-dom (apply map-fn (list item)) env ns))))
|
||||
|
||||
(define extract-key
|
||||
(fn (node index)
|
||||
;; Extract key from rendered node: :key attr, data-key, or index fallback
|
||||
(let ((k (dom-get-attr node "key")))
|
||||
(if k
|
||||
(do (dom-remove-attr node "key") k)
|
||||
(let ((dk (dom-get-data node "key")))
|
||||
(if dk (str dk) (str "__idx_" index)))))))
|
||||
|
||||
(define reactive-list
|
||||
(fn (map-fn items-sig env ns)
|
||||
(let ((container (create-fragment))
|
||||
(marker (create-comment "island-list"))
|
||||
(key-map (dict))
|
||||
(key-order (list)))
|
||||
(dom-append container marker)
|
||||
(effect (fn ()
|
||||
(let ((items (deref items-sig)))
|
||||
(if (dom-parent marker)
|
||||
;; Marker in DOM: reconcile
|
||||
(let ((new-map (dict))
|
||||
(new-keys (list))
|
||||
(has-keys false))
|
||||
|
||||
;; Render or reuse each item
|
||||
(for-each-indexed
|
||||
(fn (idx item)
|
||||
(let ((rendered (render-list-item map-fn item env ns))
|
||||
(key (extract-key rendered idx)))
|
||||
(when (and (not has-keys)
|
||||
(not (starts-with? key "__idx_")))
|
||||
(set! has-keys true))
|
||||
;; Reuse existing node if key matches, else use new
|
||||
(if (dict-has? key-map key)
|
||||
(dict-set! new-map key (dict-get key-map key))
|
||||
(dict-set! new-map key rendered))
|
||||
(append! new-keys key)))
|
||||
items)
|
||||
|
||||
(if (not has-keys)
|
||||
;; No keys: simple clear and re-render (original strategy)
|
||||
(do
|
||||
(dom-remove-children-after marker)
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (k) (dom-append frag (dict-get new-map k)))
|
||||
new-keys)
|
||||
(dom-insert-after marker frag)))
|
||||
|
||||
;; Keyed reconciliation
|
||||
(do
|
||||
;; Remove stale nodes
|
||||
(for-each
|
||||
(fn (old-key)
|
||||
(when (not (dict-has? new-map old-key))
|
||||
(dom-remove (dict-get key-map old-key))))
|
||||
key-order)
|
||||
|
||||
;; Reorder/insert to match new key order
|
||||
(let ((cursor marker))
|
||||
(for-each
|
||||
(fn (k)
|
||||
(let ((node (dict-get new-map k))
|
||||
(next (dom-next-sibling cursor)))
|
||||
(when (not (identical? node next))
|
||||
(dom-insert-after cursor node))
|
||||
(set! cursor node)))
|
||||
new-keys))))
|
||||
|
||||
;; Update state for next render
|
||||
(set! key-map new-map)
|
||||
(set! key-order new-keys))
|
||||
|
||||
;; First run (marker not in DOM yet): render initial items into container
|
||||
(for-each-indexed
|
||||
(fn (idx item)
|
||||
(let ((rendered (render-list-item map-fn item env ns))
|
||||
(key (extract-key rendered idx)))
|
||||
(dict-set! key-map key rendered)
|
||||
(append! key-order key)
|
||||
(dom-append container rendered)))
|
||||
items)))))
|
||||
container)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; bind-input — two-way signal binding for form elements
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (bind-input el sig) creates a bidirectional link:
|
||||
;; Signal → element: effect updates el.value (or el.checked) when sig changes
|
||||
;; Element → signal: input/change listener updates sig when user types
|
||||
;;
|
||||
;; Handles: input[text/number/email/...], textarea, select, checkbox, radio
|
||||
|
||||
(define bind-input
|
||||
(fn (el sig)
|
||||
(let ((input-type (lower (or (dom-get-attr el "type") "")))
|
||||
(is-checkbox (or (= input-type "checkbox")
|
||||
(= input-type "radio"))))
|
||||
;; Set initial value from signal
|
||||
(if is-checkbox
|
||||
(dom-set-prop el "checked" (deref sig))
|
||||
(dom-set-prop el "value" (str (deref sig))))
|
||||
;; Signal → element (reactive effect)
|
||||
(effect (fn ()
|
||||
(if is-checkbox
|
||||
(dom-set-prop el "checked" (deref sig))
|
||||
(let ((v (str (deref sig))))
|
||||
(when (!= (dom-get-prop el "value") v)
|
||||
(dom-set-prop el "value" v))))))
|
||||
;; Element → signal (event listener)
|
||||
(dom-listen el (if is-checkbox "change" "input")
|
||||
(fn (e)
|
||||
(if is-checkbox
|
||||
(reset! sig (dom-get-prop el "checked"))
|
||||
(reset! sig (dom-get-prop el "value"))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-dom-portal — render children into a remote target element
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (portal "#modal-root" (div "content"))
|
||||
;;
|
||||
;; Renders children into the DOM node matched by the selector, rather than
|
||||
;; into the current position. Returns a comment marker at the original
|
||||
;; position. Registers a disposer to clean up portal content on island
|
||||
;; teardown.
|
||||
|
||||
(define render-dom-portal
|
||||
(fn (args env ns)
|
||||
(let ((selector (trampoline (eval-expr (first args) env)))
|
||||
(target (or (dom-query selector)
|
||||
(dom-ensure-element selector))))
|
||||
(if (not target)
|
||||
(create-comment (str "portal: " selector " (not found)"))
|
||||
(let ((marker (create-comment (str "portal: " selector)))
|
||||
(frag (create-fragment)))
|
||||
;; Render children into the fragment
|
||||
(for-each
|
||||
(fn (child) (dom-append frag (render-to-dom child env ns)))
|
||||
(rest args))
|
||||
;; Track portal nodes for disposal
|
||||
(let ((portal-nodes (dom-child-nodes frag)))
|
||||
;; Append into remote target
|
||||
(dom-append target frag)
|
||||
;; Register disposer: remove portal content on island teardown
|
||||
(register-in-scope
|
||||
(fn ()
|
||||
(for-each (fn (n) (dom-remove n)) portal-nodes))))
|
||||
;; Return marker at original position
|
||||
marker)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-dom-error-boundary — catch errors, render fallback UI
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; (error-boundary fallback-fn body...)
|
||||
;;
|
||||
;; Renders body children inside a try/catch. If any child throws during
|
||||
;; rendering, the fallback function is called with the error object, and
|
||||
;; its result is rendered instead. Effects within the boundary are disposed
|
||||
;; on error.
|
||||
;;
|
||||
;; The fallback function receives the error and a retry thunk:
|
||||
;; (fn (err retry) ...)
|
||||
;; Calling (retry) re-renders the body, replacing the fallback.
|
||||
|
||||
(define render-dom-error-boundary
|
||||
(fn (args env ns)
|
||||
(let ((fallback-expr (first args))
|
||||
(body-exprs (rest args))
|
||||
(container (dom-create-element "div" nil))
|
||||
;; retry-version: bump this signal to force re-render after fallback
|
||||
(retry-version (signal 0)))
|
||||
(dom-set-attr container "data-sx-boundary" "true")
|
||||
|
||||
;; The entire body is rendered inside ONE effect + try-catch.
|
||||
;; Body renders WITHOUT *island-scope* so that if/when/cond use static
|
||||
;; paths — their signal reads become direct deref calls tracked by THIS
|
||||
;; effect. Errors from signal changes throw synchronously within try-catch.
|
||||
;; The error boundary's own effect handles all reactivity for its subtree.
|
||||
(effect (fn ()
|
||||
;; Touch retry-version so the effect re-runs when retry is called
|
||||
(deref retry-version)
|
||||
|
||||
;; Clear container
|
||||
(dom-set-prop container "innerHTML" "")
|
||||
|
||||
;; Save and clear island scope BEFORE try-catch so it can be
|
||||
;; restored in both success and error paths.
|
||||
(let ((saved-scope *island-scope*))
|
||||
(set! *island-scope* nil)
|
||||
(try-catch
|
||||
(fn ()
|
||||
;; Body renders statically — signal reads tracked by THIS effect,
|
||||
;; throws propagate to our try-catch.
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (child)
|
||||
(dom-append frag (render-to-dom child env ns)))
|
||||
body-exprs)
|
||||
(dom-append container frag))
|
||||
(set! *island-scope* saved-scope))
|
||||
(fn (err)
|
||||
;; Restore scope first, then render fallback
|
||||
(set! *island-scope* saved-scope)
|
||||
(let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
|
||||
(retry-fn (fn () (swap! retry-version (fn (n) (+ n 1))))))
|
||||
(let ((fallback-dom
|
||||
(if (lambda? fallback-fn)
|
||||
(render-lambda-dom fallback-fn (list err retry-fn) env ns)
|
||||
(render-to-dom (apply fallback-fn (list err retry-fn)) env ns))))
|
||||
(dom-append container fallback-dom))))))))
|
||||
|
||||
container)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — DOM adapter
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -422,11 +938,32 @@
|
||||
;; (dom-create-element tag ns) → Element (ns=nil for HTML, string for SVG/MathML)
|
||||
;; (create-text-node s) → Text node
|
||||
;; (create-fragment) → DocumentFragment
|
||||
;; (create-comment s) → Comment node
|
||||
;;
|
||||
;; Tree mutation:
|
||||
;; (dom-append parent child) → void (appendChild)
|
||||
;; (dom-set-attr el name val) → void (setAttribute)
|
||||
;; (dom-remove-attr el name) → void (removeAttribute)
|
||||
;; (dom-get-attr el name) → string or nil (getAttribute)
|
||||
;; (dom-set-text-content n s) → void (set textContent)
|
||||
;; (dom-remove node) → void (remove from parent)
|
||||
;; (dom-insert-after ref node) → void (insert node after ref)
|
||||
;; (dom-parent node) → parent Element or nil
|
||||
;; (dom-child-nodes frag) → list of child nodes
|
||||
;; (dom-remove-children-after m)→ void (remove all siblings after marker)
|
||||
;; (dom-set-data el key val) → void (store arbitrary data on element)
|
||||
;; (dom-get-data el key) → any (retrieve data stored on element)
|
||||
;;
|
||||
;; Property access (for input binding):
|
||||
;; (dom-set-prop el name val) → void (set JS property: el[name] = val)
|
||||
;; (dom-get-prop el name) → any (read JS property: el[name])
|
||||
;;
|
||||
;; Query (for portals):
|
||||
;; (dom-query selector) → Element or nil (document.querySelector)
|
||||
;;
|
||||
;; Event handling:
|
||||
;; (dom-listen el name handler) → remove-fn (addEventListener, returns remover)
|
||||
;; (dom-dispatch el name detail)→ boolean (dispatch CustomEvent, bubbles: true)
|
||||
;;
|
||||
;; Content parsing:
|
||||
;; (dom-parse-html s) → DocumentFragment from HTML string
|
||||
@@ -441,11 +978,18 @@
|
||||
;; From eval.sx:
|
||||
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
|
||||
;; env-has?, env-get, env-set!, env-merge
|
||||
;; lambda?, component?, macro?
|
||||
;; lambda?, component?, island?, macro?
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;; component-params, component-body, component-closure,
|
||||
;; component-has-children?, component-name
|
||||
;;
|
||||
;; From signals.sx:
|
||||
;; signal, deref, reset!, swap!, computed, effect, batch
|
||||
;; signal?, with-island-scope, register-in-scope
|
||||
;;
|
||||
;; Pure primitives used:
|
||||
;; keys, get, str
|
||||
;;
|
||||
;; Iteration:
|
||||
;; (for-each-indexed fn coll) → call fn(index, item) for each element
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
;; parse-element-args, render-attrs, definition-form?
|
||||
;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings,
|
||||
;; eval-cond, env-has?, env-get, env-set!, env-merge,
|
||||
;; lambda?, component?, macro?,
|
||||
;; lambda?, component?, island?, macro?,
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;; ==========================================================================
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
(define RENDER_HTML_FORMS
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defmacro" "defstyle" "defhandler"
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"map" "map-indexed" "filter" "for-each"))
|
||||
|
||||
(define render-html-form?
|
||||
@@ -85,6 +85,12 @@
|
||||
(contains? HTML_TAGS name)
|
||||
(render-html-element name args env)
|
||||
|
||||
;; Island (~name) — reactive component, SSR with hydration markers
|
||||
(and (starts-with? name "~")
|
||||
(env-has? env name)
|
||||
(island? (env-get env name)))
|
||||
(render-html-island (env-get env name) args env)
|
||||
|
||||
;; Component or macro call (~name)
|
||||
(starts-with? name "~")
|
||||
(let ((val (env-get env name)))
|
||||
@@ -287,6 +293,85 @@
|
||||
"</" tag ">"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; render-html-island — SSR rendering of a reactive island
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Renders the island body as static HTML wrapped in a container element
|
||||
;; with data-sx-island and data-sx-state attributes. The client hydrates
|
||||
;; this by finding these elements and re-rendering with reactive context.
|
||||
;;
|
||||
;; On the server, signal/deref/reset!/swap! are simple passthrough:
|
||||
;; (signal val) → returns val (no container needed server-side)
|
||||
;; (deref s) → returns s (signal values are plain values server-side)
|
||||
;; (reset! s v) → no-op
|
||||
;; (swap! s f) → no-op
|
||||
|
||||
(define render-html-island
|
||||
(fn (island args env)
|
||||
;; Parse kwargs and children (same pattern as render-html-component)
|
||||
(let ((kwargs (dict))
|
||||
(children (list)))
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
(let ((skip (get state "skip")))
|
||||
(if skip
|
||||
(assoc state "skip" false "i" (inc (get state "i")))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc (get state "i")) (len args)))
|
||||
(let ((val (trampoline
|
||||
(eval-expr (nth args (inc (get state "i"))) env))))
|
||||
(dict-set! kwargs (keyword-name arg) val)
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
(do
|
||||
(append! children arg)
|
||||
(assoc state "i" (inc (get state "i"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
|
||||
;; Build island env: closure + caller env + params
|
||||
(let ((local (env-merge (component-closure island) env))
|
||||
(island-name (component-name island)))
|
||||
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn (p)
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params island))
|
||||
|
||||
;; If island accepts children, pre-render them to raw HTML
|
||||
(when (component-has-children? island)
|
||||
(env-set! local "children"
|
||||
(make-raw-html
|
||||
(join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
|
||||
;; Render the island body as HTML
|
||||
(let ((body-html (render-to-html (component-body island) local))
|
||||
(state-json (serialize-island-state kwargs)))
|
||||
;; Wrap in container with hydration attributes
|
||||
(str "<div data-sx-island=\"" (escape-attr island-name) "\""
|
||||
(if state-json
|
||||
(str " data-sx-state=\"" (escape-attr state-json) "\"")
|
||||
"")
|
||||
">"
|
||||
body-html
|
||||
"</div>"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; serialize-island-state — serialize kwargs to JSON for hydration
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Only serializes simple values (numbers, strings, booleans, nil, lists, dicts).
|
||||
;; Functions, components, and other non-serializable values are skipped.
|
||||
|
||||
(define serialize-island-state
|
||||
(fn (kwargs)
|
||||
(if (empty-dict? kwargs)
|
||||
nil
|
||||
(json-serialize kwargs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — HTML adapter
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -297,7 +382,7 @@
|
||||
;; From eval.sx:
|
||||
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
|
||||
;; env-has?, env-get, env-set!, env-merge
|
||||
;; lambda?, component?, macro?
|
||||
;; lambda?, component?, island?, macro?
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;; component-params, component-body, component-closure,
|
||||
;; component-has-children?, component-name
|
||||
@@ -305,6 +390,11 @@
|
||||
;; Raw HTML construction:
|
||||
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
|
||||
;;
|
||||
;; JSON serialization (for island state):
|
||||
;; (json-serialize dict) → JSON string
|
||||
;; (empty-dict? d) → boolean
|
||||
;; (escape-attr s) → HTML attribute escape
|
||||
;;
|
||||
;; Iteration:
|
||||
;; (for-each-indexed fn coll) → call fn(index, item) for each element
|
||||
;; (map-indexed fn coll) → map fn(index, item) over each element
|
||||
|
||||
@@ -83,12 +83,14 @@
|
||||
(let ((f (trampoline (eval-expr head env)))
|
||||
(evaled-args (map (fn (a) (trampoline (eval-expr a env))) args)))
|
||||
(cond
|
||||
(and (callable? f) (not (lambda? f)) (not (component? f)))
|
||||
(and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f)))
|
||||
(apply f evaled-args)
|
||||
(lambda? f)
|
||||
(trampoline (call-lambda f evaled-args env))
|
||||
(component? f)
|
||||
(aser-call (str "~" (component-name f)) args env)
|
||||
(island? f)
|
||||
(aser-call (str "~" (component-name f)) args env)
|
||||
:else (error (str "Not callable: " (inspect f)))))))))))
|
||||
|
||||
|
||||
@@ -128,20 +130,191 @@
|
||||
(str "(" (join " " parts) ")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Form classification
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define SPECIAL_FORM_NAMES
|
||||
(list "if" "when" "cond" "case" "and" "or"
|
||||
"let" "let*" "lambda" "fn"
|
||||
"define" "defcomp" "defmacro" "defstyle"
|
||||
"defhandler" "defpage" "defquery" "defaction" "defrelation"
|
||||
"begin" "do" "quote" "quasiquote"
|
||||
"->" "set!" "letrec" "dynamic-wind" "defisland"))
|
||||
|
||||
(define HO_FORM_NAMES
|
||||
(list "map" "map-indexed" "filter" "reduce"
|
||||
"some" "every?" "for-each"))
|
||||
|
||||
(define special-form?
|
||||
(fn (name)
|
||||
(contains? SPECIAL_FORM_NAMES name)))
|
||||
|
||||
(define ho-form?
|
||||
(fn (name)
|
||||
(contains? HO_FORM_NAMES name)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; aser-special — evaluate special/HO forms in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Control flow forms evaluate conditions normally but render branches
|
||||
;; through aser (serializing tags/components instead of rendering HTML).
|
||||
;; Definition forms evaluate for side effects and return nil.
|
||||
|
||||
(define aser-special
|
||||
(fn (name expr env)
|
||||
(let ((args (rest expr)))
|
||||
(cond
|
||||
;; if — evaluate condition, aser chosen branch
|
||||
(= name "if")
|
||||
(if (trampoline (eval-expr (first args) env))
|
||||
(aser (nth args 1) env)
|
||||
(if (> (len args) 2)
|
||||
(aser (nth args 2) env)
|
||||
nil))
|
||||
|
||||
;; when — evaluate condition, aser body if true
|
||||
(= name "when")
|
||||
(if (not (trampoline (eval-expr (first args) env)))
|
||||
nil
|
||||
(let ((result nil))
|
||||
(for-each (fn (body) (set! result (aser body env)))
|
||||
(rest args))
|
||||
result))
|
||||
|
||||
;; cond — evaluate conditions, aser matching branch
|
||||
(= name "cond")
|
||||
(let ((branch (eval-cond args env)))
|
||||
(if branch (aser branch env) nil))
|
||||
|
||||
;; case — evaluate match value, check each pair
|
||||
(= name "case")
|
||||
(let ((match-val (trampoline (eval-expr (first args) env)))
|
||||
(clauses (rest args)))
|
||||
(eval-case-aser match-val clauses env))
|
||||
|
||||
;; let / let*
|
||||
(or (= name "let") (= name "let*"))
|
||||
(let ((local (process-bindings (first args) env))
|
||||
(result nil))
|
||||
(for-each (fn (body) (set! result (aser body local)))
|
||||
(rest args))
|
||||
result)
|
||||
|
||||
;; begin / do
|
||||
(or (= name "begin") (= name "do"))
|
||||
(let ((result nil))
|
||||
(for-each (fn (body) (set! result (aser body env))) args)
|
||||
result)
|
||||
|
||||
;; and — short-circuit
|
||||
(= name "and")
|
||||
(let ((result true))
|
||||
(some (fn (arg)
|
||||
(set! result (trampoline (eval-expr arg env)))
|
||||
(not result))
|
||||
args)
|
||||
result)
|
||||
|
||||
;; or — short-circuit
|
||||
(= name "or")
|
||||
(let ((result false))
|
||||
(some (fn (arg)
|
||||
(set! result (trampoline (eval-expr arg env)))
|
||||
result)
|
||||
args)
|
||||
result)
|
||||
|
||||
;; map — evaluate function and collection, map through aser
|
||||
(= name "map")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map (fn (item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) item)
|
||||
(aser (lambda-body f) local))
|
||||
(invoke f item)))
|
||||
coll))
|
||||
|
||||
;; map-indexed
|
||||
(= name "map-indexed")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env))))
|
||||
(map-indexed (fn (i item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) i)
|
||||
(env-set! local (nth (lambda-params f) 1) item)
|
||||
(aser (lambda-body f) local))
|
||||
(invoke f i item)))
|
||||
coll))
|
||||
|
||||
;; for-each — evaluate for side effects, aser each body
|
||||
(= name "for-each")
|
||||
(let ((f (trampoline (eval-expr (first args) env)))
|
||||
(coll (trampoline (eval-expr (nth args 1) env)))
|
||||
(results (list)))
|
||||
(for-each (fn (item)
|
||||
(if (lambda? f)
|
||||
(let ((local (env-merge (lambda-closure f) env)))
|
||||
(env-set! local (first (lambda-params f)) item)
|
||||
(append! results (aser (lambda-body f) local)))
|
||||
(invoke f item)))
|
||||
coll)
|
||||
(if (empty? results) nil results))
|
||||
|
||||
;; defisland — evaluate AND serialize (client needs the definition)
|
||||
(= name "defisland")
|
||||
(do (trampoline (eval-expr expr env))
|
||||
(serialize expr))
|
||||
|
||||
;; Definition forms — evaluate for side effects
|
||||
(or (= name "define") (= name "defcomp") (= name "defmacro")
|
||||
(= name "defstyle") (= name "defhandler") (= name "defpage")
|
||||
(= name "defquery") (= name "defaction") (= name "defrelation"))
|
||||
(do (trampoline (eval-expr expr env)) nil)
|
||||
|
||||
;; Everything else — evaluate normally
|
||||
:else
|
||||
(trampoline (eval-expr expr env))))))
|
||||
|
||||
|
||||
;; Helper: case dispatch for aser mode
|
||||
(define eval-case-aser
|
||||
(fn (match-val clauses env)
|
||||
(if (< (len clauses) 2)
|
||||
nil
|
||||
(let ((test (first clauses))
|
||||
(body (nth clauses 1)))
|
||||
(if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else"))
|
||||
(and (= (type-of test) "symbol")
|
||||
(or (= (symbol-name test) ":else")
|
||||
(= (symbol-name test) "else"))))
|
||||
(aser body env)
|
||||
(if (= match-val (trampoline (eval-expr test env)))
|
||||
(aser body env)
|
||||
(eval-case-aser match-val (slice clauses 2) env)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — SX wire adapter
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Serialization:
|
||||
;; (serialize val) → SX source string representation of val
|
||||
;;
|
||||
;; Form classification:
|
||||
;; (special-form? name) → boolean
|
||||
;; (ho-form? name) → boolean
|
||||
;; (aser-special name expr env) → evaluate special/HO form through aser
|
||||
;;
|
||||
;; From eval.sx:
|
||||
;; eval-expr, trampoline, call-lambda, expand-macro
|
||||
;; env-has?, env-get, callable?, lambda?, component?, macro?
|
||||
;; primitive?, get-primitive, component-name
|
||||
;; env-has?, env-get, env-set!, env-merge, callable?, lambda?, component?,
|
||||
;; macro?, island?, primitive?, get-primitive, component-name
|
||||
;; lambda-closure, lambda-params, lambda-body
|
||||
;;
|
||||
;; From render.sx:
|
||||
;; HTML_TAGS, eval-cond, process-bindings
|
||||
;;
|
||||
;; From parser.sx:
|
||||
;; serialize (= sx-serialize)
|
||||
;;
|
||||
;; From signals.sx (optional):
|
||||
;; invoke
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -84,9 +84,10 @@
|
||||
(dom-append el node)
|
||||
;; Hoist head elements from rendered content
|
||||
(hoist-head-elements-full el)
|
||||
;; Process sx- attributes and hydrate
|
||||
;; Process sx- attributes, hydrate data-sx and islands
|
||||
(process-elements el)
|
||||
(sx-hydrate-elements el))))))
|
||||
(sx-hydrate-elements el)
|
||||
(sx-hydrate-islands el))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -117,6 +118,7 @@
|
||||
exprs)
|
||||
(process-elements el)
|
||||
(sx-hydrate-elements el)
|
||||
(sx-hydrate-islands el)
|
||||
(dom-dispatch el "sx:resolved" {:id id})))
|
||||
(log-warn (str "resolveSuspense: no element for id=" id))))))
|
||||
|
||||
@@ -305,6 +307,100 @@
|
||||
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Island hydration — activate reactive islands from SSR output
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; The server renders islands as:
|
||||
;; <div data-sx-island="counter" data-sx-state='{"initial": 0}'>
|
||||
;; ...static HTML...
|
||||
;; </div>
|
||||
;;
|
||||
;; Hydration:
|
||||
;; 1. Find all [data-sx-island] elements
|
||||
;; 2. Look up the island component by name
|
||||
;; 3. Parse data-sx-state into kwargs
|
||||
;; 4. Re-render the island body in a reactive context
|
||||
;; 5. Morph existing DOM to preserve structure, focus, scroll
|
||||
;; 6. Store disposers on the element for cleanup
|
||||
|
||||
(define sx-hydrate-islands
|
||||
(fn (root)
|
||||
(let ((els (dom-query-all (or root (dom-body)) "[data-sx-island]")))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(when (not (is-processed? el "island-hydrated"))
|
||||
(mark-processed! el "island-hydrated")
|
||||
(hydrate-island el)))
|
||||
els))))
|
||||
|
||||
(define hydrate-island
|
||||
(fn (el)
|
||||
(let ((name (dom-get-attr el "data-sx-island"))
|
||||
(state-json (or (dom-get-attr el "data-sx-state") "{}")))
|
||||
(let ((comp-name (str "~" name))
|
||||
(env (get-render-env nil)))
|
||||
(let ((comp (env-get env comp-name)))
|
||||
(if (not (or (component? comp) (island? comp)))
|
||||
(log-warn (str "hydrate-island: unknown island " comp-name))
|
||||
|
||||
;; Parse state and build keyword args
|
||||
(let ((kwargs (json-parse state-json))
|
||||
(disposers (list))
|
||||
(local (env-merge (component-closure comp) env)))
|
||||
|
||||
;; Bind params from kwargs
|
||||
(for-each
|
||||
(fn (p)
|
||||
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params comp))
|
||||
|
||||
;; Render the island body in a reactive scope
|
||||
(let ((body-dom
|
||||
(with-island-scope
|
||||
(fn (disposable) (append! disposers disposable))
|
||||
(fn () (render-to-dom (component-body comp) local nil)))))
|
||||
|
||||
;; Clear existing content and append reactive DOM directly.
|
||||
;; Unlike morph-children, this preserves addEventListener-based
|
||||
;; event handlers on the freshly rendered nodes.
|
||||
(dom-set-text-content el "")
|
||||
(dom-append el body-dom)
|
||||
|
||||
;; Store disposers for cleanup
|
||||
(dom-set-data el "sx-disposers" disposers)
|
||||
|
||||
;; Process any sx- attributes on new content
|
||||
(process-elements el)
|
||||
|
||||
(log-info (str "hydrated island: " comp-name
|
||||
" (" (len disposers) " disposers)"))))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Island disposal — clean up when island removed from DOM
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define dispose-island
|
||||
(fn (el)
|
||||
(let ((disposers (dom-get-data el "sx-disposers")))
|
||||
(when disposers
|
||||
(for-each
|
||||
(fn (d)
|
||||
(when (callable? d) (d)))
|
||||
disposers)
|
||||
(dom-set-data el "sx-disposers" nil)))))
|
||||
|
||||
(define dispose-islands-in
|
||||
(fn (root)
|
||||
;; Dispose all islands within root before a swap replaces them.
|
||||
(when root
|
||||
(let ((islands (dom-query-all root "[data-sx-island]")))
|
||||
(when (and islands (not (empty? islands)))
|
||||
(log-info (str "disposing " (len islands) " island(s)"))
|
||||
(for-each dispose-island islands))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Full boot sequence
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -317,13 +413,15 @@
|
||||
;; 3. Process scripts (components + mounts)
|
||||
;; 4. Process page registry (client-side routing)
|
||||
;; 5. Hydrate [data-sx] elements
|
||||
;; 6. Process engine elements
|
||||
;; 6. Hydrate [data-sx-island] elements (reactive islands)
|
||||
;; 7. Process engine elements
|
||||
(do
|
||||
(log-info (str "sx-browser " SX_VERSION))
|
||||
(init-css-tracking)
|
||||
(process-page-scripts)
|
||||
(process-sx-scripts nil)
|
||||
(sx-hydrate-elements nil)
|
||||
(sx-hydrate-islands nil)
|
||||
(process-elements nil))))
|
||||
|
||||
|
||||
@@ -382,8 +480,25 @@
|
||||
;; (log-info msg) → void (console.log with prefix)
|
||||
;; (log-parse-error label text err) → void (diagnostic parse error)
|
||||
;;
|
||||
;; === JSON parsing ===
|
||||
;; === JSON ===
|
||||
;; (json-parse str) → dict/list/value (JSON.parse)
|
||||
;;
|
||||
;; === Processing markers ===
|
||||
;; (mark-processed! el key) → void
|
||||
;; (is-processed? el key) → boolean
|
||||
;;
|
||||
;; === Morph ===
|
||||
;; (morph-children target source) → void (morph target's children to match source)
|
||||
;;
|
||||
;; === Island support (from adapter-dom.sx / signals.sx) ===
|
||||
;; (island? x) → boolean
|
||||
;; (component-closure comp) → env
|
||||
;; (component-params comp) → list of param names
|
||||
;; (component-body comp) → AST
|
||||
;; (component-name comp) → string
|
||||
;; (component-has-children? comp) → boolean
|
||||
;; (with-island-scope scope-fn body-fn) → result (track disposables)
|
||||
;; (render-to-dom expr env ns) → DOM node
|
||||
;; (dom-get-data el key) → any (from el._sxData)
|
||||
;; (dom-set-data el key val) → void
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -102,9 +102,11 @@ class JSEmitter:
|
||||
# Map SX names to JS names
|
||||
return self._mangle(name)
|
||||
|
||||
def _mangle(self, name: str) -> str:
|
||||
"""Convert SX identifier to valid JS identifier."""
|
||||
RENAMES = {
|
||||
# Explicit SX→JS name mappings. Auto-mangle (kebab→camelCase, ?→_p, !→_b)
|
||||
# is only a fallback. Every platform symbol used in spec .sx files MUST have
|
||||
# an entry here — relying on auto-mangle is fragile and has caused runtime
|
||||
# errors (e.g. has-key? → hasKey_p instead of dictHas).
|
||||
RENAMES = {
|
||||
"nil": "NIL",
|
||||
"true": "true",
|
||||
"false": "false",
|
||||
@@ -139,6 +141,39 @@ class JSEmitter:
|
||||
"callable?": "isCallable",
|
||||
"lambda?": "isLambda",
|
||||
"component?": "isComponent",
|
||||
"island?": "isIsland",
|
||||
"make-island": "makeIsland",
|
||||
"make-signal": "makeSignal",
|
||||
"signal?": "isSignal",
|
||||
"signal-value": "signalValue",
|
||||
"signal-set-value!": "signalSetValue",
|
||||
"signal-subscribers": "signalSubscribers",
|
||||
"signal-add-sub!": "signalAddSub",
|
||||
"signal-remove-sub!": "signalRemoveSub",
|
||||
"signal-deps": "signalDeps",
|
||||
"signal-set-deps!": "signalSetDeps",
|
||||
"set-tracking-context!": "setTrackingContext",
|
||||
"get-tracking-context": "getTrackingContext",
|
||||
"make-tracking-context": "makeTrackingContext",
|
||||
"tracking-context-deps": "trackingContextDeps",
|
||||
"tracking-context-add-dep!": "trackingContextAddDep",
|
||||
"tracking-context-notify-fn": "trackingContextNotifyFn",
|
||||
"identical?": "isIdentical",
|
||||
"notify-subscribers": "notifySubscribers",
|
||||
"flush-subscribers": "flushSubscribers",
|
||||
"dispose-computed": "disposeComputed",
|
||||
"with-island-scope": "withIslandScope",
|
||||
"register-in-scope": "registerInScope",
|
||||
"*batch-depth*": "_batchDepth",
|
||||
"*batch-queue*": "_batchQueue",
|
||||
"*island-scope*": "_islandScope",
|
||||
"*store-registry*": "_storeRegistry",
|
||||
"def-store": "defStore",
|
||||
"use-store": "useStore",
|
||||
"clear-stores": "clearStores",
|
||||
"emit-event": "emitEvent",
|
||||
"on-event": "onEvent",
|
||||
"bridge-event": "bridgeEvent",
|
||||
"macro?": "isMacro",
|
||||
"primitive?": "isPrimitive",
|
||||
"get-primitive": "getPrimitive",
|
||||
@@ -166,12 +201,20 @@ class JSEmitter:
|
||||
"render-list-to-html": "renderListToHtml",
|
||||
"render-html-element": "renderHtmlElement",
|
||||
"render-html-component": "renderHtmlComponent",
|
||||
"render-html-island": "renderHtmlIsland",
|
||||
"serialize-island-state": "serializeIslandState",
|
||||
"json-serialize": "jsonSerialize",
|
||||
"empty-dict?": "isEmptyDict",
|
||||
"parse-element-args": "parseElementArgs",
|
||||
"render-attrs": "renderAttrs",
|
||||
"aser-list": "aserList",
|
||||
"aser-fragment": "aserFragment",
|
||||
"aser-call": "aserCall",
|
||||
"aser-special": "aserSpecial",
|
||||
"eval-case-aser": "evalCaseAser",
|
||||
"sx-serialize": "sxSerialize",
|
||||
"sx-serialize-dict": "sxSerializeDict",
|
||||
"sx-expr-source": "sxExprSource",
|
||||
"sf-if": "sfIf",
|
||||
"sf-when": "sfWhen",
|
||||
"sf-cond": "sfCond",
|
||||
@@ -191,6 +234,7 @@ class JSEmitter:
|
||||
"sf-lambda": "sfLambda",
|
||||
"sf-define": "sfDefine",
|
||||
"sf-defcomp": "sfDefcomp",
|
||||
"sf-defisland": "sfDefisland",
|
||||
"defcomp-kwarg": "defcompKwarg",
|
||||
"sf-defmacro": "sfDefmacro",
|
||||
"sf-begin": "sfBegin",
|
||||
@@ -240,6 +284,11 @@ class JSEmitter:
|
||||
"render-dom-form?": "isRenderDomForm",
|
||||
"dispatch-render-form": "dispatchRenderForm",
|
||||
"render-lambda-dom": "renderLambdaDom",
|
||||
"render-dom-island": "renderDomIsland",
|
||||
"reactive-text": "reactiveText",
|
||||
"reactive-attr": "reactiveAttr",
|
||||
"reactive-fragment": "reactiveFragment",
|
||||
"reactive-list": "reactiveList",
|
||||
"dom-create-element": "domCreateElement",
|
||||
"dom-append": "domAppend",
|
||||
"dom-set-attr": "domSetAttr",
|
||||
@@ -278,10 +327,21 @@ class JSEmitter:
|
||||
"dom-add-class": "domAddClass",
|
||||
"dom-remove-class": "domRemoveClass",
|
||||
"dom-dispatch": "domDispatch",
|
||||
"dom-listen": "domListen",
|
||||
"event-detail": "eventDetail",
|
||||
"dom-query": "domQuery",
|
||||
"dom-ensure-element": "domEnsureElement",
|
||||
"dom-query-all": "domQueryAll",
|
||||
"dom-tag-name": "domTagName",
|
||||
"create-comment": "createComment",
|
||||
"dom-remove": "domRemove",
|
||||
"dom-child-nodes": "domChildNodes",
|
||||
"dom-remove-children-after": "domRemoveChildrenAfter",
|
||||
"dom-set-data": "domSetData",
|
||||
"dom-get-data": "domGetData",
|
||||
"json-parse": "jsonParse",
|
||||
"dict-has?": "dictHas",
|
||||
"has-key?": "dictHas",
|
||||
"dict-delete!": "dictDelete",
|
||||
"process-bindings": "processBindings",
|
||||
"eval-cond": "evalCond",
|
||||
@@ -345,6 +405,7 @@ class JSEmitter:
|
||||
"bind-sse": "bindSse",
|
||||
"bind-sse-swap": "bindSseSwap",
|
||||
"bind-inline-handlers": "bindInlineHandlers",
|
||||
"process-emit-elements": "processEmitElements",
|
||||
"bind-preload-for": "bindPreloadFor",
|
||||
"do-preload": "doPreload",
|
||||
"VERB_SELECTOR": "VERB_SELECTOR",
|
||||
@@ -354,7 +415,9 @@ class JSEmitter:
|
||||
"engine-init": "engineInit",
|
||||
# engine orchestration platform
|
||||
"promise-resolve": "promiseResolve",
|
||||
"promise-then": "promiseThen",
|
||||
"promise-catch": "promiseCatch",
|
||||
"promise-delayed": "promiseDelayed",
|
||||
"abort-previous": "abortPrevious",
|
||||
"track-controller": "trackController",
|
||||
"new-abort-controller": "newAbortController",
|
||||
@@ -363,6 +426,7 @@ class JSEmitter:
|
||||
"set-timeout": "setTimeout_",
|
||||
"set-interval": "setInterval_",
|
||||
"clear-timeout": "clearTimeout_",
|
||||
"clear-interval": "clearInterval_",
|
||||
"request-animation-frame": "requestAnimationFrame_",
|
||||
"csrf-token": "csrfToken",
|
||||
"cross-origin?": "isCrossOrigin",
|
||||
@@ -386,6 +450,11 @@ class JSEmitter:
|
||||
"dom-outer-html": "domOuterHtml",
|
||||
"dom-body-inner-html": "domBodyInnerHtml",
|
||||
"prevent-default": "preventDefault_",
|
||||
"stop-propagation": "stopPropagation_",
|
||||
"dom-focus": "domFocus",
|
||||
"try-catch": "tryCatch",
|
||||
"error-message": "errorMessage",
|
||||
"schedule-idle": "scheduleIdle",
|
||||
"element-value": "elementValue",
|
||||
"validate-for-request": "validateForRequest",
|
||||
"with-transition": "withTransition",
|
||||
@@ -467,6 +536,9 @@ class JSEmitter:
|
||||
"process-component-script": "processComponentScript",
|
||||
"SX_VERSION": "SX_VERSION",
|
||||
"boot-init": "bootInit",
|
||||
"sx-hydrate-islands": "sxHydrateIslands",
|
||||
"hydrate-island": "hydrateIsland",
|
||||
"dispose-island": "disposeIsland",
|
||||
"resolve-suspense": "resolveSuspense",
|
||||
"resolve-mount-target": "resolveMountTarget",
|
||||
"sx-render-with-env": "sxRenderWithEnv",
|
||||
@@ -523,9 +595,12 @@ class JSEmitter:
|
||||
"match-route": "matchRoute",
|
||||
"find-matching-route": "findMatchingRoute",
|
||||
"for-each-indexed": "forEachIndexed",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
}
|
||||
|
||||
def _mangle(self, name: str) -> str:
|
||||
"""Convert SX identifier to valid JS identifier."""
|
||||
if name in self.RENAMES:
|
||||
return self.RENAMES[name]
|
||||
# General mangling: replace - with camelCase, ? with _p, ! with _b
|
||||
result = name
|
||||
if result.endswith("?"):
|
||||
@@ -605,17 +680,39 @@ class JSEmitter:
|
||||
params = expr[1]
|
||||
body = expr[2:]
|
||||
param_names = []
|
||||
for p in params:
|
||||
rest_name = None
|
||||
i = 0
|
||||
while i < len(params):
|
||||
p = params[i]
|
||||
if isinstance(p, Symbol) and p.name == "&rest":
|
||||
# Next param is the rest parameter
|
||||
if i + 1 < len(params):
|
||||
rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1]))
|
||||
i += 2
|
||||
continue
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
if isinstance(p, Symbol):
|
||||
param_names.append(self._mangle(p.name))
|
||||
else:
|
||||
param_names.append(str(p))
|
||||
i += 1
|
||||
params_str = ", ".join(param_names)
|
||||
# Build rest-param preamble if needed
|
||||
rest_preamble = ""
|
||||
if rest_name:
|
||||
n = len(param_names)
|
||||
rest_preamble = f"var {rest_name} = Array.prototype.slice.call(arguments, {n}); "
|
||||
if len(body) == 1:
|
||||
body_js = self.emit(body[0])
|
||||
if rest_preamble:
|
||||
return f"function({params_str}) {{ {rest_preamble}return {body_js}; }}"
|
||||
return f"function({params_str}) {{ return {body_js}; }}"
|
||||
# Multi-expression body: statements then return last
|
||||
parts = []
|
||||
if rest_preamble:
|
||||
parts.append(rest_preamble.rstrip())
|
||||
for b in body[:-1]:
|
||||
parts.append(self.emit_statement(b))
|
||||
parts.append(f"return {self.emit(body[-1])};")
|
||||
@@ -1045,6 +1142,7 @@ ADAPTER_DEPS = {
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
"signals": ("signals.sx", "signals (reactive signal runtime)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1304,9 +1402,10 @@ ASYNC_IO_JS = '''
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component
|
||||
// Component or Island
|
||||
if (hname.charAt(0) === "~") {
|
||||
var comp = env[hname];
|
||||
if (comp && comp._island) return renderDomIsland(comp, expr.slice(1), env, ns);
|
||||
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
|
||||
if (comp && comp._macro) {
|
||||
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
|
||||
@@ -1833,6 +1932,9 @@ def compile_ref_to_js(
|
||||
if sm not in SPEC_MODULES:
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
# dom adapter uses signal runtime for reactive islands
|
||||
if "dom" in adapter_set and "signals" in SPEC_MODULES:
|
||||
spec_mod_set.add("signals")
|
||||
# boot.sx uses parse-route-pattern from router.sx
|
||||
if "boot" in adapter_set:
|
||||
spec_mod_set.add("router")
|
||||
@@ -1877,6 +1979,7 @@ def compile_ref_to_js(
|
||||
has_orch = "orchestration" in adapter_set
|
||||
has_boot = "boot" in adapter_set
|
||||
has_parser = "parser" in adapter_set
|
||||
has_signals = "signals" in spec_mod_set
|
||||
adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only"
|
||||
|
||||
# Determine which primitive modules to include
|
||||
@@ -1920,12 +2023,12 @@ def compile_ref_to_js(
|
||||
if name in adapter_set and name in adapter_platform:
|
||||
parts.append(adapter_platform[name])
|
||||
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom))
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom, has_signals))
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_JS)
|
||||
if has_dom:
|
||||
parts.append(ASYNC_IO_JS)
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router))
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals))
|
||||
parts.append(EPILOGUE)
|
||||
from datetime import datetime, timezone
|
||||
build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
@@ -1984,6 +2087,29 @@ PREAMBLE = '''\
|
||||
}
|
||||
Component.prototype._component = true;
|
||||
|
||||
function Island(name, params, hasChildren, body, closure) {
|
||||
this.name = name;
|
||||
this.params = params;
|
||||
this.hasChildren = hasChildren;
|
||||
this.body = body;
|
||||
this.closure = closure || {};
|
||||
}
|
||||
Island.prototype._island = true;
|
||||
|
||||
function SxSignal(value) {
|
||||
this.value = value;
|
||||
this.subscribers = [];
|
||||
this.deps = [];
|
||||
}
|
||||
SxSignal.prototype._signal = true;
|
||||
|
||||
function TrackingCtx(notifyFn) {
|
||||
this.notifyFn = notifyFn;
|
||||
this.deps = [];
|
||||
}
|
||||
|
||||
var _trackingContext = null;
|
||||
|
||||
function Macro(params, restParam, body, closure, name) {
|
||||
this.params = params;
|
||||
this.restParam = restParam;
|
||||
@@ -2078,6 +2204,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; };
|
||||
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
|
||||
PRIMITIVES["zero?"] = function(n) { return n === 0; };
|
||||
PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; };
|
||||
''',
|
||||
|
||||
"core.strings": '''
|
||||
@@ -2099,6 +2226,9 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); };
|
||||
PRIMITIVES["string-length"] = function(s) { return String(s).length; };
|
||||
PRIMITIVES["string-contains?"] = function(s, sub) { return String(s).indexOf(String(sub)) !== -1; };
|
||||
PRIMITIVES["concat"] = function() {
|
||||
var out = [];
|
||||
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
|
||||
@@ -2134,6 +2264,12 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
PRIMITIVES["zip-pairs"] = function(c) {
|
||||
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
|
||||
};
|
||||
PRIMITIVES["reverse"] = function(c) { return Array.isArray(c) ? c.slice().reverse() : String(c).split("").reverse().join(""); };
|
||||
PRIMITIVES["flatten"] = function(c) {
|
||||
var out = [];
|
||||
function walk(a) { for (var i = 0; i < a.length; i++) Array.isArray(a[i]) ? walk(a[i]) : out.push(a[i]); }
|
||||
walk(c || []); return out;
|
||||
};
|
||||
''',
|
||||
|
||||
"core.dict": '''
|
||||
@@ -2156,6 +2292,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
||||
return out;
|
||||
};
|
||||
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
|
||||
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
|
||||
PRIMITIVES["into"] = function(target, coll) {
|
||||
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
|
||||
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
|
||||
@@ -2240,6 +2377,8 @@ PLATFORM_JS_PRE = '''
|
||||
if (x._thunk) return "thunk";
|
||||
if (x._lambda) return "lambda";
|
||||
if (x._component) return "component";
|
||||
if (x._island) return "island";
|
||||
if (x._signal) return "signal";
|
||||
if (x._macro) return "macro";
|
||||
if (x._raw) return "raw-html";
|
||||
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
||||
@@ -2287,7 +2426,52 @@ PLATFORM_JS_PRE = '''
|
||||
function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); }
|
||||
function isLambda(x) { return x != null && x._lambda === true; }
|
||||
function isComponent(x) { return x != null && x._component === true; }
|
||||
function isIsland(x) { return x != null && x._island === true; }
|
||||
function isMacro(x) { return x != null && x._macro === true; }
|
||||
function isIdentical(a, b) { return a === b; }
|
||||
|
||||
// Island platform
|
||||
function makeIsland(name, params, hasChildren, body, env) {
|
||||
return new Island(name, params, hasChildren, body, merge(env));
|
||||
}
|
||||
|
||||
// Signal platform
|
||||
function makeSignal(value) { return new SxSignal(value); }
|
||||
function isSignal(x) { return x != null && x._signal === true; }
|
||||
function signalValue(s) { return s.value; }
|
||||
function signalSetValue(s, v) { s.value = v; }
|
||||
function signalSubscribers(s) { return s.subscribers.slice(); }
|
||||
function signalAddSub(s, fn) { if (s.subscribers.indexOf(fn) < 0) s.subscribers.push(fn); }
|
||||
function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); }
|
||||
function signalDeps(s) { return s.deps.slice(); }
|
||||
function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; }
|
||||
function setTrackingContext(ctx) { _trackingContext = ctx; }
|
||||
function getTrackingContext() { return _trackingContext || NIL; }
|
||||
function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); }
|
||||
function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; }
|
||||
function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); }
|
||||
function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; }
|
||||
|
||||
// invoke — call any callable (native fn or SX lambda) with args.
|
||||
// Transpiled code emits direct calls f(args) which fail on SX lambdas
|
||||
// from runtime-evaluated island bodies. invoke dispatches correctly.
|
||||
function invoke() {
|
||||
var f = arguments[0];
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
|
||||
if (typeof f === 'function') return f.apply(null, args);
|
||||
return NIL;
|
||||
}
|
||||
|
||||
// JSON / dict helpers for island state serialization
|
||||
function jsonSerialize(obj) {
|
||||
try { return JSON.stringify(obj); } catch(e) { return "{}"; }
|
||||
}
|
||||
function isEmptyDict(d) {
|
||||
if (!d || typeof d !== "object") return true;
|
||||
for (var k in d) if (d.hasOwnProperty(k)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function envHas(env, name) { return name in env; }
|
||||
function envGet(env, name) { return env[name]; }
|
||||
@@ -2304,17 +2488,8 @@ PLATFORM_JS_PRE = '''
|
||||
|
||||
// Render-expression detection — lets the evaluator delegate to the active adapter.
|
||||
// Matches HTML tags, SVG tags, <>, raw!, ~components, html: prefix, custom elements.
|
||||
function isRenderExpr(expr) {
|
||||
if (!Array.isArray(expr) || !expr.length) return false;
|
||||
var h = expr[0];
|
||||
if (!h || !h._sym) return false;
|
||||
var n = h.name;
|
||||
return !!(n === "<>" || n === "raw!" ||
|
||||
n.charAt(0) === "~" || n.indexOf("html:") === 0 ||
|
||||
(typeof HTML_TAGS !== "undefined" && HTML_TAGS.indexOf(n) >= 0) ||
|
||||
(typeof SVG_TAGS !== "undefined" && SVG_TAGS.indexOf(n) >= 0) ||
|
||||
(n.indexOf("-") > 0 && expr.length > 1 && expr[1] && expr[1]._kw));
|
||||
}
|
||||
// Placeholder — overridden by transpiled version from render.sx
|
||||
function isRenderExpr(expr) { return false; }
|
||||
|
||||
// Render dispatch — call the active adapter's render function.
|
||||
// Set by each adapter when loaded; defaults to identity (no rendering).
|
||||
@@ -2379,7 +2554,10 @@ PLATFORM_JS_POST = '''
|
||||
var range = PRIMITIVES["range"];
|
||||
function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; }
|
||||
function append_b(arr, x) { arr.push(x); return arr; }
|
||||
var apply = function(f, args) { return f.apply(null, args); };
|
||||
var apply = function(f, args) {
|
||||
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
|
||||
return f.apply(null, args);
|
||||
};
|
||||
|
||||
// Additional primitive aliases used by adapter/engine transpiled code
|
||||
var split = PRIMITIVES["split"];
|
||||
@@ -2398,28 +2576,12 @@ PLATFORM_JS_POST = '''
|
||||
function escapeAttr(s) { return escapeHtml(s); }
|
||||
function rawHtmlContent(r) { return r.html; }
|
||||
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
||||
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
||||
|
||||
// Serializer
|
||||
function serialize(val) {
|
||||
if (isNil(val)) return "nil";
|
||||
if (typeof val === "boolean") return val ? "true" : "false";
|
||||
if (typeof val === "number") return String(val);
|
||||
if (typeof val === "string") return \'"\' + val.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, \'\\\\"\') + \'"\';
|
||||
if (isSym(val)) return val.name;
|
||||
if (isKw(val)) return ":" + val.name;
|
||||
if (Array.isArray(val)) return "(" + val.map(serialize).join(" ") + ")";
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function isSpecialForm(n) { return n in {
|
||||
"if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1,
|
||||
"lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1,
|
||||
"defhandler":1,"begin":1,"do":1,
|
||||
"quote":1,"quasiquote":1,"->":1,"set!":1
|
||||
}; }
|
||||
function isHoForm(n) { return n in {
|
||||
"map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1
|
||||
}; }
|
||||
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
||||
function serialize(val) { return String(val); }
|
||||
function isSpecialForm(n) { return false; }
|
||||
function isHoForm(n) { return false; }
|
||||
|
||||
// processBindings and evalCond — now specced in render.sx, bootstrapped above
|
||||
|
||||
@@ -2616,6 +2778,10 @@ PLATFORM_DOM_JS = """
|
||||
return _hasDom ? document.createTextNode(s) : null;
|
||||
}
|
||||
|
||||
function createComment(s) {
|
||||
return _hasDom ? document.createComment(s || "") : null;
|
||||
}
|
||||
|
||||
function createFragment() {
|
||||
return _hasDom ? document.createDocumentFragment() : null;
|
||||
}
|
||||
@@ -2739,10 +2905,38 @@ PLATFORM_DOM_JS = """
|
||||
return el.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
function domListen(el, name, handler) {
|
||||
if (!_hasDom || !el) return function() {};
|
||||
// Wrap SX lambdas from runtime-evaluated island code into native fns
|
||||
var wrapped = isLambda(handler)
|
||||
? function(e) { invoke(handler, e); }
|
||||
: handler;
|
||||
el.addEventListener(name, wrapped);
|
||||
return function() { el.removeEventListener(name, wrapped); };
|
||||
}
|
||||
|
||||
function eventDetail(e) {
|
||||
return (e && e.detail != null) ? e.detail : nil;
|
||||
}
|
||||
|
||||
function domQuery(sel) {
|
||||
return _hasDom ? document.querySelector(sel) : null;
|
||||
}
|
||||
|
||||
function domEnsureElement(sel) {
|
||||
if (!_hasDom) return null;
|
||||
var el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
// Parse #id selector → create div with that id, append to body
|
||||
if (sel.charAt(0) === '#') {
|
||||
el = document.createElement('div');
|
||||
el.id = sel.slice(1);
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function domQueryAll(root, sel) {
|
||||
if (!root || !root.querySelectorAll) return [];
|
||||
return Array.prototype.slice.call(root.querySelectorAll(sel));
|
||||
@@ -2750,79 +2944,31 @@ PLATFORM_DOM_JS = """
|
||||
|
||||
function domTagName(el) { return el && el.tagName ? el.tagName : ""; }
|
||||
|
||||
// =========================================================================
|
||||
// Performance overrides — replace transpiled spec with imperative JS
|
||||
// =========================================================================
|
||||
// Island DOM helpers
|
||||
function domRemove(node) {
|
||||
if (node && node.parentNode) node.parentNode.removeChild(node);
|
||||
}
|
||||
function domChildNodes(el) {
|
||||
if (!el || !el.childNodes) return [];
|
||||
return Array.prototype.slice.call(el.childNodes);
|
||||
}
|
||||
function domRemoveChildrenAfter(marker) {
|
||||
if (!marker || !marker.parentNode) return;
|
||||
var parent = marker.parentNode;
|
||||
while (marker.nextSibling) parent.removeChild(marker.nextSibling);
|
||||
}
|
||||
function domSetData(el, key, val) {
|
||||
if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; }
|
||||
}
|
||||
function domGetData(el, key) {
|
||||
return (el && el._sxData) ? (el._sxData[key] != null ? el._sxData[key] : nil) : nil;
|
||||
}
|
||||
function jsonParse(s) {
|
||||
try { return JSON.parse(s); } catch(e) { return {}; }
|
||||
}
|
||||
|
||||
// Override renderDomComponent: imperative kwarg parsing, no reduce/assoc
|
||||
renderDomComponent = function(comp, args, env, ns) {
|
||||
// Parse keyword args imperatively
|
||||
var kwargs = {};
|
||||
var children = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
kwargs[arg.name] = trampoline(evalExpr(args[i + 1], env));
|
||||
i++; // skip value
|
||||
} else {
|
||||
children.push(arg);
|
||||
}
|
||||
}
|
||||
// Build local env via prototype chain
|
||||
var local = Object.create(componentClosure(comp));
|
||||
// Copy caller env own properties
|
||||
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
|
||||
// Bind params
|
||||
var params = componentParams(comp);
|
||||
for (var j = 0; j < params.length; j++) {
|
||||
var p = params[j];
|
||||
local[p] = p in kwargs ? kwargs[p] : NIL;
|
||||
}
|
||||
// Bind children
|
||||
if (componentHasChildren(comp)) {
|
||||
var childFrag = document.createDocumentFragment();
|
||||
for (var c = 0; c < children.length; c++) {
|
||||
var rendered = renderToDom(children[c], env, ns);
|
||||
if (rendered) childFrag.appendChild(rendered);
|
||||
}
|
||||
local["children"] = childFrag;
|
||||
}
|
||||
return renderToDom(componentBody(comp), local, ns);
|
||||
};
|
||||
|
||||
// Override renderDomElement: imperative attr parsing, no reduce/assoc
|
||||
renderDomElement = function(tag, args, env, ns) {
|
||||
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
|
||||
var el = domCreateElement(tag, newNs);
|
||||
var extraClasses = [];
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
var attrName = arg.name;
|
||||
var attrVal = trampoline(evalExpr(args[i + 1], env));
|
||||
i++; // skip value
|
||||
if (isNil(attrVal) || attrVal === false) continue;
|
||||
if (contains(BOOLEAN_ATTRS, attrName)) {
|
||||
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
|
||||
} else if (attrVal === true) {
|
||||
el.setAttribute(attrName, "");
|
||||
} else {
|
||||
el.setAttribute(attrName, String(attrVal));
|
||||
}
|
||||
} else {
|
||||
if (!isVoid) {
|
||||
var child = renderToDom(arg, env, newNs);
|
||||
if (child) el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraClasses.length) {
|
||||
var existing = el.getAttribute("class") || "";
|
||||
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
|
||||
}
|
||||
return el;
|
||||
};
|
||||
// renderDomComponent and renderDomElement are transpiled from
|
||||
// adapter-dom.sx — no imperative overrides needed.
|
||||
"""
|
||||
|
||||
PLATFORM_ENGINE_PURE_JS = """
|
||||
@@ -2917,8 +3063,19 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
|
||||
function promiseResolve(val) { return Promise.resolve(val); }
|
||||
|
||||
function promiseThen(p, onResolve, onReject) {
|
||||
if (!p || !p.then) return p;
|
||||
return onReject ? p.then(onResolve, onReject) : p.then(onResolve);
|
||||
}
|
||||
|
||||
function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; }
|
||||
|
||||
function promiseDelayed(ms, value) {
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(function() { resolve(value); }, ms);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Abort controllers ---
|
||||
|
||||
var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null;
|
||||
@@ -2944,12 +3101,20 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
|
||||
// --- Timers ---
|
||||
|
||||
function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); }
|
||||
function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); }
|
||||
function _wrapSxFn(fn) {
|
||||
if (fn && fn._lambda) {
|
||||
return function() { return trampoline(callLambda(fn, [], lambdaClosure(fn))); };
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
function setTimeout_(fn, ms) { return setTimeout(_wrapSxFn(fn), ms || 0); }
|
||||
function setInterval_(fn, ms) { return setInterval(_wrapSxFn(fn), ms || 1000); }
|
||||
function clearTimeout_(id) { clearTimeout(id); }
|
||||
function clearInterval_(id) { clearInterval(id); }
|
||||
function requestAnimationFrame_(fn) {
|
||||
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn);
|
||||
else setTimeout(fn, 16);
|
||||
var cb = _wrapSxFn(fn);
|
||||
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(cb);
|
||||
else setTimeout(cb, 16);
|
||||
}
|
||||
|
||||
// --- Fetch ---
|
||||
@@ -3345,6 +3510,23 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
// --- Events ---
|
||||
|
||||
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
|
||||
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
|
||||
function domFocus(el) { if (el && el.focus) el.focus(); }
|
||||
function tryCatch(tryFn, catchFn) {
|
||||
var t = _wrapSxFn(tryFn);
|
||||
var c = catchFn && catchFn._lambda
|
||||
? function(e) { return trampoline(callLambda(catchFn, [e], lambdaClosure(catchFn))); }
|
||||
: catchFn;
|
||||
try { return t(); } catch (e) { return c(e); }
|
||||
}
|
||||
function errorMessage(e) {
|
||||
return e && e.message ? e.message : String(e);
|
||||
}
|
||||
function scheduleIdle(fn) {
|
||||
var cb = _wrapSxFn(fn);
|
||||
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb);
|
||||
else setTimeout(cb, 0);
|
||||
}
|
||||
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
|
||||
|
||||
function domAddListener(el, event, fn, opts) {
|
||||
@@ -3470,6 +3652,8 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
logInfo("sx:route server " + pathname);
|
||||
executeRequest(link, { method: "GET", url: liveHref }).then(function() {
|
||||
try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {}
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:route server fetch error: " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3844,7 +4028,7 @@ PLATFORM_BOOT_JS = """
|
||||
|
||||
"""
|
||||
|
||||
def fixups_js(has_html, has_sx, has_dom):
|
||||
def fixups_js(has_html, has_sx, has_dom, has_signals=False):
|
||||
lines = ['''
|
||||
// =========================================================================
|
||||
// Post-transpilation fixups
|
||||
@@ -3865,10 +4049,50 @@ def fixups_js(has_html, has_sx, has_dom):
|
||||
lines.append(' if (typeof aser === "function") PRIMITIVES["aser"] = aser;')
|
||||
if has_dom:
|
||||
lines.append(' if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;')
|
||||
if has_signals:
|
||||
lines.append('''
|
||||
// Expose signal functions as primitives so runtime-evaluated SX code
|
||||
// (e.g. island bodies from .sx files) can call them
|
||||
PRIMITIVES["signal"] = signal;
|
||||
PRIMITIVES["signal?"] = isSignal;
|
||||
PRIMITIVES["deref"] = deref;
|
||||
PRIMITIVES["reset!"] = reset_b;
|
||||
PRIMITIVES["swap!"] = swap_b;
|
||||
PRIMITIVES["computed"] = computed;
|
||||
PRIMITIVES["effect"] = effect;
|
||||
PRIMITIVES["batch"] = batch;
|
||||
// Timer primitives for island code
|
||||
PRIMITIVES["set-interval"] = setInterval_;
|
||||
PRIMITIVES["clear-interval"] = clearInterval_;
|
||||
// Reactive DOM helpers for island code
|
||||
PRIMITIVES["reactive-text"] = reactiveText;
|
||||
PRIMITIVES["create-text-node"] = createTextNode;
|
||||
PRIMITIVES["dom-set-text-content"] = domSetTextContent;
|
||||
PRIMITIVES["dom-listen"] = domListen;
|
||||
PRIMITIVES["dom-dispatch"] = domDispatch;
|
||||
PRIMITIVES["event-detail"] = eventDetail;
|
||||
PRIMITIVES["resource"] = resource;
|
||||
PRIMITIVES["promise-delayed"] = promiseDelayed;
|
||||
PRIMITIVES["promise-then"] = promiseThen;
|
||||
PRIMITIVES["def-store"] = defStore;
|
||||
PRIMITIVES["use-store"] = useStore;
|
||||
PRIMITIVES["emit-event"] = emitEvent;
|
||||
PRIMITIVES["on-event"] = onEvent;
|
||||
PRIMITIVES["bridge-event"] = bridgeEvent;
|
||||
// DOM primitives for island code
|
||||
PRIMITIVES["dom-focus"] = domFocus;
|
||||
PRIMITIVES["dom-tag-name"] = domTagName;
|
||||
PRIMITIVES["dom-get-prop"] = domGetProp;
|
||||
PRIMITIVES["stop-propagation"] = stopPropagation_;
|
||||
PRIMITIVES["error-message"] = errorMessage;
|
||||
PRIMITIVES["schedule-idle"] = scheduleIdle;
|
||||
PRIMITIVES["invoke"] = invoke;
|
||||
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
|
||||
PRIMITIVES["filter"] = filter;''')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False):
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False, has_signals=False):
|
||||
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
|
||||
if has_parser:
|
||||
parser = '''
|
||||
@@ -3994,6 +4218,8 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
|
||||
api_lines.append(' renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,')
|
||||
api_lines.append(' getEnv: function() { return componentEnv; },')
|
||||
api_lines.append(' resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,')
|
||||
api_lines.append(' hydrateIslands: typeof sxHydrateIslands === "function" ? sxHydrateIslands : null,')
|
||||
api_lines.append(' disposeIsland: typeof disposeIsland === "function" ? disposeIsland : null,')
|
||||
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
|
||||
elif has_orch:
|
||||
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
|
||||
@@ -4020,6 +4246,22 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
|
||||
api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,')
|
||||
api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,')
|
||||
api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,')
|
||||
if has_signals:
|
||||
api_lines.append(' signal: signal,')
|
||||
api_lines.append(' deref: deref,')
|
||||
api_lines.append(' reset: reset_b,')
|
||||
api_lines.append(' swap: swap_b,')
|
||||
api_lines.append(' computed: computed,')
|
||||
api_lines.append(' effect: effect,')
|
||||
api_lines.append(' batch: batch,')
|
||||
api_lines.append(' isSignal: isSignal,')
|
||||
api_lines.append(' makeSignal: makeSignal,')
|
||||
api_lines.append(' defStore: defStore,')
|
||||
api_lines.append(' useStore: useStore,')
|
||||
api_lines.append(' clearStores: clearStores,')
|
||||
api_lines.append(' emitEvent: emitEvent,')
|
||||
api_lines.append(' onEvent: onEvent,')
|
||||
api_lines.append(' bridgeEvent: bridgeEvent,')
|
||||
api_lines.append(f' _version: "{version}"')
|
||||
api_lines.append(' };')
|
||||
api_lines.append('')
|
||||
|
||||
@@ -148,6 +148,42 @@ class PyEmitter:
|
||||
"callable?": "is_callable",
|
||||
"lambda?": "is_lambda",
|
||||
"component?": "is_component",
|
||||
"island?": "is_island",
|
||||
"make-island": "make_island",
|
||||
"make-signal": "make_signal",
|
||||
"signal?": "is_signal",
|
||||
"signal-value": "signal_value",
|
||||
"signal-set-value!": "signal_set_value",
|
||||
"signal-subscribers": "signal_subscribers",
|
||||
"signal-add-sub!": "signal_add_sub",
|
||||
"signal-remove-sub!": "signal_remove_sub",
|
||||
"signal-deps": "signal_deps",
|
||||
"signal-set-deps!": "signal_set_deps",
|
||||
"set-tracking-context!": "set_tracking_context",
|
||||
"get-tracking-context": "get_tracking_context",
|
||||
"make-tracking-context": "make_tracking_context",
|
||||
"tracking-context-deps": "tracking_context_deps",
|
||||
"tracking-context-add-dep!": "tracking_context_add_dep",
|
||||
"tracking-context-notify-fn": "tracking_context_notify_fn",
|
||||
"identical?": "is_identical",
|
||||
"notify-subscribers": "notify_subscribers",
|
||||
"flush-subscribers": "flush_subscribers",
|
||||
"dispose-computed": "dispose_computed",
|
||||
"with-island-scope": "with_island_scope",
|
||||
"register-in-scope": "register_in_scope",
|
||||
"*batch-depth*": "_batch_depth",
|
||||
"*batch-queue*": "_batch_queue",
|
||||
"*island-scope*": "_island_scope",
|
||||
"*store-registry*": "_store_registry",
|
||||
"def-store": "def_store",
|
||||
"use-store": "use_store",
|
||||
"clear-stores": "clear_stores",
|
||||
"emit-event": "emit_event",
|
||||
"on-event": "on_event",
|
||||
"bridge-event": "bridge_event",
|
||||
"dom-listen": "dom_listen",
|
||||
"dom-dispatch": "dom_dispatch",
|
||||
"event-detail": "event_detail",
|
||||
"macro?": "is_macro",
|
||||
"primitive?": "is_primitive",
|
||||
"get-primitive": "get_primitive",
|
||||
@@ -232,9 +268,18 @@ class PyEmitter:
|
||||
"dispatch-html-form": "dispatch_html_form",
|
||||
"render-lambda-html": "render_lambda_html",
|
||||
"make-raw-html": "make_raw_html",
|
||||
"render-html-island": "render_html_island",
|
||||
"serialize-island-state": "serialize_island_state",
|
||||
"json-serialize": "json_serialize",
|
||||
"empty-dict?": "is_empty_dict",
|
||||
"sf-defisland": "sf_defisland",
|
||||
# adapter-sx.sx
|
||||
"render-to-sx": "render_to_sx",
|
||||
"aser": "aser",
|
||||
"eval-case-aser": "eval_case_aser",
|
||||
"sx-serialize": "sx_serialize",
|
||||
"sx-serialize-dict": "sx_serialize_dict",
|
||||
"sx-expr-source": "sx_expr_source",
|
||||
# Primitives that need exact aliases
|
||||
"contains?": "contains_p",
|
||||
"starts-with?": "starts_with_p",
|
||||
@@ -379,11 +424,26 @@ class PyEmitter:
|
||||
params = expr[1]
|
||||
body = expr[2:]
|
||||
param_names = []
|
||||
for p in params:
|
||||
rest_name = None
|
||||
i = 0
|
||||
while i < len(params):
|
||||
p = params[i]
|
||||
if isinstance(p, Symbol) and p.name == "&rest":
|
||||
# Next param is the rest parameter
|
||||
if i + 1 < len(params):
|
||||
rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1]))
|
||||
i += 2
|
||||
continue
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
if isinstance(p, Symbol):
|
||||
param_names.append(self._mangle(p.name))
|
||||
else:
|
||||
param_names.append(str(p))
|
||||
i += 1
|
||||
if rest_name:
|
||||
param_names.append(f"*{rest_name}")
|
||||
params_str = ", ".join(param_names)
|
||||
if len(body) == 1:
|
||||
body_py = self.emit(body[0])
|
||||
@@ -867,6 +927,7 @@ SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
"engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"),
|
||||
"signals": ("signals.sx", "signals (reactive signal runtime)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1004,6 +1065,12 @@ def compile_ref_to_py(
|
||||
if sm not in SPEC_MODULES:
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
# html adapter needs deps (component analysis) and signals (island rendering)
|
||||
if "html" in adapter_set:
|
||||
if "deps" in SPEC_MODULES:
|
||||
spec_mod_set.add("deps")
|
||||
if "signals" in SPEC_MODULES:
|
||||
spec_mod_set.add("signals")
|
||||
has_deps = "deps" in spec_mod_set
|
||||
|
||||
# Core files always included, then selected adapters, then spec modules
|
||||
@@ -1092,7 +1159,7 @@ from typing import Any
|
||||
# =========================================================================
|
||||
|
||||
from shared.sx.types import (
|
||||
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro,
|
||||
NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro,
|
||||
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
@@ -1197,6 +1264,10 @@ def type_of(x):
|
||||
return "lambda"
|
||||
if isinstance(x, Component):
|
||||
return "component"
|
||||
if isinstance(x, Island):
|
||||
return "island"
|
||||
if isinstance(x, _Signal):
|
||||
return "signal"
|
||||
if isinstance(x, Macro):
|
||||
return "macro"
|
||||
if isinstance(x, _RawHTML):
|
||||
@@ -1235,6 +1306,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"):
|
||||
body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto")
|
||||
|
||||
|
||||
def make_island(name, params, has_children, body, env):
|
||||
return Island(name=name, params=list(params), has_children=has_children,
|
||||
body=body, closure=dict(env))
|
||||
|
||||
|
||||
def make_macro(params, rest_param, body, env, name=None):
|
||||
return Macro(params=list(params), rest_param=rest_param, body=body,
|
||||
closure=dict(env), name=name)
|
||||
@@ -1369,6 +1445,140 @@ def is_macro(x):
|
||||
return isinstance(x, Macro)
|
||||
|
||||
|
||||
def is_island(x):
|
||||
return isinstance(x, Island)
|
||||
|
||||
|
||||
def is_identical(a, b):
|
||||
return a is b
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Signal platform -- reactive state primitives
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
class _Signal:
|
||||
"""Reactive signal container."""
|
||||
__slots__ = ("value", "subscribers", "deps")
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.subscribers = []
|
||||
self.deps = []
|
||||
|
||||
|
||||
class _TrackingContext:
|
||||
"""Context for discovering signal dependencies."""
|
||||
__slots__ = ("notify_fn", "deps")
|
||||
def __init__(self, notify_fn):
|
||||
self.notify_fn = notify_fn
|
||||
self.deps = []
|
||||
|
||||
|
||||
_tracking_context = None
|
||||
|
||||
|
||||
def make_signal(value):
|
||||
return _Signal(value)
|
||||
|
||||
|
||||
def is_signal(x):
|
||||
return isinstance(x, _Signal)
|
||||
|
||||
|
||||
def signal_value(s):
|
||||
return s.value if isinstance(s, _Signal) else s
|
||||
|
||||
|
||||
def signal_set_value(s, v):
|
||||
if isinstance(s, _Signal):
|
||||
s.value = v
|
||||
|
||||
|
||||
def signal_subscribers(s):
|
||||
return list(s.subscribers) if isinstance(s, _Signal) else []
|
||||
|
||||
|
||||
def signal_add_sub(s, fn):
|
||||
if isinstance(s, _Signal) and fn not in s.subscribers:
|
||||
s.subscribers.append(fn)
|
||||
|
||||
|
||||
def signal_remove_sub(s, fn):
|
||||
if isinstance(s, _Signal) and fn in s.subscribers:
|
||||
s.subscribers.remove(fn)
|
||||
|
||||
|
||||
def signal_deps(s):
|
||||
return list(s.deps) if isinstance(s, _Signal) else []
|
||||
|
||||
|
||||
def signal_set_deps(s, deps):
|
||||
if isinstance(s, _Signal):
|
||||
s.deps = list(deps) if isinstance(deps, list) else []
|
||||
|
||||
|
||||
def set_tracking_context(ctx):
|
||||
global _tracking_context
|
||||
_tracking_context = ctx
|
||||
|
||||
|
||||
def get_tracking_context():
|
||||
global _tracking_context
|
||||
return _tracking_context if _tracking_context is not None else NIL
|
||||
|
||||
|
||||
def make_tracking_context(notify_fn):
|
||||
return _TrackingContext(notify_fn)
|
||||
|
||||
|
||||
def tracking_context_deps(ctx):
|
||||
return ctx.deps if isinstance(ctx, _TrackingContext) else []
|
||||
|
||||
|
||||
def tracking_context_add_dep(ctx, s):
|
||||
if isinstance(ctx, _TrackingContext) and s not in ctx.deps:
|
||||
ctx.deps.append(s)
|
||||
|
||||
|
||||
def tracking_context_notify_fn(ctx):
|
||||
return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL
|
||||
|
||||
|
||||
def invoke(f, *args):
|
||||
"""Call f with args — handles both native callables and SX lambdas.
|
||||
|
||||
In Python, all transpiled lambdas are natively callable, so this is
|
||||
just a direct call. The JS host needs dispatch logic here because
|
||||
SX lambdas from runtime-evaluated code are objects, not functions.
|
||||
"""
|
||||
return f(*args)
|
||||
|
||||
|
||||
def json_serialize(obj):
|
||||
import json
|
||||
try:
|
||||
return json.dumps(obj)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def is_empty_dict(d):
|
||||
if not isinstance(d, dict):
|
||||
return True
|
||||
return len(d) == 0
|
||||
|
||||
|
||||
# DOM event primitives — no-ops on server (browser-only).
|
||||
def dom_listen(el, name, handler):
|
||||
return lambda: None
|
||||
|
||||
def dom_dispatch(el, name, detail=None):
|
||||
return False
|
||||
|
||||
def event_detail(e):
|
||||
return None
|
||||
|
||||
|
||||
def env_has(env, name):
|
||||
return name in env
|
||||
|
||||
@@ -1409,17 +1619,8 @@ def dict_delete(d, k):
|
||||
|
||||
|
||||
def is_render_expr(expr):
|
||||
"""Check if expression is an HTML element, component, or fragment."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return False
|
||||
h = expr[0]
|
||||
if not isinstance(h, Symbol):
|
||||
return False
|
||||
n = h.name
|
||||
return (n == "<>" or n == "raw!" or
|
||||
n.startswith("~") or n.startswith("html:") or
|
||||
n in HTML_TAGS or
|
||||
("-" in n and len(expr) > 1 and isinstance(expr[1], Keyword)))
|
||||
"""Placeholder — overridden by transpiled version from render.sx."""
|
||||
return False
|
||||
|
||||
|
||||
# Render dispatch -- set by adapter
|
||||
@@ -1461,6 +1662,10 @@ def make_raw_html(s):
|
||||
return _RawHTML(s)
|
||||
|
||||
|
||||
def sx_expr_source(x):
|
||||
return x.source if isinstance(x, SxExpr) else str(x)
|
||||
|
||||
|
||||
class EvalError(Exception):
|
||||
pass
|
||||
|
||||
@@ -1500,7 +1705,12 @@ def escape_string(s):
|
||||
|
||||
|
||||
def serialize(val):
|
||||
"""Serialize an SX value to SX source text."""
|
||||
"""Serialize an SX value to SX source text.
|
||||
|
||||
Note: parser.sx defines sx-serialize with a serialize alias, but parser.sx
|
||||
is only included in JS builds (for client-side parsing). Python builds
|
||||
provide this as a platform function.
|
||||
"""
|
||||
t = type_of(val)
|
||||
if t == "sx-expr":
|
||||
return val.source
|
||||
@@ -1534,179 +1744,26 @@ def serialize(val):
|
||||
return "nil"
|
||||
return str(val)
|
||||
|
||||
# Aliases for transpiled code — parser.sx defines sx-serialize/sx-serialize-dict
|
||||
# but parser.sx is JS-only. Provide aliases so transpiled render.sx works.
|
||||
sx_serialize = serialize
|
||||
sx_serialize_dict = lambda d: serialize(d)
|
||||
|
||||
_SPECIAL_FORM_NAMES = frozenset([
|
||||
"if", "when", "cond", "case", "and", "or",
|
||||
"let", "let*", "lambda", "fn",
|
||||
"define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation",
|
||||
"begin", "do", "quote", "quasiquote",
|
||||
"->", "set!",
|
||||
])
|
||||
|
||||
_HO_FORM_NAMES = frozenset([
|
||||
"map", "map-indexed", "filter", "reduce",
|
||||
"some", "every?", "for-each",
|
||||
])
|
||||
_SPECIAL_FORM_NAMES = frozenset() # Placeholder — overridden by transpiled adapter-sx.sx
|
||||
_HO_FORM_NAMES = frozenset()
|
||||
|
||||
def is_special_form(name):
|
||||
return name in _SPECIAL_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
def is_ho_form(name):
|
||||
return name in _HO_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
|
||||
def aser_special(name, expr, env):
|
||||
"""Evaluate a special/HO form in aser mode.
|
||||
|
||||
Control flow forms evaluate conditions normally but render branches
|
||||
through aser (serializing tags/components instead of rendering HTML).
|
||||
Definition forms evaluate for side effects and return nil.
|
||||
"""
|
||||
# Control flow — evaluate conditions, aser branches
|
||||
args = expr[1:]
|
||||
if name == "if":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
return aser(args[1], env)
|
||||
return aser(args[2], env) if _b_len(args) > 2 else NIL
|
||||
if name == "when":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
return NIL
|
||||
if name == "cond":
|
||||
clauses = args
|
||||
if clauses and isinstance(clauses[0], _b_list) and _b_len(clauses[0]) == 2:
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return aser(clause[1], env)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(clause[1], env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(clause[1], env)
|
||||
else:
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name == "case":
|
||||
match_val = trampoline(eval_expr(args[0], env))
|
||||
clauses = args[1:]
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if match_val == trampoline(eval_expr(test, env)):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name in ("let", "let*"):
|
||||
bindings = args[0]
|
||||
local = _b_dict(env)
|
||||
if isinstance(bindings, _b_list):
|
||||
if bindings and isinstance(bindings[0], _b_list):
|
||||
for b in bindings:
|
||||
var = b[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(b[1], local))
|
||||
else:
|
||||
for i in _b_range(0, _b_len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(bindings[i + 1], local))
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, local)
|
||||
return result
|
||||
if name in ("begin", "do"):
|
||||
result = NIL
|
||||
for body in args:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
if name == "and":
|
||||
result = True
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if not sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
if name == "or":
|
||||
result = False
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
# HO forms in aser mode — map/for-each render through aser
|
||||
if name == "map":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(item))
|
||||
else:
|
||||
raise EvalError("map requires callable")
|
||||
return results
|
||||
if name == "map-indexed":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = i
|
||||
local[fn.params[1]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(i, item))
|
||||
else:
|
||||
raise EvalError("map-indexed requires callable")
|
||||
return results
|
||||
if name == "for-each":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
fn(item)
|
||||
return results if results else NIL
|
||||
# Definition forms — evaluate for side effects
|
||||
if name in ("define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation"):
|
||||
trampoline(eval_expr(expr, env))
|
||||
return NIL
|
||||
# Lambda/fn, quote, quasiquote, set!, -> : evaluate normally
|
||||
result = eval_expr(expr, env)
|
||||
return trampoline(result)
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return trampoline(eval_expr(expr, env))
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1997,6 +2054,7 @@ parse_int = PRIMITIVES["parse-int"]
|
||||
upper = PRIMITIVES["upper"]
|
||||
has_key_p = PRIMITIVES["has-key?"]
|
||||
dissoc = PRIMITIVES["dissoc"]
|
||||
index_of = PRIMITIVES["index-of"]
|
||||
'''
|
||||
|
||||
|
||||
@@ -2021,7 +2079,7 @@ PLATFORM_DEPS_PY = (
|
||||
' return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []\n'
|
||||
'\n'
|
||||
'def env_components(env):\n'
|
||||
' """Return list of component/macro names in an environment."""\n'
|
||||
' """Placeholder — overridden by transpiled version from deps.sx."""\n'
|
||||
' return [k for k, v in env.items()\n'
|
||||
' if isinstance(v, (Component, Macro))]\n'
|
||||
'\n'
|
||||
@@ -2091,7 +2149,11 @@ CONTINUATIONS_PY = '''
|
||||
|
||||
_RESET_RESUME = [] # stack of resume values; empty = not resuming
|
||||
|
||||
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
|
||||
# Extend the transpiled form name lists with continuation forms
|
||||
if isinstance(SPECIAL_FORM_NAMES, list):
|
||||
SPECIAL_FORM_NAMES.extend(["reset", "shift"])
|
||||
else:
|
||||
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
|
||||
|
||||
def sf_reset(args, env):
|
||||
"""(reset body) -- establish a continuation delimiter."""
|
||||
|
||||
@@ -124,3 +124,55 @@
|
||||
(define-boundary-types
|
||||
(list "number" "string" "boolean" "nil" "keyword"
|
||||
"list" "dict" "sx-source"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Signal primitives — reactive state for islands
|
||||
;;
|
||||
;; These are pure primitives (no IO) but are separated from primitives.sx
|
||||
;; because they introduce a new type (signal) and depend on signals.sx.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(declare-tier :signals :source "signals.sx")
|
||||
|
||||
(declare-signal-primitive "signal"
|
||||
:params (initial-value)
|
||||
:returns "signal"
|
||||
:doc "Create a reactive signal container with an initial value.")
|
||||
|
||||
(declare-signal-primitive "deref"
|
||||
:params (signal)
|
||||
:returns "any"
|
||||
:doc "Read a signal's current value. In a reactive context (inside an island),
|
||||
subscribes the current DOM binding to the signal. Outside reactive
|
||||
context, just returns the value.")
|
||||
|
||||
(declare-signal-primitive "reset!"
|
||||
:params (signal value)
|
||||
:returns "nil"
|
||||
:doc "Set a signal to a new value. Notifies all subscribers.")
|
||||
|
||||
(declare-signal-primitive "swap!"
|
||||
:params (signal f &rest args)
|
||||
:returns "nil"
|
||||
:doc "Update a signal by applying f to its current value. (swap! s inc)
|
||||
is equivalent to (reset! s (inc (deref s))) but atomic.")
|
||||
|
||||
(declare-signal-primitive "computed"
|
||||
:params (compute-fn)
|
||||
:returns "signal"
|
||||
:doc "Create a derived signal that recomputes when its dependencies change.
|
||||
Dependencies are discovered automatically by tracking deref calls.")
|
||||
|
||||
(declare-signal-primitive "effect"
|
||||
:params (effect-fn)
|
||||
:returns "lambda"
|
||||
:doc "Run a side effect that re-runs when its signal dependencies change.
|
||||
Returns a dispose function. If the effect function returns a function,
|
||||
it is called as cleanup before the next run.")
|
||||
|
||||
(declare-signal-primitive "batch"
|
||||
:params (thunk)
|
||||
:returns "any"
|
||||
:doc "Group multiple signal writes. Subscribers are notified once at the end,
|
||||
after all values have been updated.")
|
||||
|
||||
182
shared/sx/ref/demo-signals.html
Normal file
182
shared/sx/ref/demo-signals.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SX Reactive Islands Demo</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 20px; color: #1a1a2e; background: #f8f8fc; }
|
||||
h1 { margin-bottom: 8px; font-size: 1.5rem; }
|
||||
.subtitle { color: #666; margin-bottom: 32px; font-size: 0.9rem; }
|
||||
.demo { background: white; border: 1px solid #e2e2ea; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
.demo h2 { font-size: 1.1rem; margin-bottom: 12px; color: #2d2d4e; }
|
||||
.demo-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
button { background: #4a3f8a; color: white; border: none; border-radius: 4px; padding: 6px 16px; cursor: pointer; font-size: 0.9rem; }
|
||||
button:hover { background: #5b4fa0; }
|
||||
button:active { background: #3a2f7a; }
|
||||
.value { font-size: 1.4rem; font-weight: 600; min-width: 3ch; text-align: center; }
|
||||
.derived { color: #666; font-size: 0.85rem; }
|
||||
.effect-log { background: #f0f0f8; border-radius: 4px; padding: 8px 12px; font-family: monospace; font-size: 0.8rem; max-height: 120px; overflow-y: auto; white-space: pre-wrap; }
|
||||
.batch-indicator { display: inline-block; background: #e8f5e9; color: #2e7d32; padding: 2px 8px; border-radius: 3px; font-size: 0.8rem; }
|
||||
code { background: #f0f0f8; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; }
|
||||
.note { color: #888; font-size: 0.8rem; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SX Reactive Islands</h1>
|
||||
<p class="subtitle">Signals transpiled from <code>signals.sx</code> spec via <code>bootstrap_js.py</code></p>
|
||||
|
||||
<!-- Demo 1: Basic signal -->
|
||||
<div class="demo" id="demo-counter">
|
||||
<h2>1. Signal: Counter</h2>
|
||||
<div class="demo-row">
|
||||
<button onclick="decr()">-</button>
|
||||
<span class="value" id="count-display">0</span>
|
||||
<button onclick="incr()">+</button>
|
||||
</div>
|
||||
<div class="derived" id="doubled-display"></div>
|
||||
<p class="note"><code>signal</code> + <code>computed</code> + <code>effect</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Demo 2: Batch -->
|
||||
<div class="demo" id="demo-batch">
|
||||
<h2>2. Batch: Two signals, one notification</h2>
|
||||
<div class="demo-row">
|
||||
<span>first: <strong id="first-display">0</strong></span>
|
||||
<span>second: <strong id="second-display">0</strong></span>
|
||||
<span class="batch-indicator" id="render-count"></span>
|
||||
</div>
|
||||
<div class="demo-row">
|
||||
<button onclick="batchBoth()">Batch increment both</button>
|
||||
<button onclick="noBatchBoth()">No-batch increment both</button>
|
||||
</div>
|
||||
<p class="note"><code>batch</code> coalesces writes: 2 updates, 1 re-render</p>
|
||||
</div>
|
||||
|
||||
<!-- Demo 3: Effect with cleanup -->
|
||||
<div class="demo" id="demo-effect">
|
||||
<h2>3. Effect: Auto-tracking + Cleanup</h2>
|
||||
<div class="demo-row">
|
||||
<button onclick="togglePolling()">Toggle polling</button>
|
||||
<span id="poll-status"></span>
|
||||
</div>
|
||||
<div class="effect-log" id="effect-log"></div>
|
||||
<p class="note"><code>effect</code> returns cleanup fn; dispose stops tracking</p>
|
||||
</div>
|
||||
|
||||
<!-- Demo 4: Computed chains -->
|
||||
<div class="demo" id="demo-chain">
|
||||
<h2>4. Computed chain: base → doubled → quadrupled</h2>
|
||||
<div class="demo-row">
|
||||
<button onclick="chainDecr()">-</button>
|
||||
<span>base: <strong id="chain-base">1</strong></span>
|
||||
<button onclick="chainIncr()">+</button>
|
||||
</div>
|
||||
<div class="derived">
|
||||
doubled: <strong id="chain-doubled"></strong>
|
||||
quadrupled: <strong id="chain-quad"></strong>
|
||||
</div>
|
||||
<p class="note">Three-level computed dependency graph, auto-propagation</p>
|
||||
</div>
|
||||
|
||||
<script src="sx-ref.js"></script>
|
||||
<script>
|
||||
// Grab signal primitives from transpiled runtime
|
||||
var S = window.Sx;
|
||||
var signal = S.signal;
|
||||
var deref = S.deref;
|
||||
var reset = S.reset;
|
||||
var swap = S.swap;
|
||||
var computed = S.computed;
|
||||
var effect = S.effect;
|
||||
var batch = S.batch;
|
||||
|
||||
// ---- Demo 1: Counter ----
|
||||
var count = signal(0);
|
||||
var doubled = computed(function() { return deref(count) * 2; });
|
||||
|
||||
effect(function() {
|
||||
document.getElementById("count-display").textContent = deref(count);
|
||||
});
|
||||
effect(function() {
|
||||
document.getElementById("doubled-display").textContent = "doubled: " + deref(doubled);
|
||||
});
|
||||
|
||||
function incr() { swap(count, function(n) { return n + 1; }); }
|
||||
function decr() { swap(count, function(n) { return n - 1; }); }
|
||||
|
||||
// ---- Demo 2: Batch ----
|
||||
var first = signal(0);
|
||||
var second = signal(0);
|
||||
var renders = signal(0);
|
||||
|
||||
effect(function() {
|
||||
document.getElementById("first-display").textContent = deref(first);
|
||||
document.getElementById("second-display").textContent = deref(second);
|
||||
swap(renders, function(n) { return n + 1; });
|
||||
});
|
||||
effect(function() {
|
||||
document.getElementById("render-count").textContent = "renders: " + deref(renders);
|
||||
});
|
||||
|
||||
function batchBoth() {
|
||||
batch(function() {
|
||||
swap(first, function(n) { return n + 1; });
|
||||
swap(second, function(n) { return n + 1; });
|
||||
});
|
||||
}
|
||||
function noBatchBoth() {
|
||||
swap(first, function(n) { return n + 1; });
|
||||
swap(second, function(n) { return n + 1; });
|
||||
}
|
||||
|
||||
// ---- Demo 3: Effect with cleanup ----
|
||||
var polling = signal(false);
|
||||
var pollDispose = null;
|
||||
var logEl = document.getElementById("effect-log");
|
||||
|
||||
function log(msg) {
|
||||
logEl.textContent += msg + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
effect(function() {
|
||||
var active = deref(polling);
|
||||
document.getElementById("poll-status").textContent = active ? "polling..." : "stopped";
|
||||
if (active) {
|
||||
var n = 0;
|
||||
var id = setInterval(function() {
|
||||
n++;
|
||||
log("poll #" + n);
|
||||
}, 500);
|
||||
log("effect: started interval");
|
||||
// Return cleanup function
|
||||
return function() {
|
||||
clearInterval(id);
|
||||
log("cleanup: cleared interval");
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function togglePolling() { swap(polling, function(v) { return !v; }); }
|
||||
|
||||
// ---- Demo 4: Computed chain ----
|
||||
var base = signal(1);
|
||||
var chainDoubled = computed(function() { return deref(base) * 2; });
|
||||
var quadrupled = computed(function() { return deref(chainDoubled) * 2; });
|
||||
|
||||
effect(function() {
|
||||
document.getElementById("chain-base").textContent = deref(base);
|
||||
});
|
||||
effect(function() {
|
||||
document.getElementById("chain-doubled").textContent = deref(chainDoubled);
|
||||
});
|
||||
effect(function() {
|
||||
document.getElementById("chain-quad").textContent = deref(quadrupled);
|
||||
});
|
||||
|
||||
function chainIncr() { swap(base, function(n) { return n + 1; }); }
|
||||
function chainDecr() { swap(base, function(n) { return n - 1; }); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,7 +20,6 @@
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (env-components env) → list of component/macro names in env
|
||||
;; (regex-find-all pat src) → list of capture group 1 matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; ==========================================================================
|
||||
@@ -423,7 +422,20 @@
|
||||
;; (component-set-io-refs! c r)→ cache IO refs on component
|
||||
;; (component-affinity c) → "auto" | "client" | "server"
|
||||
;; (macro-body m) → AST body of macro
|
||||
;; (env-components env) → list of component names in env
|
||||
;; (regex-find-all pat src) → list of capture group matches
|
||||
;; (scan-css-classes src) → list of CSS class strings from source
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; env-components — list component/macro names in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Moved from platform to spec: pure logic using type predicates.
|
||||
|
||||
(define env-components
|
||||
(fn (env)
|
||||
(filter
|
||||
(fn (k)
|
||||
(let ((v (env-get env k)))
|
||||
(or (component? v) (macro? v))))
|
||||
(keys env))))
|
||||
|
||||
@@ -35,12 +35,14 @@
|
||||
;; lambda — closure: {params, body, closure-env, name?}
|
||||
;; macro — AST transformer: {params, rest-param, body, closure-env}
|
||||
;; component — UI component: {name, params, has-children, body, closure-env}
|
||||
;; island — reactive component: like component but with island flag
|
||||
;; thunk — deferred eval for TCO: {expr, env}
|
||||
;;
|
||||
;; Each target must provide:
|
||||
;; (type-of x) → one of the strings above
|
||||
;; (make-lambda ...) → platform Lambda value
|
||||
;; (make-component ..) → platform Component value
|
||||
;; (make-island ...) → platform Island value (component + island flag)
|
||||
;; (make-macro ...) → platform Macro value
|
||||
;; (make-thunk ...) → platform Thunk value
|
||||
;;
|
||||
@@ -141,6 +143,7 @@
|
||||
(= name "fn") (sf-lambda args env)
|
||||
(= name "define") (sf-define args env)
|
||||
(= name "defcomp") (sf-defcomp args env)
|
||||
(= name "defisland") (sf-defisland args env)
|
||||
(= name "defmacro") (sf-defmacro args env)
|
||||
(= name "defstyle") (sf-defstyle args env)
|
||||
(= name "defhandler") (sf-defhandler args env)
|
||||
@@ -192,7 +195,7 @@
|
||||
(evaluated-args (map (fn (a) (trampoline (eval-expr a env))) args)))
|
||||
(cond
|
||||
;; Native callable (primitive function)
|
||||
(and (callable? f) (not (lambda? f)) (not (component? f)))
|
||||
(and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f)))
|
||||
(apply f evaluated-args)
|
||||
|
||||
;; Lambda
|
||||
@@ -203,6 +206,10 @@
|
||||
(component? f)
|
||||
(call-component f args env)
|
||||
|
||||
;; Island (reactive component) — same calling convention
|
||||
(island? f)
|
||||
(call-component f args env)
|
||||
|
||||
:else (error (str "Not callable: " (inspect f)))))))
|
||||
|
||||
|
||||
@@ -469,7 +476,10 @@
|
||||
(define sf-lambda
|
||||
(fn (args env)
|
||||
(let ((params-expr (first args))
|
||||
(body (nth args 1))
|
||||
(body-exprs (rest args))
|
||||
(body (if (= (len body-exprs) 1)
|
||||
(first body-exprs)
|
||||
(cons (make-symbol "begin") body-exprs)))
|
||||
(param-names (map (fn (p)
|
||||
(if (= (type-of p) "symbol")
|
||||
(symbol-name p)
|
||||
@@ -543,6 +553,24 @@
|
||||
(list params has-children))))
|
||||
|
||||
|
||||
(define sf-defisland
|
||||
(fn (args env)
|
||||
;; (defisland ~name (params) body)
|
||||
;; Like defcomp but creates an island (reactive component).
|
||||
;; Islands have the same calling convention as components but
|
||||
;; render with a reactive context on the client.
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(body (last args))
|
||||
(comp-name (strip-prefix (symbol-name name-sym) "~"))
|
||||
(parsed (parse-comp-params params-raw))
|
||||
(params (first parsed))
|
||||
(has-children (nth parsed 1)))
|
||||
(let ((island (make-island comp-name params has-children body env)))
|
||||
(env-set! env (symbol-name name-sym) island)
|
||||
island))))
|
||||
|
||||
|
||||
(define sf-defmacro
|
||||
(fn (args env)
|
||||
(let ((name-sym (first args))
|
||||
@@ -903,6 +931,11 @@
|
||||
;; (component-closure c) → env
|
||||
;; (component-has-children? c) → boolean
|
||||
;; (component-affinity c) → "auto" | "client" | "server"
|
||||
;;
|
||||
;; (make-island name params has-children body env) → Island
|
||||
;; (island? x) → boolean
|
||||
;; ;; Islands reuse component accessors: component-params, component-body, etc.
|
||||
;;
|
||||
;; (macro-params m) → list of strings
|
||||
;; (macro-rest-param m) → string or nil
|
||||
;; (macro-body m) → expr
|
||||
@@ -915,6 +948,7 @@
|
||||
;; (callable? x) → boolean (native function or lambda)
|
||||
;; (lambda? x) → boolean
|
||||
;; (component? x) → boolean
|
||||
;; (island? x) → boolean
|
||||
;; (macro? x) → boolean
|
||||
;; (primitive? name) → boolean (is name a registered primitive?)
|
||||
;; (get-primitive name) → function
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
;; Process OOB swaps
|
||||
(process-oob-swaps container
|
||||
(fn (t oob s)
|
||||
(dispose-islands-in t)
|
||||
(swap-dom-nodes t oob s)
|
||||
(sx-hydrate t)
|
||||
(process-elements t)))
|
||||
@@ -269,6 +270,8 @@
|
||||
(content (if select-sel
|
||||
(select-from-container container select-sel)
|
||||
(children-to-fragment container))))
|
||||
;; Dispose old islands before swap
|
||||
(dispose-islands-in target)
|
||||
;; Swap
|
||||
(with-transition use-transition
|
||||
(fn ()
|
||||
@@ -282,6 +285,8 @@
|
||||
(let ((doc (dom-parse-html-document text)))
|
||||
(when doc
|
||||
(let ((select-sel (dom-get-attr el "sx-select")))
|
||||
;; Dispose old islands before swap
|
||||
(dispose-islands-in target)
|
||||
(if select-sel
|
||||
;; Select from parsed document
|
||||
(let ((html (select-html-from-doc doc select-sel)))
|
||||
@@ -295,6 +300,7 @@
|
||||
;; Process OOB swaps
|
||||
(process-oob-swaps container
|
||||
(fn (t oob s)
|
||||
(dispose-islands-in t)
|
||||
(swap-dom-nodes t oob s)
|
||||
(post-swap t)))
|
||||
;; Hoist head elements
|
||||
@@ -432,6 +438,7 @@
|
||||
(activate-scripts root)
|
||||
(sx-process-scripts root)
|
||||
(sx-hydrate root)
|
||||
(sx-hydrate-islands root)
|
||||
(process-elements root)))
|
||||
|
||||
|
||||
@@ -663,6 +670,139 @@
|
||||
(update-page-cache hdr-update data)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Optimistic data updates (Phase 7c)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side predicted mutations with rollback.
|
||||
;; submit-mutation applies a predicted update immediately, sends the mutation
|
||||
;; to the server, and either confirms or reverts based on the response.
|
||||
|
||||
(define _optimistic-snapshots (dict))
|
||||
|
||||
(define optimistic-cache-update
|
||||
(fn (cache-key mutator)
|
||||
;; Apply predicted mutation to cached data. Saves snapshot for rollback.
|
||||
;; Returns predicted data or nil if no cached data exists.
|
||||
(let ((cached (page-data-cache-get cache-key)))
|
||||
(when cached
|
||||
(let ((predicted (mutator cached)))
|
||||
;; Save original for revert
|
||||
(dict-set! _optimistic-snapshots cache-key cached)
|
||||
;; Update cache with prediction
|
||||
(page-data-cache-set cache-key predicted)
|
||||
predicted)))))
|
||||
|
||||
(define optimistic-cache-revert
|
||||
(fn (cache-key)
|
||||
;; Revert to pre-mutation snapshot. Returns restored data or nil.
|
||||
(let ((snapshot (get _optimistic-snapshots cache-key)))
|
||||
(when snapshot
|
||||
(page-data-cache-set cache-key snapshot)
|
||||
(dict-delete! _optimistic-snapshots cache-key)
|
||||
snapshot))))
|
||||
|
||||
(define optimistic-cache-confirm
|
||||
(fn (cache-key)
|
||||
;; Server accepted — discard the rollback snapshot.
|
||||
(dict-delete! _optimistic-snapshots cache-key)))
|
||||
|
||||
(define submit-mutation
|
||||
(fn (page-name params action-name payload mutator-fn on-complete)
|
||||
;; Optimistic mutation: predict locally, send to server, confirm or revert.
|
||||
;; on-complete is called with "confirmed" or "reverted" status.
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(predicted (optimistic-cache-update cache-key mutator-fn)))
|
||||
;; Re-render with predicted data immediately
|
||||
(when predicted
|
||||
(try-rerender-page page-name params predicted))
|
||||
;; Send to server
|
||||
(execute-action action-name payload
|
||||
(fn (result)
|
||||
;; Success: update cache with server truth, confirm
|
||||
(when result
|
||||
(page-data-cache-set cache-key result))
|
||||
(optimistic-cache-confirm cache-key)
|
||||
(when result
|
||||
(try-rerender-page page-name params result))
|
||||
(log-info (str "sx:optimistic confirmed " page-name))
|
||||
(when on-complete (on-complete "confirmed")))
|
||||
(fn (error)
|
||||
;; Failure: revert to snapshot
|
||||
(let ((reverted (optimistic-cache-revert cache-key)))
|
||||
(when reverted
|
||||
(try-rerender-page page-name params reverted))
|
||||
(log-warn (str "sx:optimistic reverted " page-name ": " error))
|
||||
(when on-complete (on-complete "reverted"))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Offline data layer (Phase 7d)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Connectivity tracking + offline mutation queue.
|
||||
;; When offline, mutations are queued locally. On reconnect, queued mutations
|
||||
;; are replayed in order via submit-mutation.
|
||||
|
||||
(define _is-online true)
|
||||
(define _offline-queue (list))
|
||||
|
||||
(define offline-is-online?
|
||||
(fn () _is-online))
|
||||
|
||||
(define offline-set-online!
|
||||
(fn (val)
|
||||
(set! _is-online val)))
|
||||
|
||||
(define offline-queue-mutation
|
||||
(fn (action-name payload page-name params mutator-fn)
|
||||
;; Queue a mutation for later sync. Apply optimistic update locally.
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(entry (dict
|
||||
"action" action-name
|
||||
"payload" payload
|
||||
"page" page-name
|
||||
"params" params
|
||||
"timestamp" (now-ms)
|
||||
"status" "pending")))
|
||||
(append! _offline-queue entry)
|
||||
;; Apply optimistic locally (reuses Phase 7c)
|
||||
(let ((predicted (optimistic-cache-update cache-key mutator-fn)))
|
||||
(when predicted
|
||||
(try-rerender-page page-name params predicted)))
|
||||
(log-info (str "sx:offline queued " action-name " (" (len _offline-queue) " pending)"))
|
||||
entry)))
|
||||
|
||||
(define offline-sync
|
||||
(fn ()
|
||||
;; Replay all pending mutations. Called on reconnect.
|
||||
(let ((pending (filter (fn (e) (= (get e "status") "pending")) _offline-queue)))
|
||||
(when (not (empty? pending))
|
||||
(log-info (str "sx:offline syncing " (len pending) " mutations"))
|
||||
(for-each
|
||||
(fn (entry)
|
||||
(execute-action (get entry "action") (get entry "payload")
|
||||
(fn (result)
|
||||
(dict-set! entry "status" "synced")
|
||||
(log-info (str "sx:offline synced " (get entry "action"))))
|
||||
(fn (error)
|
||||
(dict-set! entry "status" "failed")
|
||||
(log-warn (str "sx:offline sync failed " (get entry "action") ": " error)))))
|
||||
pending)))))
|
||||
|
||||
(define offline-pending-count
|
||||
(fn ()
|
||||
(len (filter (fn (e) (= (get e "status") "pending")) _offline-queue))))
|
||||
|
||||
(define offline-aware-mutation
|
||||
(fn (page-name params action-name payload mutator-fn on-complete)
|
||||
;; Top-level mutation function. Routes to submit-mutation when online,
|
||||
;; offline-queue-mutation when offline.
|
||||
(if _is-online
|
||||
(submit-mutation page-name params action-name payload mutator-fn on-complete)
|
||||
(do
|
||||
(offline-queue-mutation action-name payload page-name params mutator-fn)
|
||||
(when on-complete (on-complete "queued"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -682,6 +822,7 @@
|
||||
;; Swap rendered DOM content into target and run post-processing.
|
||||
;; Shared by pure and data page client routes.
|
||||
(do
|
||||
(dispose-islands-in target)
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
@@ -869,6 +1010,7 @@
|
||||
(use-transition (get swap-spec "transition"))
|
||||
(trimmed (trim data)))
|
||||
(when (not (empty? trimmed))
|
||||
(dispose-islands-in target)
|
||||
(if (starts-with? trimmed "(")
|
||||
;; SX response
|
||||
(let ((rendered (sx-render trimmed))
|
||||
@@ -954,10 +1096,11 @@
|
||||
(mark-processed! el "verb")
|
||||
(process-one el)))
|
||||
els))
|
||||
;; Also process boost, SSE, inline handlers
|
||||
;; Also process boost, SSE, inline handlers, emit attributes
|
||||
(process-boosted root)
|
||||
(process-sse root)
|
||||
(bind-inline-handlers root)))
|
||||
(bind-inline-handlers root)
|
||||
(process-emit-elements root)))
|
||||
|
||||
|
||||
(define process-one
|
||||
@@ -971,6 +1114,40 @@
|
||||
(bind-preload-for el))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; data-sx-emit — auto-dispatch custom events for lake→island bridge
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Elements with data-sx-emit="event-name" get a click listener that
|
||||
;; dispatches a CustomEvent with that name. Optional data-sx-emit-detail
|
||||
;; provides JSON payload.
|
||||
;;
|
||||
;; Example:
|
||||
;; <button data-sx-emit="cart:add"
|
||||
;; data-sx-emit-detail='{"id":42,"name":"Widget"}'>
|
||||
;; Add to Cart
|
||||
;; </button>
|
||||
;;
|
||||
;; On click → dispatches CustomEvent "cart:add" with detail {id:42, name:"Widget"}
|
||||
;; The event bubbles up to the island container where bridge-event catches it.
|
||||
|
||||
(define process-emit-elements
|
||||
(fn (root)
|
||||
(let ((els (dom-query-all (or root (dom-body)) "[data-sx-emit]")))
|
||||
(for-each
|
||||
(fn (el)
|
||||
(when (not (is-processed? el "emit"))
|
||||
(mark-processed! el "emit")
|
||||
(let ((event-name (dom-get-attr el "data-sx-emit")))
|
||||
(when event-name
|
||||
(dom-listen el "click"
|
||||
(fn (e)
|
||||
(let ((detail-json (dom-get-attr el "data-sx-emit-detail"))
|
||||
(detail (if detail-json (json-parse detail-json) (dict))))
|
||||
(dom-dispatch el event-name detail))))))))
|
||||
els))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; History: popstate handler
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -1137,6 +1314,10 @@
|
||||
;; when data is available. params is a dict of URL/route parameters.
|
||||
;; (parse-sx-data text) → parsed SX data value, or nil on error.
|
||||
;; Used by cache update to parse server-provided data in SX format.
|
||||
;; (execute-action name payload on-success on-error) → void; POST to server,
|
||||
;; calls (on-success data-dict) or (on-error message).
|
||||
;; (try-rerender-page page-name params data) → void; re-evaluate and swap
|
||||
;; the current page content with updated data bindings.
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
@@ -1160,4 +1341,8 @@
|
||||
;; === Cache management ===
|
||||
;; (parse-sx-data text) → parsed SX data value, or nil on error
|
||||
;; (sw-post-message msg) → void; post message to active service worker
|
||||
;;
|
||||
;; === Offline persistence ===
|
||||
;; (persist-offline-data key data) → void; write to IndexedDB
|
||||
;; (retrieve-offline-data key cb) → void; read from IndexedDB, calls (cb data)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -305,6 +305,10 @@
|
||||
"}")))
|
||||
|
||||
|
||||
;; Alias: adapters use (serialize val) — canonicalize to sx-serialize
|
||||
(define serialize sx-serialize)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform parser interface
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
|
||||
(define definition-form?
|
||||
(fn (name)
|
||||
(or (= name "define") (= name "defcomp") (= name "defmacro")
|
||||
(= name "defstyle") (= name "defhandler"))))
|
||||
(or (= name "define") (= name "defcomp") (= name "defisland")
|
||||
(= name "defmacro") (= name "defstyle") (= name "defhandler"))))
|
||||
|
||||
|
||||
(define parse-element-args
|
||||
@@ -190,6 +190,30 @@
|
||||
local)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; is-render-expr? — check if expression is a rendering form
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Used by eval-list to dispatch rendering forms to the active adapter
|
||||
;; (HTML, SX wire, or DOM) rather than evaluating them as function calls.
|
||||
|
||||
(define is-render-expr?
|
||||
(fn (expr)
|
||||
(if (or (not (= (type-of expr) "list")) (empty? expr))
|
||||
false
|
||||
(let ((h (first expr)))
|
||||
(if (not (= (type-of h) "symbol"))
|
||||
false
|
||||
(let ((n (symbol-name h)))
|
||||
(or (= n "<>")
|
||||
(= n "raw!")
|
||||
(starts-with? n "~")
|
||||
(starts-with? n "html:")
|
||||
(contains? HTML_TAGS n)
|
||||
(and (> (index-of n "-") 0)
|
||||
(> (len expr) 1)
|
||||
(= (type-of (nth expr 1)) "keyword")))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface (shared across adapters)
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -199,11 +223,6 @@
|
||||
;; (escape-attr s) → attribute-value-escaped string
|
||||
;; (raw-html-content r) → unwrap RawHTML marker to string
|
||||
;;
|
||||
;; Serialization:
|
||||
;; (serialize val) → SX source string representation of val
|
||||
;;
|
||||
;; Form classification (used by SX wire adapter):
|
||||
;; (special-form? name) → boolean
|
||||
;; (ho-form? name) → boolean
|
||||
;; (aser-special name expr env) → evaluate special/HO form through aser
|
||||
;; From parser.sx:
|
||||
;; (sx-serialize val) → SX source string (aliased as serialize above)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
421
shared/sx/ref/signals.sx
Normal file
421
shared/sx/ref/signals.sx
Normal file
@@ -0,0 +1,421 @@
|
||||
;; ==========================================================================
|
||||
;; signals.sx — Reactive signal runtime specification
|
||||
;;
|
||||
;; Defines the signal primitive: a container for a value that notifies
|
||||
;; subscribers when it changes. Signals are the reactive state primitive
|
||||
;; for SX islands.
|
||||
;;
|
||||
;; Signals are pure computation — no DOM, no IO. The reactive rendering
|
||||
;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server
|
||||
;; adapter (adapter-html.sx) reads signal values without subscribing.
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (make-signal value) → Signal — create signal container
|
||||
;; (signal? x) → boolean — type predicate
|
||||
;; (signal-value s) → any — read current value (no tracking)
|
||||
;; (signal-set-value! s v) → void — write value (no notification)
|
||||
;; (signal-subscribers s) → list — list of subscriber fns
|
||||
;; (signal-add-sub! s fn) → void — add subscriber
|
||||
;; (signal-remove-sub! s fn) → void — remove subscriber
|
||||
;; (signal-deps s) → list — dependency list (for computed)
|
||||
;; (signal-set-deps! s deps) → void — set dependency list
|
||||
;;
|
||||
;; Global state required:
|
||||
;; *tracking-context* → nil | Effect/Computed currently evaluating
|
||||
;; (set-tracking-context! c) → void
|
||||
;; (get-tracking-context) → context or nil
|
||||
;;
|
||||
;; Runtime callable dispatch:
|
||||
;; (invoke f &rest args) → any — call f with args; handles both
|
||||
;; native host functions AND SX lambdas
|
||||
;; from runtime-evaluated code (islands).
|
||||
;; Transpiled code emits direct calls
|
||||
;; f(args) which fail on SX lambdas.
|
||||
;; invoke goes through the evaluator's
|
||||
;; dispatch (call-fn) so either works.
|
||||
;;
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. signal — create a reactive container
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define signal
|
||||
(fn (initial-value)
|
||||
(make-signal initial-value)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. deref — read signal value, subscribe current reactive context
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; In a reactive context (inside effect or computed), deref registers the
|
||||
;; signal as a dependency. Outside reactive context, deref just returns
|
||||
;; the current value — no subscription, no overhead.
|
||||
|
||||
(define deref
|
||||
(fn (s)
|
||||
(if (not (signal? s))
|
||||
s ;; non-signal values pass through
|
||||
(let ((ctx (get-tracking-context)))
|
||||
(when ctx
|
||||
;; Register this signal as a dependency of the current context
|
||||
(tracking-context-add-dep! ctx s)
|
||||
;; Subscribe the context to this signal
|
||||
(signal-add-sub! s (tracking-context-notify-fn ctx)))
|
||||
(signal-value s)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. reset! — write a new value, notify subscribers
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define reset!
|
||||
(fn (s value)
|
||||
(when (signal? s)
|
||||
(let ((old (signal-value s)))
|
||||
(when (not (identical? old value))
|
||||
(signal-set-value! s value)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. swap! — update signal via function
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define swap!
|
||||
(fn (s f &rest args)
|
||||
(when (signal? s)
|
||||
(let ((old (signal-value s))
|
||||
(new-val (apply f (cons old args))))
|
||||
(when (not (identical? old new-val))
|
||||
(signal-set-value! s new-val)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. computed — derived signal with automatic dependency tracking
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A computed signal wraps a zero-arg function. It re-evaluates when any
|
||||
;; of its dependencies change. The dependency set is discovered automatically
|
||||
;; by tracking deref calls during evaluation.
|
||||
|
||||
(define computed
|
||||
(fn (compute-fn)
|
||||
(let ((s (make-signal nil))
|
||||
(deps (list))
|
||||
(compute-ctx nil))
|
||||
|
||||
;; The notify function — called when a dependency changes
|
||||
(let ((recompute
|
||||
(fn ()
|
||||
;; Unsubscribe from old deps
|
||||
(for-each
|
||||
(fn (dep) (signal-remove-sub! dep recompute))
|
||||
(signal-deps s))
|
||||
(signal-set-deps! s (list))
|
||||
|
||||
;; Create tracking context for this computed
|
||||
(let ((ctx (make-tracking-context recompute)))
|
||||
(let ((prev (get-tracking-context)))
|
||||
(set-tracking-context! ctx)
|
||||
(let ((new-val (invoke compute-fn)))
|
||||
(set-tracking-context! prev)
|
||||
;; Save discovered deps
|
||||
(signal-set-deps! s (tracking-context-deps ctx))
|
||||
;; Update value + notify downstream
|
||||
(let ((old (signal-value s)))
|
||||
(signal-set-value! s new-val)
|
||||
(when (not (identical? old new-val))
|
||||
(notify-subscribers s)))))))))
|
||||
|
||||
;; Initial computation
|
||||
(recompute)
|
||||
;; Auto-register disposal with island scope
|
||||
(register-in-scope (fn () (dispose-computed s)))
|
||||
s))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. effect — side effect that runs when dependencies change
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Like computed, but doesn't produce a signal value. Returns a dispose
|
||||
;; function that tears down the effect.
|
||||
|
||||
(define effect
|
||||
(fn (effect-fn)
|
||||
(let ((deps (list))
|
||||
(disposed false)
|
||||
(cleanup-fn nil))
|
||||
|
||||
(let ((run-effect
|
||||
(fn ()
|
||||
(when (not disposed)
|
||||
;; Run previous cleanup if any
|
||||
(when cleanup-fn (invoke cleanup-fn))
|
||||
|
||||
;; Unsubscribe from old deps
|
||||
(for-each
|
||||
(fn (dep) (signal-remove-sub! dep run-effect))
|
||||
deps)
|
||||
(set! deps (list))
|
||||
|
||||
;; Track new deps
|
||||
(let ((ctx (make-tracking-context run-effect)))
|
||||
(let ((prev (get-tracking-context)))
|
||||
(set-tracking-context! ctx)
|
||||
(let ((result (invoke effect-fn)))
|
||||
(set-tracking-context! prev)
|
||||
(set! deps (tracking-context-deps ctx))
|
||||
;; If effect returns a function, it's the cleanup
|
||||
(when (callable? result)
|
||||
(set! cleanup-fn result)))))))))
|
||||
|
||||
;; Initial run
|
||||
(run-effect)
|
||||
|
||||
;; Return dispose function
|
||||
(let ((dispose-fn
|
||||
(fn ()
|
||||
(set! disposed true)
|
||||
(when cleanup-fn (invoke cleanup-fn))
|
||||
(for-each
|
||||
(fn (dep) (signal-remove-sub! dep run-effect))
|
||||
deps)
|
||||
(set! deps (list)))))
|
||||
;; Auto-register with island scope so disposal happens on swap
|
||||
(register-in-scope dispose-fn)
|
||||
dispose-fn)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. batch — group multiple signal writes into one notification pass
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; During a batch, signal writes are deferred. Subscribers are notified
|
||||
;; once at the end, after all values have been updated.
|
||||
|
||||
(define *batch-depth* 0)
|
||||
(define *batch-queue* (list))
|
||||
|
||||
(define batch
|
||||
(fn (thunk)
|
||||
(set! *batch-depth* (+ *batch-depth* 1))
|
||||
(invoke thunk)
|
||||
(set! *batch-depth* (- *batch-depth* 1))
|
||||
(when (= *batch-depth* 0)
|
||||
(let ((queue *batch-queue*))
|
||||
(set! *batch-queue* (list))
|
||||
;; Collect unique subscribers across all queued signals,
|
||||
;; then notify each exactly once.
|
||||
(let ((seen (list))
|
||||
(pending (list)))
|
||||
(for-each
|
||||
(fn (s)
|
||||
(for-each
|
||||
(fn (sub)
|
||||
(when (not (contains? seen sub))
|
||||
(append! seen sub)
|
||||
(append! pending sub)))
|
||||
(signal-subscribers s)))
|
||||
queue)
|
||||
(for-each (fn (sub) (sub)) pending))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. notify-subscribers — internal notification dispatch
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; If inside a batch, queues the signal. Otherwise, notifies immediately.
|
||||
|
||||
(define notify-subscribers
|
||||
(fn (s)
|
||||
(if (> *batch-depth* 0)
|
||||
(when (not (contains? *batch-queue* s))
|
||||
(append! *batch-queue* s))
|
||||
(flush-subscribers s))))
|
||||
|
||||
(define flush-subscribers
|
||||
(fn (s)
|
||||
(for-each
|
||||
(fn (sub) (sub))
|
||||
(signal-subscribers s))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Tracking context
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; A tracking context is an ephemeral object created during effect/computed
|
||||
;; evaluation to discover signal dependencies. Platform must provide:
|
||||
;;
|
||||
;; (make-tracking-context notify-fn) → context
|
||||
;; (tracking-context-deps ctx) → list of signals
|
||||
;; (tracking-context-add-dep! ctx s) → void (adds s to ctx's dep list)
|
||||
;; (tracking-context-notify-fn ctx) → the notify function
|
||||
;;
|
||||
;; These are platform primitives because the context is mutable state
|
||||
;; that must be efficient (often a Set in the host language).
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. dispose — tear down a computed signal
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; For computed signals, unsubscribe from all dependencies.
|
||||
;; For effects, the dispose function is returned by effect itself.
|
||||
|
||||
(define dispose-computed
|
||||
(fn (s)
|
||||
(when (signal? s)
|
||||
(for-each
|
||||
(fn (dep) (signal-remove-sub! dep nil))
|
||||
(signal-deps s))
|
||||
(signal-set-deps! s (list)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 11. Island scope — automatic cleanup of signals within an island
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; When an island is created, all signals, effects, and computeds created
|
||||
;; within it are tracked. When the island is removed from the DOM, they
|
||||
;; are all disposed.
|
||||
|
||||
(define *island-scope* nil)
|
||||
|
||||
(define with-island-scope
|
||||
(fn (scope-fn body-fn)
|
||||
(let ((prev *island-scope*))
|
||||
(set! *island-scope* scope-fn)
|
||||
(let ((result (body-fn)))
|
||||
(set! *island-scope* prev)
|
||||
result))))
|
||||
|
||||
;; Hook into signal/effect/computed creation for scope tracking.
|
||||
;; The platform's make-signal should call (register-in-scope s) if
|
||||
;; *island-scope* is non-nil.
|
||||
|
||||
(define register-in-scope
|
||||
(fn (disposable)
|
||||
(when *island-scope*
|
||||
(*island-scope* disposable))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 12. Named stores — page-level signal containers (L3)
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; Stores persist across island creation/destruction. They live at page
|
||||
;; scope, not island scope. When an island is swapped out and re-created,
|
||||
;; it reconnects to the same store instance.
|
||||
;;
|
||||
;; The store registry is global page-level state. It survives island
|
||||
;; disposal but is cleared on full page navigation.
|
||||
|
||||
(define *store-registry* (dict))
|
||||
|
||||
(define def-store
|
||||
(fn (name init-fn)
|
||||
(let ((registry *store-registry*))
|
||||
;; Only create the store once — subsequent calls return existing
|
||||
(when (not (has-key? registry name))
|
||||
(set! *store-registry* (assoc registry name (invoke init-fn))))
|
||||
(get *store-registry* name))))
|
||||
|
||||
(define use-store
|
||||
(fn (name)
|
||||
(if (has-key? *store-registry* name)
|
||||
(get *store-registry* name)
|
||||
(error (str "Store not found: " name
|
||||
". Call (def-store ...) before (use-store ...).")))))
|
||||
|
||||
(define clear-stores
|
||||
(fn ()
|
||||
(set! *store-registry* (dict))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 13. Event bridge — DOM event communication for lake→island
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; Server-rendered content ("htmx lakes") inside reactive islands can
|
||||
;; communicate with island signals via DOM custom events. The bridge
|
||||
;; pattern:
|
||||
;;
|
||||
;; 1. Server renders a button/link with data-sx-emit="event-name"
|
||||
;; 2. When clicked, the client dispatches a CustomEvent on the element
|
||||
;; 3. The event bubbles up to the island container
|
||||
;; 4. An island effect listens for the event and updates signals
|
||||
;;
|
||||
;; This keeps server content pure HTML — no signal references needed.
|
||||
;; The island effect is the only reactive code.
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (dom-listen el event-name handler) → remove-fn
|
||||
;; (dom-dispatch el event-name detail) → void
|
||||
;; (event-detail e) → any
|
||||
;;
|
||||
;; These are platform primitives because they require browser DOM APIs.
|
||||
|
||||
(define emit-event
|
||||
(fn (el event-name detail)
|
||||
(dom-dispatch el event-name detail)))
|
||||
|
||||
(define on-event
|
||||
(fn (el event-name handler)
|
||||
(dom-listen el event-name handler)))
|
||||
|
||||
;; Convenience: create an effect that listens for a DOM event on an
|
||||
;; element and writes the event detail (or a transformed value) into
|
||||
;; a target signal. Returns the effect's dispose function.
|
||||
;; When the effect is disposed (island teardown), the listener is
|
||||
;; removed automatically via the cleanup return.
|
||||
|
||||
(define bridge-event
|
||||
(fn (el event-name target-signal transform-fn)
|
||||
(effect (fn ()
|
||||
(let ((remove (dom-listen el event-name
|
||||
(fn (e)
|
||||
(let ((detail (event-detail e))
|
||||
(new-val (if transform-fn
|
||||
(invoke transform-fn detail)
|
||||
detail)))
|
||||
(reset! target-signal new-val))))))
|
||||
;; Return cleanup — removes listener on dispose/re-run
|
||||
remove)))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; 14. Resource — async signal with loading/resolved/error states
|
||||
;; ==========================================================================
|
||||
;;
|
||||
;; A resource wraps an async operation (fetch, computation) and exposes
|
||||
;; its state as a signal. The signal transitions through:
|
||||
;; {:loading true :data nil :error nil} — initial/loading
|
||||
;; {:loading false :data result :error nil} — success
|
||||
;; {:loading false :data nil :error err} — failure
|
||||
;;
|
||||
;; Usage:
|
||||
;; (let ((user (resource (fn () (fetch-json "/api/user")))))
|
||||
;; (cond
|
||||
;; (get (deref user) "loading") (div "Loading...")
|
||||
;; (get (deref user) "error") (div "Error: " (get (deref user) "error"))
|
||||
;; :else (div (get (deref user) "data"))))
|
||||
;;
|
||||
;; Platform interface required:
|
||||
;; (promise-then promise on-resolve on-reject) → void
|
||||
|
||||
(define resource
|
||||
(fn (fetch-fn)
|
||||
(let ((state (signal (dict "loading" true "data" nil "error" nil))))
|
||||
;; Kick off the async operation
|
||||
(promise-then (invoke fetch-fn)
|
||||
(fn (data) (reset! state (dict "loading" false "data" data "error" nil)))
|
||||
(fn (err) (reset! state (dict "loading" false "data" nil "error" err))))
|
||||
state)))
|
||||
|
||||
|
||||
@@ -182,6 +182,23 @@
|
||||
(when subtitle (p subtitle))
|
||||
children))")
|
||||
|
||||
(define-special-form "defisland"
|
||||
:syntax (defisland ~name (&key param1 param2 &rest children) body)
|
||||
:doc "Define a reactive island. Islands have the same calling convention
|
||||
as components (defcomp) but create a reactive boundary. Inside an
|
||||
island, signals are tracked — deref subscribes DOM nodes to signals,
|
||||
and signal changes update only the affected nodes.
|
||||
|
||||
On the server, islands render as static HTML wrapped in a
|
||||
data-sx-island container with serialized initial state. On the
|
||||
client, islands hydrate into reactive contexts."
|
||||
:tail-position "body"
|
||||
:example "(defisland ~counter (&key initial)
|
||||
(let ((count (signal (or initial 0))))
|
||||
(div :class \"counter\"
|
||||
(span (deref count))
|
||||
(button :on-click (fn (e) (swap! count inc)) \"+\"))))")
|
||||
|
||||
(define-special-form "defmacro"
|
||||
:syntax (defmacro name (params ...) body)
|
||||
:doc "Define a macro. Macros receive their arguments unevaluated (as raw
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
|
||||
# WARNING: eval.sx dispatches forms not in special-forms.sx: form?
|
||||
"""
|
||||
sx_ref.py -- Generated from reference SX evaluator specification.
|
||||
|
||||
@@ -18,7 +19,7 @@ from typing import Any
|
||||
# =========================================================================
|
||||
|
||||
from shared.sx.types import (
|
||||
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro,
|
||||
NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro,
|
||||
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
@@ -122,6 +123,10 @@ def type_of(x):
|
||||
return "lambda"
|
||||
if isinstance(x, Component):
|
||||
return "component"
|
||||
if isinstance(x, Island):
|
||||
return "island"
|
||||
if isinstance(x, _Signal):
|
||||
return "signal"
|
||||
if isinstance(x, Macro):
|
||||
return "macro"
|
||||
if isinstance(x, _RawHTML):
|
||||
@@ -160,6 +165,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"):
|
||||
body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto")
|
||||
|
||||
|
||||
def make_island(name, params, has_children, body, env):
|
||||
return Island(name=name, params=list(params), has_children=has_children,
|
||||
body=body, closure=dict(env))
|
||||
|
||||
|
||||
def make_macro(params, rest_param, body, env, name=None):
|
||||
return Macro(params=list(params), rest_param=rest_param, body=body,
|
||||
closure=dict(env), name=name)
|
||||
@@ -294,6 +304,140 @@ def is_macro(x):
|
||||
return isinstance(x, Macro)
|
||||
|
||||
|
||||
def is_island(x):
|
||||
return isinstance(x, Island)
|
||||
|
||||
|
||||
def is_identical(a, b):
|
||||
return a is b
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Signal platform -- reactive state primitives
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
class _Signal:
|
||||
"""Reactive signal container."""
|
||||
__slots__ = ("value", "subscribers", "deps")
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.subscribers = []
|
||||
self.deps = []
|
||||
|
||||
|
||||
class _TrackingContext:
|
||||
"""Context for discovering signal dependencies."""
|
||||
__slots__ = ("notify_fn", "deps")
|
||||
def __init__(self, notify_fn):
|
||||
self.notify_fn = notify_fn
|
||||
self.deps = []
|
||||
|
||||
|
||||
_tracking_context = None
|
||||
|
||||
|
||||
def make_signal(value):
|
||||
return _Signal(value)
|
||||
|
||||
|
||||
def is_signal(x):
|
||||
return isinstance(x, _Signal)
|
||||
|
||||
|
||||
def signal_value(s):
|
||||
return s.value if isinstance(s, _Signal) else s
|
||||
|
||||
|
||||
def signal_set_value(s, v):
|
||||
if isinstance(s, _Signal):
|
||||
s.value = v
|
||||
|
||||
|
||||
def signal_subscribers(s):
|
||||
return list(s.subscribers) if isinstance(s, _Signal) else []
|
||||
|
||||
|
||||
def signal_add_sub(s, fn):
|
||||
if isinstance(s, _Signal) and fn not in s.subscribers:
|
||||
s.subscribers.append(fn)
|
||||
|
||||
|
||||
def signal_remove_sub(s, fn):
|
||||
if isinstance(s, _Signal) and fn in s.subscribers:
|
||||
s.subscribers.remove(fn)
|
||||
|
||||
|
||||
def signal_deps(s):
|
||||
return list(s.deps) if isinstance(s, _Signal) else []
|
||||
|
||||
|
||||
def signal_set_deps(s, deps):
|
||||
if isinstance(s, _Signal):
|
||||
s.deps = list(deps) if isinstance(deps, list) else []
|
||||
|
||||
|
||||
def set_tracking_context(ctx):
|
||||
global _tracking_context
|
||||
_tracking_context = ctx
|
||||
|
||||
|
||||
def get_tracking_context():
|
||||
global _tracking_context
|
||||
return _tracking_context if _tracking_context is not None else NIL
|
||||
|
||||
|
||||
def make_tracking_context(notify_fn):
|
||||
return _TrackingContext(notify_fn)
|
||||
|
||||
|
||||
def tracking_context_deps(ctx):
|
||||
return ctx.deps if isinstance(ctx, _TrackingContext) else []
|
||||
|
||||
|
||||
def tracking_context_add_dep(ctx, s):
|
||||
if isinstance(ctx, _TrackingContext) and s not in ctx.deps:
|
||||
ctx.deps.append(s)
|
||||
|
||||
|
||||
def tracking_context_notify_fn(ctx):
|
||||
return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL
|
||||
|
||||
|
||||
def invoke(f, *args):
|
||||
"""Call f with args — handles both native callables and SX lambdas.
|
||||
|
||||
In Python, all transpiled lambdas are natively callable, so this is
|
||||
just a direct call. The JS host needs dispatch logic here because
|
||||
SX lambdas from runtime-evaluated code are objects, not functions.
|
||||
"""
|
||||
return f(*args)
|
||||
|
||||
|
||||
def json_serialize(obj):
|
||||
import json
|
||||
try:
|
||||
return json.dumps(obj)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def is_empty_dict(d):
|
||||
if not isinstance(d, dict):
|
||||
return True
|
||||
return len(d) == 0
|
||||
|
||||
|
||||
# DOM event primitives — no-ops on server (browser-only).
|
||||
def dom_listen(el, name, handler):
|
||||
return lambda: None
|
||||
|
||||
def dom_dispatch(el, name, detail=None):
|
||||
return False
|
||||
|
||||
def event_detail(e):
|
||||
return None
|
||||
|
||||
|
||||
def env_has(env, name):
|
||||
return name in env
|
||||
|
||||
@@ -334,17 +478,8 @@ def dict_delete(d, k):
|
||||
|
||||
|
||||
def is_render_expr(expr):
|
||||
"""Check if expression is an HTML element, component, or fragment."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return False
|
||||
h = expr[0]
|
||||
if not isinstance(h, Symbol):
|
||||
return False
|
||||
n = h.name
|
||||
return (n == "<>" or n == "raw!" or
|
||||
n.startswith("~") or n.startswith("html:") or
|
||||
n in HTML_TAGS or
|
||||
("-" in n and len(expr) > 1 and isinstance(expr[1], Keyword)))
|
||||
"""Placeholder — overridden by transpiled version from render.sx."""
|
||||
return False
|
||||
|
||||
|
||||
# Render dispatch -- set by adapter
|
||||
@@ -386,6 +521,10 @@ def make_raw_html(s):
|
||||
return _RawHTML(s)
|
||||
|
||||
|
||||
def sx_expr_source(x):
|
||||
return x.source if isinstance(x, SxExpr) else str(x)
|
||||
|
||||
|
||||
class EvalError(Exception):
|
||||
pass
|
||||
|
||||
@@ -425,7 +564,12 @@ def escape_string(s):
|
||||
|
||||
|
||||
def serialize(val):
|
||||
"""Serialize an SX value to SX source text."""
|
||||
"""Serialize an SX value to SX source text.
|
||||
|
||||
Note: parser.sx defines sx-serialize with a serialize alias, but parser.sx
|
||||
is only included in JS builds (for client-side parsing). Python builds
|
||||
provide this as a platform function.
|
||||
"""
|
||||
t = type_of(val)
|
||||
if t == "sx-expr":
|
||||
return val.source
|
||||
@@ -459,179 +603,26 @@ def serialize(val):
|
||||
return "nil"
|
||||
return str(val)
|
||||
|
||||
# Aliases for transpiled code — parser.sx defines sx-serialize/sx-serialize-dict
|
||||
# but parser.sx is JS-only. Provide aliases so transpiled render.sx works.
|
||||
sx_serialize = serialize
|
||||
sx_serialize_dict = lambda d: serialize(d)
|
||||
|
||||
_SPECIAL_FORM_NAMES = frozenset([
|
||||
"if", "when", "cond", "case", "and", "or",
|
||||
"let", "let*", "lambda", "fn",
|
||||
"define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation",
|
||||
"begin", "do", "quote", "quasiquote",
|
||||
"->", "set!",
|
||||
])
|
||||
|
||||
_HO_FORM_NAMES = frozenset([
|
||||
"map", "map-indexed", "filter", "reduce",
|
||||
"some", "every?", "for-each",
|
||||
])
|
||||
_SPECIAL_FORM_NAMES = frozenset() # Placeholder — overridden by transpiled adapter-sx.sx
|
||||
_HO_FORM_NAMES = frozenset()
|
||||
|
||||
def is_special_form(name):
|
||||
return name in _SPECIAL_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
def is_ho_form(name):
|
||||
return name in _HO_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
|
||||
def aser_special(name, expr, env):
|
||||
"""Evaluate a special/HO form in aser mode.
|
||||
|
||||
Control flow forms evaluate conditions normally but render branches
|
||||
through aser (serializing tags/components instead of rendering HTML).
|
||||
Definition forms evaluate for side effects and return nil.
|
||||
"""
|
||||
# Control flow — evaluate conditions, aser branches
|
||||
args = expr[1:]
|
||||
if name == "if":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
return aser(args[1], env)
|
||||
return aser(args[2], env) if _b_len(args) > 2 else NIL
|
||||
if name == "when":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
return NIL
|
||||
if name == "cond":
|
||||
clauses = args
|
||||
if clauses and isinstance(clauses[0], _b_list) and _b_len(clauses[0]) == 2:
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return aser(clause[1], env)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(clause[1], env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(clause[1], env)
|
||||
else:
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name == "case":
|
||||
match_val = trampoline(eval_expr(args[0], env))
|
||||
clauses = args[1:]
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if match_val == trampoline(eval_expr(test, env)):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name in ("let", "let*"):
|
||||
bindings = args[0]
|
||||
local = _b_dict(env)
|
||||
if isinstance(bindings, _b_list):
|
||||
if bindings and isinstance(bindings[0], _b_list):
|
||||
for b in bindings:
|
||||
var = b[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(b[1], local))
|
||||
else:
|
||||
for i in _b_range(0, _b_len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(bindings[i + 1], local))
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, local)
|
||||
return result
|
||||
if name in ("begin", "do"):
|
||||
result = NIL
|
||||
for body in args:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
if name == "and":
|
||||
result = True
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if not sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
if name == "or":
|
||||
result = False
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
# HO forms in aser mode — map/for-each render through aser
|
||||
if name == "map":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(item))
|
||||
else:
|
||||
raise EvalError("map requires callable")
|
||||
return results
|
||||
if name == "map-indexed":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = i
|
||||
local[fn.params[1]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(i, item))
|
||||
else:
|
||||
raise EvalError("map-indexed requires callable")
|
||||
return results
|
||||
if name == "for-each":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
fn(item)
|
||||
return results if results else NIL
|
||||
# Definition forms — evaluate for side effects
|
||||
if name in ("define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation"):
|
||||
trampoline(eval_expr(expr, env))
|
||||
return NIL
|
||||
# Lambda/fn, quote, quasiquote, set!, -> : evaluate normally
|
||||
result = eval_expr(expr, env)
|
||||
return trampoline(result)
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return trampoline(eval_expr(expr, env))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -888,6 +879,7 @@ parse_int = PRIMITIVES["parse-int"]
|
||||
upper = PRIMITIVES["upper"]
|
||||
has_key_p = PRIMITIVES["has-key?"]
|
||||
dissoc = PRIMITIVES["dissoc"]
|
||||
index_of = PRIMITIVES["index-of"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -909,7 +901,7 @@ def component_css_classes(c):
|
||||
return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []
|
||||
|
||||
def env_components(env):
|
||||
"""Return list of component/macro names in an environment."""
|
||||
"""Placeholder — overridden by transpiled version from deps.sx."""
|
||||
return [k for k, v in env.items()
|
||||
if isinstance(v, (Component, Macro))]
|
||||
|
||||
@@ -947,10 +939,10 @@ trampoline = lambda val: (lambda result: (trampoline(eval_expr(thunk_expr(result
|
||||
eval_expr = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('dict', lambda: map_dict(lambda k, v: trampoline(eval_expr(v, env)), expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else eval_list(expr, env))), (None, lambda: expr)])
|
||||
|
||||
# eval-list
|
||||
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env)))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
|
||||
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defisland(args, env) if sx_truthy((name == 'defisland')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
|
||||
|
||||
# eval-call
|
||||
eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))
|
||||
eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else (call_component(f, args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))
|
||||
|
||||
# call-lambda
|
||||
call_lambda = lambda f, args, caller_env: (lambda params: (lambda local: (error(sx_str((lambda_name(f) if sx_truthy(lambda_name(f)) else 'lambda'), ' expects ', len(params), ' args, got ', len(args))) if sx_truthy((len(args) != len(params))) else _sx_begin(for_each(lambda pair: _sx_dict_set(local, first(pair), nth(pair, 1)), zip(params, args)), make_thunk(lambda_body(f), local))))(env_merge(lambda_closure(f), caller_env)))(lambda_params(f))
|
||||
@@ -1040,6 +1032,9 @@ def parse_comp_params(params_expr):
|
||||
params.append(name)
|
||||
return [params, _cells['has_children']]
|
||||
|
||||
# sf-defisland
|
||||
sf_defisland = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda comp_name: (lambda parsed: (lambda params: (lambda has_children: (lambda island: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), island), island))(make_island(comp_name, params, has_children, body, env)))(nth(parsed, 1)))(first(parsed)))(parse_comp_params(params_raw)))(strip_prefix(symbol_name(name_sym), '~')))(last(args)))(nth(args, 1)))(first(args))
|
||||
|
||||
# sf-defmacro
|
||||
sf_defmacro = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda parsed: (lambda params: (lambda rest_param: (lambda mac: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), mac), mac))(make_macro(params, rest_param, body, env, symbol_name(name_sym))))(nth(parsed, 1)))(first(parsed)))(parse_macro_params(params_raw)))(nth(args, 2)))(nth(args, 1)))(first(args))
|
||||
|
||||
@@ -1164,7 +1159,7 @@ VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'li
|
||||
BOOLEAN_ATTRS = ['async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert', 'ismap', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected']
|
||||
|
||||
# definition-form?
|
||||
is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler')))))
|
||||
is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defisland') if sx_truthy((name == 'defisland')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler'))))))
|
||||
|
||||
# parse-element-args
|
||||
parse_element_args = lambda args, env: (lambda attrs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(attrs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), [attrs, children]))([]))({})
|
||||
@@ -1184,6 +1179,9 @@ eval_cond_clojure = lambda clauses, env: (NIL if sx_truthy((len(clauses) < 2)) e
|
||||
# process-bindings
|
||||
process_bindings = lambda bindings, env: (lambda local: _sx_begin(for_each(lambda pair: ((lambda name: _sx_dict_set(local, name, trampoline(eval_expr(nth(pair, 1), local))))((symbol_name(first(pair)) if sx_truthy((type_of(first(pair)) == 'symbol')) else sx_str(first(pair)))) if sx_truthy(((type_of(pair) == 'list') if not sx_truthy((type_of(pair) == 'list')) else (len(pair) >= 2))) else NIL), bindings), local))(merge(env))
|
||||
|
||||
# is-render-expr?
|
||||
is_render_expr = lambda expr: (False if sx_truthy(((not sx_truthy((type_of(expr) == 'list'))) if sx_truthy((not sx_truthy((type_of(expr) == 'list')))) else empty_p(expr))) else (lambda h: (False if sx_truthy((not sx_truthy((type_of(h) == 'symbol')))) else (lambda n: ((n == '<>') if sx_truthy((n == '<>')) else ((n == 'raw!') if sx_truthy((n == 'raw!')) else (starts_with_p(n, '~') if sx_truthy(starts_with_p(n, '~')) else (starts_with_p(n, 'html:') if sx_truthy(starts_with_p(n, 'html:')) else (contains_p(HTML_TAGS, n) if sx_truthy(contains_p(HTML_TAGS, n)) else ((index_of(n, '-') > 0) if not sx_truthy((index_of(n, '-') > 0)) else ((len(expr) > 1) if not sx_truthy((len(expr) > 1)) else (type_of(nth(expr, 1)) == 'keyword')))))))))(symbol_name(h))))(first(expr)))
|
||||
|
||||
|
||||
# === Transpiled from adapter-html ===
|
||||
|
||||
@@ -1194,13 +1192,13 @@ render_to_html = lambda expr, env: _sx_case(type_of(expr), [('nil', lambda: ''),
|
||||
render_value_to_html = lambda val, env: _sx_case(type_of(val), [('nil', lambda: ''), ('string', lambda: escape_html(val)), ('number', lambda: sx_str(val)), ('boolean', lambda: ('true' if sx_truthy(val) else 'false')), ('list', lambda: render_list_to_html(val, env)), ('raw-html', lambda: raw_html_content(val)), (None, lambda: escape_html(sx_str(val)))])
|
||||
|
||||
# RENDER_HTML_FORMS
|
||||
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each']
|
||||
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each']
|
||||
|
||||
# render-html-form?
|
||||
is_render_html_form = lambda name: contains_p(RENDER_HTML_FORMS, name)
|
||||
|
||||
# render-list-to-html
|
||||
render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))(rest(expr)))(symbol_name(head))))(first(expr)))
|
||||
render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (render_html_island(env_get(env, name), args, env) if sx_truthy((starts_with_p(name, '~') if not sx_truthy(starts_with_p(name, '~')) else (env_has(env, name) if not sx_truthy(env_has(env, name)) else is_island(env_get(env, name))))) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))(rest(expr)))(symbol_name(head))))(first(expr)))
|
||||
|
||||
# dispatch-html-form
|
||||
dispatch_html_form = lambda name, expr, env: ((lambda cond_val: (render_to_html(nth(expr, 2), env) if sx_truthy(cond_val) else (render_to_html(nth(expr, 3), env) if sx_truthy((len(expr) > 3)) else '')))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'if')) else (('' if sx_truthy((not sx_truthy(trampoline(eval_expr(nth(expr, 1), env))))) else join('', map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr))))) if sx_truthy((name == 'when')) else ((lambda branch: (render_to_html(branch, env) if sx_truthy(branch) else ''))(eval_cond(rest(expr), env)) if sx_truthy((name == 'cond')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'case')) else ((lambda local: join('', map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr)))))(process_bindings(nth(expr, 1), env)) if sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))) else (join('', map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr)))) if sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))) else (_sx_begin(trampoline(eval_expr(expr, env)), '') if sx_truthy(is_definition_form(name)) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map')) else ((lambda f: (lambda coll: join('', map_indexed(lambda i, item: (render_lambda_html(f, [i, item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [i, item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map-indexed')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'filter')) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'for-each')) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))))))
|
||||
@@ -1214,6 +1212,12 @@ render_html_component = lambda comp, args, env: (lambda kwargs: (lambda children
|
||||
# render-html-element
|
||||
render_html_element = lambda tag, args, env: (lambda parsed: (lambda attrs: (lambda children: (lambda is_void: sx_str('<', tag, render_attrs(attrs), (' />' if sx_truthy(is_void) else sx_str('>', join('', map(lambda c: render_to_html(c, env), children)), '</', tag, '>'))))(contains_p(VOID_ELEMENTS, tag)))(nth(parsed, 1)))(first(parsed)))(parse_element_args(args, env))
|
||||
|
||||
# render-html-island
|
||||
render_html_island = lambda island, args, env: (lambda kwargs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(kwargs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), (lambda local: (lambda island_name: _sx_begin(for_each(lambda p: _sx_dict_set(local, p, (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)), component_params(island)), (_sx_dict_set(local, 'children', make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))) if sx_truthy(component_has_children(island)) else NIL), (lambda body_html: (lambda state_json: sx_str('<div data-sx-island="', escape_attr(island_name), '"', (sx_str(' data-sx-state="', escape_attr(state_json), '"') if sx_truthy(state_json) else ''), '>', body_html, '</div>'))(serialize_island_state(kwargs)))(render_to_html(component_body(island), local))))(component_name(island)))(env_merge(component_closure(island), env))))([]))({})
|
||||
|
||||
# serialize-island-state
|
||||
serialize_island_state = lambda kwargs: (NIL if sx_truthy(is_empty_dict(kwargs)) else json_serialize(kwargs))
|
||||
|
||||
|
||||
# === Transpiled from adapter-sx ===
|
||||
|
||||
@@ -1224,7 +1228,7 @@ render_to_sx = lambda expr, env: (lambda result: (result if sx_truthy((type_of(r
|
||||
aser = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else aser_list(expr, env))), (None, lambda: expr)])
|
||||
|
||||
# aser-list
|
||||
aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr))
|
||||
aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr))
|
||||
|
||||
# aser-fragment
|
||||
aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(parts)) else sx_str('(<> ', join(' ', map(serialize, parts)), ')')))(filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children)))
|
||||
@@ -1232,6 +1236,33 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
|
||||
# aser-call
|
||||
aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name])
|
||||
|
||||
# SPECIAL_FORM_NAMES
|
||||
SPECIAL_FORM_NAMES = ['if', 'when', 'cond', 'case', 'and', 'or', 'let', 'let*', 'lambda', 'fn', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'defpage', 'defquery', 'defaction', 'defrelation', 'begin', 'do', 'quote', 'quasiquote', '->', 'set!', 'letrec', 'dynamic-wind', 'defisland']
|
||||
|
||||
# HO_FORM_NAMES
|
||||
HO_FORM_NAMES = ['map', 'map-indexed', 'filter', 'reduce', 'some', 'every?', 'for-each']
|
||||
|
||||
# special-form?
|
||||
is_special_form = lambda name: contains_p(SPECIAL_FORM_NAMES, name)
|
||||
|
||||
# ho-form?
|
||||
is_ho_form = lambda name: contains_p(HO_FORM_NAMES, name)
|
||||
|
||||
# aser-special
|
||||
def aser_special(name, expr, env):
|
||||
_cells = {}
|
||||
args = rest(expr)
|
||||
return ((aser(nth(args, 1), env) if sx_truthy(trampoline(eval_expr(first(args), env))) else (aser(nth(args, 2), env) if sx_truthy((len(args) > 2)) else NIL)) if sx_truthy((name == 'if')) else ((NIL if sx_truthy((not sx_truthy(trampoline(eval_expr(first(args), env))))) else _sx_begin(_sx_cell_set(_cells, 'result', NIL), _sx_begin(for_each(lambda body: _sx_cell_set(_cells, 'result', aser(body, env)), rest(args)), _cells['result']))) if sx_truthy((name == 'when')) else ((lambda branch: (aser(branch, env) if sx_truthy(branch) else NIL))(eval_cond(args, env)) if sx_truthy((name == 'cond')) else ((lambda match_val: (lambda clauses: eval_case_aser(match_val, clauses, env))(rest(args)))(trampoline(eval_expr(first(args), env))) if sx_truthy((name == 'case')) else ((lambda local: _sx_begin(_sx_cell_set(_cells, 'result', NIL), _sx_begin(for_each(lambda body: _sx_cell_set(_cells, 'result', aser(body, local)), rest(args)), _cells['result'])))(process_bindings(first(args), env)) if sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))) else (_sx_begin(_sx_cell_set(_cells, 'result', NIL), _sx_begin(for_each(lambda body: _sx_cell_set(_cells, 'result', aser(body, env)), args), _cells['result'])) if sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))) else (_sx_begin(_sx_cell_set(_cells, 'result', True), _sx_begin(some(_sx_fn(lambda arg: (
|
||||
_sx_cell_set(_cells, 'result', trampoline(eval_expr(arg, env))),
|
||||
(not sx_truthy(_cells['result']))
|
||||
)[-1]), args), _cells['result'])) if sx_truthy((name == 'and')) else (_sx_begin(_sx_cell_set(_cells, 'result', False), _sx_begin(some(_sx_fn(lambda arg: (
|
||||
_sx_cell_set(_cells, 'result', trampoline(eval_expr(arg, env))),
|
||||
_cells['result']
|
||||
)[-1]), args), _cells['result'])) if sx_truthy((name == 'or')) else ((lambda f: (lambda coll: map(lambda item: ((lambda local: _sx_begin(_sx_dict_set(local, first(lambda_params(f)), item), aser(lambda_body(f), local)))(env_merge(lambda_closure(f), env)) if sx_truthy(is_lambda(f)) else invoke(f, item)), coll))(trampoline(eval_expr(nth(args, 1), env))))(trampoline(eval_expr(first(args), env))) if sx_truthy((name == 'map')) else ((lambda f: (lambda coll: map_indexed(lambda i, item: ((lambda local: _sx_begin(_sx_dict_set(local, first(lambda_params(f)), i), _sx_dict_set(local, nth(lambda_params(f), 1), item), aser(lambda_body(f), local)))(env_merge(lambda_closure(f), env)) if sx_truthy(is_lambda(f)) else invoke(f, i, item)), coll))(trampoline(eval_expr(nth(args, 1), env))))(trampoline(eval_expr(first(args), env))) if sx_truthy((name == 'map-indexed')) else ((lambda f: (lambda coll: (lambda results: _sx_begin(for_each(lambda item: ((lambda local: _sx_begin(_sx_dict_set(local, first(lambda_params(f)), item), _sx_append(results, aser(lambda_body(f), local))))(env_merge(lambda_closure(f), env)) if sx_truthy(is_lambda(f)) else invoke(f, item)), coll), (NIL if sx_truthy(empty_p(results)) else results)))([]))(trampoline(eval_expr(nth(args, 1), env))))(trampoline(eval_expr(first(args), env))) if sx_truthy((name == 'for-each')) else (_sx_begin(trampoline(eval_expr(expr, env)), serialize(expr)) if sx_truthy((name == 'defisland')) else (_sx_begin(trampoline(eval_expr(expr, env)), NIL) if sx_truthy(((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else ((name == 'defhandler') if sx_truthy((name == 'defhandler')) else ((name == 'defpage') if sx_truthy((name == 'defpage')) else ((name == 'defquery') if sx_truthy((name == 'defquery')) else ((name == 'defaction') if sx_truthy((name == 'defaction')) else (name == 'defrelation')))))))))) else trampoline(eval_expr(expr, env)))))))))))))))
|
||||
|
||||
# eval-case-aser
|
||||
eval_case_aser = lambda match_val, clauses, env: (NIL if sx_truthy((len(clauses) < 2)) else (lambda test: (lambda body: (aser(body, env) if sx_truthy((((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else')) if sx_truthy(((type_of(test) == 'keyword') if not sx_truthy((type_of(test) == 'keyword')) else (keyword_name(test) == 'else'))) else ((type_of(test) == 'symbol') if not sx_truthy((type_of(test) == 'symbol')) else ((symbol_name(test) == ':else') if sx_truthy((symbol_name(test) == ':else')) else (symbol_name(test) == 'else'))))) else (aser(body, env) if sx_truthy((match_val == trampoline(eval_expr(test, env)))) else eval_case_aser(match_val, slice(clauses, 2), env))))(nth(clauses, 1)))(first(clauses)))
|
||||
|
||||
|
||||
# === Transpiled from deps (component dependency analysis) ===
|
||||
|
||||
@@ -1289,6 +1320,110 @@ render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server'
|
||||
# page-render-plan
|
||||
page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), transitive_io_refs(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env))
|
||||
|
||||
# env-components
|
||||
env_components = lambda env: filter(lambda k: (lambda v: (is_component(v) if sx_truthy(is_component(v)) else is_macro(v)))(env_get(env, k)), keys(env))
|
||||
|
||||
|
||||
# === Transpiled from signals (reactive signal runtime) ===
|
||||
|
||||
# signal
|
||||
signal = lambda initial_value: make_signal(initial_value)
|
||||
|
||||
# deref
|
||||
deref = lambda s: (s if sx_truthy((not sx_truthy(is_signal(s)))) else (lambda ctx: _sx_begin((_sx_begin(tracking_context_add_dep(ctx, s), signal_add_sub(s, tracking_context_notify_fn(ctx))) if sx_truthy(ctx) else NIL), signal_value(s)))(get_tracking_context()))
|
||||
|
||||
# reset!
|
||||
reset_b = lambda s, value: ((lambda old: (_sx_begin(signal_set_value(s, value), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, value)))) else NIL))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL)
|
||||
|
||||
# swap!
|
||||
swap_b = lambda s, f, *args: ((lambda old: (lambda new_val: (_sx_begin(signal_set_value(s, new_val), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL))(apply(f, cons(old, args))))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL)
|
||||
|
||||
# computed
|
||||
computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), register_in_scope(lambda : dispose_computed(s)), s))(_sx_fn(lambda : (
|
||||
for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)),
|
||||
signal_set_deps(s, []),
|
||||
(lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda new_val: _sx_begin(set_tracking_context(prev), signal_set_deps(s, tracking_context_deps(ctx)), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(invoke(compute_fn))))(get_tracking_context()))(make_tracking_context(recompute))
|
||||
)[-1])))(NIL))([]))(make_signal(NIL))
|
||||
|
||||
# effect
|
||||
def effect(effect_fn):
|
||||
_cells = {}
|
||||
_cells['deps'] = []
|
||||
_cells['disposed'] = False
|
||||
_cells['cleanup_fn'] = NIL
|
||||
run_effect = lambda : (_sx_begin((invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda result: _sx_begin(set_tracking_context(prev), _sx_cell_set(_cells, 'deps', tracking_context_deps(ctx)), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(invoke(effect_fn))))(get_tracking_context()))(make_tracking_context(run_effect))) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL)
|
||||
run_effect()
|
||||
dispose_fn = _sx_fn(lambda : (
|
||||
_sx_cell_set(_cells, 'disposed', True),
|
||||
(invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL),
|
||||
for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']),
|
||||
_sx_cell_set(_cells, 'deps', [])
|
||||
)[-1])
|
||||
register_in_scope(dispose_fn)
|
||||
return dispose_fn
|
||||
|
||||
# *batch-depth*
|
||||
_batch_depth = 0
|
||||
|
||||
# *batch-queue*
|
||||
_batch_queue = []
|
||||
|
||||
# batch
|
||||
def batch(thunk):
|
||||
_batch_depth = (_batch_depth + 1)
|
||||
invoke(thunk)
|
||||
_batch_depth = (_batch_depth - 1)
|
||||
return ((lambda queue: _sx_begin(_sx_cell_set(_cells, '_batch_queue', []), (lambda seen: (lambda pending: _sx_begin(for_each(lambda s: for_each(lambda sub: (_sx_begin(_sx_append(seen, sub), _sx_append(pending, sub)) if sx_truthy((not sx_truthy(contains_p(seen, sub)))) else NIL), signal_subscribers(s)), queue), for_each(lambda sub: sub(), pending)))([]))([])))(_batch_queue) if sx_truthy((_batch_depth == 0)) else NIL)
|
||||
|
||||
# notify-subscribers
|
||||
notify_subscribers = lambda s: ((_sx_append(_batch_queue, s) if sx_truthy((not sx_truthy(contains_p(_batch_queue, s)))) else NIL) if sx_truthy((_batch_depth > 0)) else flush_subscribers(s))
|
||||
|
||||
# flush-subscribers
|
||||
flush_subscribers = lambda s: for_each(lambda sub: sub(), signal_subscribers(s))
|
||||
|
||||
# dispose-computed
|
||||
dispose_computed = lambda s: (_sx_begin(for_each(lambda dep: signal_remove_sub(dep, NIL), signal_deps(s)), signal_set_deps(s, [])) if sx_truthy(is_signal(s)) else NIL)
|
||||
|
||||
# *island-scope*
|
||||
_island_scope = NIL
|
||||
|
||||
# with-island-scope
|
||||
def with_island_scope(scope_fn, body_fn):
|
||||
prev = _island_scope
|
||||
_island_scope = scope_fn
|
||||
result = body_fn()
|
||||
_island_scope = prev
|
||||
return result
|
||||
|
||||
# register-in-scope
|
||||
register_in_scope = lambda disposable: (_island_scope(disposable) if sx_truthy(_island_scope) else NIL)
|
||||
|
||||
# *store-registry*
|
||||
_store_registry = {}
|
||||
|
||||
# def-store
|
||||
def def_store(name, init_fn):
|
||||
registry = _store_registry
|
||||
if sx_truthy((not sx_truthy(has_key_p(registry, name)))):
|
||||
_store_registry = assoc(registry, name, invoke(init_fn))
|
||||
return get(_store_registry, name)
|
||||
|
||||
# use-store
|
||||
use_store = lambda name: (get(_store_registry, name) if sx_truthy(has_key_p(_store_registry, name)) else error(sx_str('Store not found: ', name, '. Call (def-store ...) before (use-store ...).')))
|
||||
|
||||
# clear-stores
|
||||
def clear_stores():
|
||||
return _sx_cell_set(_cells, '_store_registry', {})
|
||||
|
||||
# emit-event
|
||||
emit_event = lambda el, event_name, detail: dom_dispatch(el, event_name, detail)
|
||||
|
||||
# on-event
|
||||
on_event = lambda el, event_name, handler: dom_listen(el, event_name, handler)
|
||||
|
||||
# bridge-event
|
||||
bridge_event = lambda el, event_name, target_signal, transform_fn: effect(lambda : (lambda remove: remove)(dom_listen(el, event_name, lambda e: (lambda detail: (lambda new_val: reset_b(target_signal, new_val))((invoke(transform_fn, detail) if sx_truthy(transform_fn) else detail)))(event_detail(e)))))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixups -- wire up render adapter dispatch
|
||||
|
||||
@@ -337,7 +337,32 @@
|
||||
(deftest "higher-order returns lambda"
|
||||
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
|
||||
(let ((add5 (make-adder 5)))
|
||||
(assert-equal 8 (add5 3))))))
|
||||
(assert-equal 8 (add5 3)))))
|
||||
|
||||
(deftest "multi-body lambda returns last value"
|
||||
;; All body expressions must execute. Return value is the last.
|
||||
;; Catches: sf-lambda using nth(args,1) instead of rest(args).
|
||||
(let ((f (fn (x) (+ x 1) (+ x 2) (+ x 3))))
|
||||
(assert-equal 13 (f 10))))
|
||||
|
||||
(deftest "multi-body lambda side effects via dict mutation"
|
||||
;; Verify all body expressions run by mutating a shared dict.
|
||||
(let ((state (dict "a" 0 "b" 0)))
|
||||
(let ((f (fn ()
|
||||
(dict-set! state "a" 1)
|
||||
(dict-set! state "b" 2)
|
||||
"done")))
|
||||
(assert-equal "done" (f))
|
||||
(assert-equal 1 (get state "a"))
|
||||
(assert-equal 2 (get state "b")))))
|
||||
|
||||
(deftest "multi-body lambda two expressions"
|
||||
;; Simplest case: two body expressions, return value is second.
|
||||
(assert-equal 20
|
||||
((fn (x) (+ x 1) (* x 2)) 10))
|
||||
;; And with zero-arg lambda
|
||||
(assert-equal 42
|
||||
((fn () (+ 1 2) 42)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
170
shared/sx/ref/test-orchestration.sx
Normal file
170
shared/sx/ref/test-orchestration.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; ==========================================================================
|
||||
;; test-orchestration.sx — Tests for orchestration.sx Phase 7c + 7d
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions mocked by test runner:
|
||||
;; now-ms, log-info, log-warn, execute-action, try-rerender-page
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. page-data-cache — basic cache operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "page-data-cache"
|
||||
|
||||
(deftest "cache-key bare page name"
|
||||
(assert-equal "my-page" (page-data-cache-key "my-page" nil)))
|
||||
|
||||
(deftest "cache-key with params"
|
||||
(let ((key (page-data-cache-key "my-page" {"id" "42"})))
|
||||
(assert-equal "my-page:id=42" key)))
|
||||
|
||||
(deftest "cache-set then get"
|
||||
(let ((key "test-cache-1"))
|
||||
(page-data-cache-set key {"items" (list 1 2 3)})
|
||||
(let ((result (page-data-cache-get key)))
|
||||
(assert-equal (list 1 2 3) (get result "items")))))
|
||||
|
||||
(deftest "cache miss returns nil"
|
||||
(assert-nil (page-data-cache-get "nonexistent-key"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. optimistic-cache-update — predicted mutation with snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-update"
|
||||
|
||||
(deftest "applies mutator to cached data"
|
||||
(let ((key "opt-test-1"))
|
||||
;; Seed the cache
|
||||
(page-data-cache-set key {"count" 10})
|
||||
;; Apply optimistic mutation
|
||||
(let ((predicted (optimistic-cache-update key
|
||||
(fn (data) (merge data {"count" 11})))))
|
||||
(assert-equal 11 (get predicted "count")))))
|
||||
|
||||
(deftest "updates cache with prediction"
|
||||
(let ((key "opt-test-2"))
|
||||
(page-data-cache-set key {"count" 5})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 6})))
|
||||
;; Cache now has predicted value
|
||||
(let ((cached (page-data-cache-get key)))
|
||||
(assert-equal 6 (get cached "count")))))
|
||||
|
||||
(deftest "returns nil when no cached data"
|
||||
(let ((result (optimistic-cache-update "no-such-key"
|
||||
(fn (data) (merge data {"x" 1})))))
|
||||
(assert-nil result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. optimistic-cache-revert — restore from snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-revert"
|
||||
|
||||
(deftest "reverts to original data"
|
||||
(let ((key "revert-test-1"))
|
||||
(page-data-cache-set key {"count" 10})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 99})))
|
||||
;; Cache now has 99
|
||||
(assert-equal 99 (get (page-data-cache-get key) "count"))
|
||||
;; Revert
|
||||
(let ((restored (optimistic-cache-revert key)))
|
||||
(assert-equal 10 (get restored "count"))
|
||||
;; Cache is back to original
|
||||
(assert-equal 10 (get (page-data-cache-get key) "count")))))
|
||||
|
||||
(deftest "returns nil when no snapshot"
|
||||
(assert-nil (optimistic-cache-revert "never-mutated"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. optimistic-cache-confirm — discard snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-confirm"
|
||||
|
||||
(deftest "confirm clears snapshot"
|
||||
(let ((key "confirm-test-1"))
|
||||
(page-data-cache-set key {"val" "a"})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"val" "b"})))
|
||||
;; Confirm — accepts the optimistic value
|
||||
(optimistic-cache-confirm key)
|
||||
;; Revert should now return nil (no snapshot)
|
||||
(assert-nil (optimistic-cache-revert key))
|
||||
;; Cache still has optimistic value
|
||||
(assert-equal "b" (get (page-data-cache-get key) "val")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. offline-is-online? / offline-set-online! — connectivity tracking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-connectivity"
|
||||
|
||||
(deftest "initially online"
|
||||
(assert-true (offline-is-online?)))
|
||||
|
||||
(deftest "set offline"
|
||||
(offline-set-online! false)
|
||||
(assert-false (offline-is-online?)))
|
||||
|
||||
(deftest "set back online"
|
||||
(offline-set-online! true)
|
||||
(assert-true (offline-is-online?))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. offline-queue-mutation — queue entries when offline
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-queue-mutation"
|
||||
|
||||
(deftest "queues an entry"
|
||||
;; Seed cache so optimistic update works
|
||||
(let ((key (page-data-cache-key "notes" nil)))
|
||||
(page-data-cache-set key {"items" (list "a" "b")})
|
||||
(let ((entry (offline-queue-mutation "add-note"
|
||||
{"text" "c"}
|
||||
"notes" nil
|
||||
(fn (data) (merge data {"items" (list "a" "b" "c")})))))
|
||||
(assert-equal "add-note" (get entry "action"))
|
||||
(assert-equal "pending" (get entry "status")))))
|
||||
|
||||
(deftest "pending count increases"
|
||||
;; Previous test queued 1 entry; count should be >= 1
|
||||
(assert-true (> (offline-pending-count) 0))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. offline-aware-mutation — routes by connectivity
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-aware-mutation"
|
||||
|
||||
(deftest "when online calls submit-mutation path"
|
||||
(offline-set-online! true)
|
||||
(let ((key (page-data-cache-key "test-page" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
;; This will trigger execute-action (mocked) which calls success cb
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
;; Mock execute-action calls success immediately
|
||||
(assert-equal "confirmed" status))))
|
||||
|
||||
(deftest "when offline queues mutation"
|
||||
(offline-set-online! false)
|
||||
(let ((key (page-data-cache-key "test-page-2" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page-2" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
(assert-equal "queued" status)))
|
||||
;; Clean up: go back online
|
||||
(offline-set-online! true)))
|
||||
173
shared/sx/ref/test-signals.sx
Normal file
173
shared/sx/ref/test-signals.sx
Normal file
@@ -0,0 +1,173 @@
|
||||
;; ==========================================================================
|
||||
;; test-signals.sx — Tests for signals and reactive islands
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: signals.sx, eval.sx (defisland)
|
||||
;;
|
||||
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
|
||||
;; compatibility with the hand-written evaluator which only supports
|
||||
;; single-expression lambda bodies.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Signal creation and basic read/write
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "signal basics"
|
||||
(deftest "signal creates a reactive container"
|
||||
(let ((s (signal 42)))
|
||||
(assert-true (signal? s))
|
||||
(assert-equal 42 (deref s))))
|
||||
|
||||
(deftest "deref on non-signal passes through"
|
||||
(assert-equal 5 (deref 5))
|
||||
(assert-equal "hello" (deref "hello"))
|
||||
(assert-nil (deref nil)))
|
||||
|
||||
(deftest "reset! changes value"
|
||||
(let ((s (signal 0)))
|
||||
(reset! s 10)
|
||||
(assert-equal 10 (deref s))))
|
||||
|
||||
(deftest "reset! does not notify when value unchanged"
|
||||
(let ((s (signal 5))
|
||||
(count (signal 0)))
|
||||
(effect (fn () (do (deref s) (swap! count inc))))
|
||||
;; Effect runs once on creation → count=1
|
||||
(let ((c1 (deref count)))
|
||||
(reset! s 5) ;; same value — no notification
|
||||
(assert-equal c1 (deref count)))))
|
||||
|
||||
(deftest "swap! applies function to current value"
|
||||
(let ((s (signal 10)))
|
||||
(swap! s inc)
|
||||
(assert-equal 11 (deref s))))
|
||||
|
||||
(deftest "swap! passes extra args"
|
||||
(let ((s (signal 10)))
|
||||
(swap! s + 5)
|
||||
(assert-equal 15 (deref s)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Computed signals
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "computed"
|
||||
(deftest "computed derives initial value"
|
||||
(let ((a (signal 3))
|
||||
(b (signal 4))
|
||||
(sum (computed (fn () (+ (deref a) (deref b))))))
|
||||
(assert-equal 7 (deref sum))))
|
||||
|
||||
(deftest "computed updates when dependency changes"
|
||||
(let ((a (signal 2))
|
||||
(doubled (computed (fn () (* 2 (deref a))))))
|
||||
(assert-equal 4 (deref doubled))
|
||||
(reset! a 5)
|
||||
(assert-equal 10 (deref doubled))))
|
||||
|
||||
(deftest "computed chains"
|
||||
(let ((base (signal 1))
|
||||
(doubled (computed (fn () (* 2 (deref base)))))
|
||||
(quadrupled (computed (fn () (* 2 (deref doubled))))))
|
||||
(assert-equal 4 (deref quadrupled))
|
||||
(reset! base 3)
|
||||
(assert-equal 12 (deref quadrupled)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Effects
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "effects"
|
||||
(deftest "effect runs immediately"
|
||||
(let ((ran (signal false)))
|
||||
(effect (fn () (reset! ran true)))
|
||||
(assert-true (deref ran))))
|
||||
|
||||
(deftest "effect re-runs when dependency changes"
|
||||
(let ((source (signal "a"))
|
||||
(log (signal (list))))
|
||||
(effect (fn ()
|
||||
(swap! log (fn (l) (append l (deref source))))))
|
||||
;; Initial run logs "a"
|
||||
(assert-equal (list "a") (deref log))
|
||||
;; Change triggers re-run
|
||||
(reset! source "b")
|
||||
(assert-equal (list "a" "b") (deref log))))
|
||||
|
||||
(deftest "effect dispose stops tracking"
|
||||
(let ((source (signal 0))
|
||||
(count (signal 0)))
|
||||
(let ((dispose (effect (fn () (do
|
||||
(deref source)
|
||||
(swap! count inc))))))
|
||||
;; Effect ran once
|
||||
(assert-equal 1 (deref count))
|
||||
;; Trigger
|
||||
(reset! source 1)
|
||||
(assert-equal 2 (deref count))
|
||||
;; Dispose
|
||||
(dispose)
|
||||
;; Should NOT trigger
|
||||
(reset! source 2)
|
||||
(assert-equal 2 (deref count)))))
|
||||
|
||||
(deftest "effect cleanup runs before re-run"
|
||||
(let ((source (signal 0))
|
||||
(cleanups (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref source)
|
||||
(fn () (swap! cleanups inc))))) ;; return cleanup fn
|
||||
;; No cleanup yet (first run)
|
||||
(assert-equal 0 (deref cleanups))
|
||||
;; Change triggers cleanup of previous run
|
||||
(reset! source 1)
|
||||
(assert-equal 1 (deref cleanups)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Batch
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "batch"
|
||||
(deftest "batch defers notifications"
|
||||
(let ((a (signal 0))
|
||||
(b (signal 0))
|
||||
(run-count (signal 0)))
|
||||
(effect (fn () (do
|
||||
(deref a) (deref b)
|
||||
(swap! run-count inc))))
|
||||
;; Initial run
|
||||
(assert-equal 1 (deref run-count))
|
||||
;; Without batch: 2 writes → 2 effect runs
|
||||
;; With batch: 2 writes → 1 effect run
|
||||
(batch (fn () (do
|
||||
(reset! a 1)
|
||||
(reset! b 2))))
|
||||
;; Should be 2 (initial + 1 batched), not 3
|
||||
(assert-equal 2 (deref run-count)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defisland
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "defisland"
|
||||
(deftest "defisland creates an island"
|
||||
(defisland ~test-island (&key value)
|
||||
(list "island" value))
|
||||
(assert-true (island? ~test-island)))
|
||||
|
||||
(deftest "island is callable like component"
|
||||
(defisland ~greeting (&key name)
|
||||
(str "Hello, " name "!"))
|
||||
(assert-equal "Hello, World!" (~greeting :name "World")))
|
||||
|
||||
(deftest "island accepts children"
|
||||
(defisland ~wrapper (&rest children)
|
||||
(list "wrap" children))
|
||||
(assert-equal (list "wrap" (list "a" "b"))
|
||||
(~wrapper "a" "b"))))
|
||||
@@ -21,7 +21,7 @@ sys.path.insert(0, _PROJECT)
|
||||
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.evaluator import _eval, _trampoline, _call_lambda
|
||||
from shared.sx.types import Symbol, Keyword, Lambda, NIL
|
||||
from shared.sx.types import Symbol, Keyword, Lambda, NIL, Component, Island
|
||||
|
||||
# --- Test state ---
|
||||
suite_stack: list[str] = []
|
||||
@@ -134,15 +134,134 @@ def render_html(sx_source):
|
||||
return result
|
||||
|
||||
|
||||
# --- Signal platform primitives ---
|
||||
# Implements the signal runtime platform interface for testing signals.sx
|
||||
|
||||
class Signal:
|
||||
"""A reactive signal container."""
|
||||
__slots__ = ("value", "subscribers", "deps")
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.subscribers = [] # list of callables
|
||||
self.deps = [] # list of Signal (for computed)
|
||||
|
||||
|
||||
class TrackingContext:
|
||||
"""Tracks signal dependencies during effect/computed evaluation."""
|
||||
__slots__ = ("notify_fn", "deps")
|
||||
|
||||
def __init__(self, notify_fn):
|
||||
self.notify_fn = notify_fn
|
||||
self.deps = []
|
||||
|
||||
|
||||
_tracking_context = [None] # mutable cell
|
||||
|
||||
|
||||
def _make_signal(value):
|
||||
s = Signal(value)
|
||||
return s
|
||||
|
||||
|
||||
def _signal_p(x):
|
||||
return isinstance(x, Signal)
|
||||
|
||||
|
||||
def _signal_value(s):
|
||||
return s.value
|
||||
|
||||
|
||||
def _signal_set_value(s, v):
|
||||
s.value = v
|
||||
return NIL
|
||||
|
||||
|
||||
def _signal_subscribers(s):
|
||||
return list(s.subscribers)
|
||||
|
||||
|
||||
def _signal_add_sub(s, fn):
|
||||
if fn not in s.subscribers:
|
||||
s.subscribers.append(fn)
|
||||
return NIL
|
||||
|
||||
|
||||
def _signal_remove_sub(s, fn):
|
||||
if fn in s.subscribers:
|
||||
s.subscribers.remove(fn)
|
||||
return NIL
|
||||
|
||||
|
||||
def _signal_deps(s):
|
||||
return list(s.deps)
|
||||
|
||||
|
||||
def _signal_set_deps(s, deps):
|
||||
s.deps = list(deps)
|
||||
return NIL
|
||||
|
||||
|
||||
def _set_tracking_context(ctx):
|
||||
_tracking_context[0] = ctx
|
||||
return NIL
|
||||
|
||||
|
||||
def _get_tracking_context():
|
||||
return _tracking_context[0] or NIL
|
||||
|
||||
|
||||
def _make_tracking_context(notify_fn):
|
||||
return TrackingContext(notify_fn)
|
||||
|
||||
|
||||
def _tracking_context_deps(ctx):
|
||||
if isinstance(ctx, TrackingContext):
|
||||
return ctx.deps
|
||||
return []
|
||||
|
||||
|
||||
def _tracking_context_add_dep(ctx, s):
|
||||
if isinstance(ctx, TrackingContext) and s not in ctx.deps:
|
||||
ctx.deps.append(s)
|
||||
return NIL
|
||||
|
||||
|
||||
def _tracking_context_notify_fn(ctx):
|
||||
if isinstance(ctx, TrackingContext):
|
||||
return ctx.notify_fn
|
||||
return NIL
|
||||
|
||||
|
||||
def _identical(a, b):
|
||||
return a is b
|
||||
|
||||
|
||||
def _island_p(x):
|
||||
return isinstance(x, Island)
|
||||
|
||||
|
||||
def _make_island(name, params, has_children, body, closure):
|
||||
return Island(
|
||||
name=name,
|
||||
params=list(params),
|
||||
has_children=has_children,
|
||||
body=body,
|
||||
closure=dict(closure) if isinstance(closure, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
# --- Spec registry ---
|
||||
|
||||
SPECS = {
|
||||
"eval": {"file": "test-eval.sx", "needs": []},
|
||||
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
||||
"router": {"file": "test-router.sx", "needs": []},
|
||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||
"deps": {"file": "test-deps.sx", "needs": []},
|
||||
"engine": {"file": "test-engine.sx", "needs": []},
|
||||
"eval": {"file": "test-eval.sx", "needs": []},
|
||||
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
||||
"router": {"file": "test-router.sx", "needs": []},
|
||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||
"deps": {"file": "test-deps.sx", "needs": []},
|
||||
"engine": {"file": "test-engine.sx", "needs": []},
|
||||
"orchestration": {"file": "test-orchestration.sx", "needs": []},
|
||||
"signals": {"file": "test-signals.sx", "needs": ["make-signal"]},
|
||||
}
|
||||
|
||||
REF_DIR = os.path.join(_HERE, "..", "ref")
|
||||
@@ -186,6 +305,31 @@ env = {
|
||||
"inc": lambda n: n + 1,
|
||||
# Component accessor for affinity (Phase 7)
|
||||
"component-affinity": lambda c: getattr(c, 'affinity', 'auto'),
|
||||
# Signal platform primitives
|
||||
"make-signal": _make_signal,
|
||||
"signal?": _signal_p,
|
||||
"signal-value": _signal_value,
|
||||
"signal-set-value!": _signal_set_value,
|
||||
"signal-subscribers": _signal_subscribers,
|
||||
"signal-add-sub!": _signal_add_sub,
|
||||
"signal-remove-sub!": _signal_remove_sub,
|
||||
"signal-deps": _signal_deps,
|
||||
"signal-set-deps!": _signal_set_deps,
|
||||
"set-tracking-context!": _set_tracking_context,
|
||||
"get-tracking-context": _get_tracking_context,
|
||||
"make-tracking-context": _make_tracking_context,
|
||||
"tracking-context-deps": _tracking_context_deps,
|
||||
"tracking-context-add-dep!": _tracking_context_add_dep,
|
||||
"tracking-context-notify-fn": _tracking_context_notify_fn,
|
||||
"identical?": _identical,
|
||||
# Island platform primitives
|
||||
"island?": _island_p,
|
||||
"make-island": _make_island,
|
||||
"component-name": lambda c: getattr(c, 'name', ''),
|
||||
"component-params": lambda c: list(getattr(c, 'params', [])),
|
||||
"component-body": lambda c: getattr(c, 'body', NIL),
|
||||
"component-closure": lambda c: dict(getattr(c, 'closure', {})),
|
||||
"component-has-children?": lambda c: getattr(c, 'has_children', False),
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +477,65 @@ def _load_engine_from_bootstrap(env):
|
||||
eval_file("engine.sx", env)
|
||||
|
||||
|
||||
def _load_orchestration(env):
|
||||
"""Load orchestration.sx with mocked platform functions for testing.
|
||||
|
||||
Orchestration defines many browser-wiring functions (DOM, fetch, etc.)
|
||||
but the Phase 7c/7d tests only exercise the cache, optimistic, and
|
||||
offline functions. Lambda bodies referencing DOM/fetch are never called,
|
||||
so we only need to mock the functions actually invoked by the tests:
|
||||
now-ms, log-info, log-warn, execute-action, try-rerender-page.
|
||||
"""
|
||||
_mock_ts = [1000] # mutable so mock can advance time
|
||||
|
||||
def _mock_now_ms():
|
||||
return _mock_ts[0]
|
||||
|
||||
def _noop(*_a, **_kw):
|
||||
return NIL
|
||||
|
||||
def _mock_execute_action(action, payload, on_success, on_error):
|
||||
"""Mock: immediately calls on_success with payload as 'server truth'."""
|
||||
on_success(payload)
|
||||
return NIL
|
||||
|
||||
def _dict_delete(d, k):
|
||||
if isinstance(d, dict) and k in d:
|
||||
del d[k]
|
||||
return NIL
|
||||
|
||||
env["now-ms"] = _mock_now_ms
|
||||
env["log-info"] = _noop
|
||||
env["log-warn"] = _noop
|
||||
env["execute-action"] = _mock_execute_action
|
||||
env["try-rerender-page"] = _noop
|
||||
env["persist-offline-data"] = _noop
|
||||
env["retrieve-offline-data"] = lambda: NIL
|
||||
env["dict-delete!"] = _dict_delete
|
||||
# DOM / browser stubs (never called by tests, but referenced in lambdas
|
||||
# that the evaluator might try to resolve at call time)
|
||||
for stub in [
|
||||
"try-parse-json", "dom-dispatch", "dom-query-selector",
|
||||
"dom-get-attribute", "dom-set-attribute", "dom-set-text-content",
|
||||
"dom-append", "dom-insert-html-adjacent", "dom-remove",
|
||||
"dom-outer-html", "dom-inner-html", "dom-create-element",
|
||||
"dom-set-inner-html", "dom-morph", "dom-get-tag",
|
||||
"dom-query-selector-all", "dom-add-event-listener",
|
||||
"dom-set-timeout", "dom-prevent-default", "dom-closest",
|
||||
"dom-matches", "dom-get-id", "dom-set-id", "dom-form-data",
|
||||
"dom-is-form", "browser-location-href", "browser-push-state",
|
||||
"browser-replace-state", "sx-hydrate-elements", "render-to-dom",
|
||||
"hoist-head-elements-full", "url-pathname",
|
||||
]:
|
||||
if stub not in env:
|
||||
env[stub] = _noop
|
||||
|
||||
# Load engine.sx first (orchestration depends on it)
|
||||
_load_engine_from_bootstrap(env)
|
||||
# Load orchestration.sx
|
||||
eval_file("orchestration.sx", env)
|
||||
|
||||
|
||||
def _load_forms_from_bootstrap(env):
|
||||
"""Load forms functions (including streaming protocol) from sx_ref.py."""
|
||||
try:
|
||||
@@ -352,6 +555,171 @@ def _load_forms_from_bootstrap(env):
|
||||
eval_file("forms.sx", env)
|
||||
|
||||
|
||||
def _load_signals(env):
|
||||
"""Load signals.sx spec — defines signal, deref, reset!, swap!, etc.
|
||||
|
||||
The hand-written evaluator doesn't support &rest in define/fn,
|
||||
so we override swap! with a native implementation after loading.
|
||||
"""
|
||||
# callable? is needed by effect (to check if return value is cleanup fn)
|
||||
env["callable?"] = lambda x: callable(x) or isinstance(x, Lambda)
|
||||
eval_file("signals.sx", env)
|
||||
|
||||
# Override signal functions that need to call Lambda subscribers.
|
||||
# The hand-written evaluator's Lambda objects can't be called directly
|
||||
# from Python — they need _call_lambda. So we provide native versions
|
||||
# of functions that bridge native→Lambda calls.
|
||||
|
||||
def _call_sx_fn(fn, args):
|
||||
"""Call an SX function (Lambda or native) from Python."""
|
||||
if isinstance(fn, Lambda):
|
||||
return _trampoline(_call_lambda(fn, list(args), env))
|
||||
if callable(fn):
|
||||
return fn(*args)
|
||||
return NIL
|
||||
|
||||
def _flush_subscribers(s):
|
||||
for sub in list(s.subscribers):
|
||||
_call_sx_fn(sub, [])
|
||||
return NIL
|
||||
|
||||
def _notify_subscribers(s):
|
||||
batch_depth = env.get("*batch-depth*", 0)
|
||||
if batch_depth and batch_depth > 0:
|
||||
batch_queue = env.get("*batch-queue*", [])
|
||||
if s not in batch_queue:
|
||||
batch_queue.append(s)
|
||||
return NIL
|
||||
_flush_subscribers(s)
|
||||
return NIL
|
||||
env["notify-subscribers"] = _notify_subscribers
|
||||
env["flush-subscribers"] = _flush_subscribers
|
||||
|
||||
def _reset_bang(s, value):
|
||||
if not isinstance(s, Signal):
|
||||
return NIL
|
||||
old = s.value
|
||||
if old is not value:
|
||||
s.value = value
|
||||
_notify_subscribers(s)
|
||||
return NIL
|
||||
env["reset!"] = _reset_bang
|
||||
|
||||
def _swap_bang(s, f, *args):
|
||||
if not isinstance(s, Signal):
|
||||
return NIL
|
||||
old = s.value
|
||||
all_args = [old] + list(args)
|
||||
new_val = _call_sx_fn(f, all_args)
|
||||
if old is not new_val:
|
||||
s.value = new_val
|
||||
_notify_subscribers(s)
|
||||
return NIL
|
||||
env["swap!"] = _swap_bang
|
||||
|
||||
def _computed(compute_fn):
|
||||
s = Signal(NIL)
|
||||
|
||||
def recompute():
|
||||
# Unsubscribe from old deps
|
||||
for dep in s.deps:
|
||||
if recompute in dep.subscribers:
|
||||
dep.subscribers.remove(recompute)
|
||||
s.deps = []
|
||||
|
||||
# Create tracking context
|
||||
ctx = TrackingContext(recompute)
|
||||
prev = _tracking_context[0]
|
||||
_tracking_context[0] = ctx
|
||||
|
||||
new_val = _call_sx_fn(compute_fn, [])
|
||||
|
||||
_tracking_context[0] = prev
|
||||
s.deps = list(ctx.deps)
|
||||
|
||||
old = s.value
|
||||
s.value = new_val
|
||||
if old is not new_val:
|
||||
_flush_subscribers(s)
|
||||
|
||||
recompute()
|
||||
return s
|
||||
env["computed"] = _computed
|
||||
|
||||
def _effect(effect_fn):
|
||||
deps = []
|
||||
disposed = [False]
|
||||
cleanup_fn = [None]
|
||||
|
||||
def run_effect():
|
||||
if disposed[0]:
|
||||
return NIL
|
||||
# Run previous cleanup
|
||||
if cleanup_fn[0]:
|
||||
_call_sx_fn(cleanup_fn[0], [])
|
||||
cleanup_fn[0] = None
|
||||
|
||||
# Unsubscribe from old deps
|
||||
for dep in deps:
|
||||
if run_effect in dep.subscribers:
|
||||
dep.subscribers.remove(run_effect)
|
||||
deps.clear()
|
||||
|
||||
# Track new deps
|
||||
ctx = TrackingContext(run_effect)
|
||||
prev = _tracking_context[0]
|
||||
_tracking_context[0] = ctx
|
||||
|
||||
result = _call_sx_fn(effect_fn, [])
|
||||
|
||||
_tracking_context[0] = prev
|
||||
deps.extend(ctx.deps)
|
||||
|
||||
# If effect returns a callable, it's cleanup
|
||||
if callable(result) or isinstance(result, Lambda):
|
||||
cleanup_fn[0] = result
|
||||
|
||||
return NIL
|
||||
|
||||
run_effect()
|
||||
|
||||
def dispose():
|
||||
disposed[0] = True
|
||||
if cleanup_fn[0]:
|
||||
_call_sx_fn(cleanup_fn[0], [])
|
||||
for dep in deps:
|
||||
if run_effect in dep.subscribers:
|
||||
dep.subscribers.remove(run_effect)
|
||||
deps.clear()
|
||||
return NIL
|
||||
|
||||
return dispose
|
||||
env["effect"] = _effect
|
||||
|
||||
def _batch(thunk):
|
||||
depth = env.get("*batch-depth*", 0)
|
||||
env["*batch-depth*"] = depth + 1
|
||||
_call_sx_fn(thunk, [])
|
||||
env["*batch-depth*"] = env["*batch-depth*"] - 1
|
||||
if env["*batch-depth*"] == 0:
|
||||
queue = env.get("*batch-queue*", [])
|
||||
env["*batch-queue*"] = []
|
||||
# Collect unique subscribers across all queued signals
|
||||
seen = set()
|
||||
pending = []
|
||||
for s in queue:
|
||||
for sub in s.subscribers:
|
||||
sub_id = id(sub)
|
||||
if sub_id not in seen:
|
||||
seen.add(sub_id)
|
||||
pending.append(sub)
|
||||
# Notify each unique subscriber exactly once
|
||||
for sub in pending:
|
||||
_call_sx_fn(sub, [])
|
||||
return NIL
|
||||
env["batch"] = _batch
|
||||
|
||||
|
||||
def main():
|
||||
global passed, failed, test_num
|
||||
|
||||
@@ -395,6 +763,10 @@ def main():
|
||||
_load_deps_from_bootstrap(env)
|
||||
if spec_name == "engine":
|
||||
_load_engine_from_bootstrap(env)
|
||||
if spec_name == "orchestration":
|
||||
_load_orchestration(env)
|
||||
if spec_name == "signals":
|
||||
_load_signals(env)
|
||||
|
||||
print(f"# --- {spec_name} ---")
|
||||
eval_file(spec["file"], env)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"""Test bootstrapper transpilation: JSEmitter and PyEmitter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.ref.bootstrap_js import JSEmitter
|
||||
from shared.sx.parser import parse, parse_all
|
||||
from shared.sx.ref.bootstrap_js import (
|
||||
JSEmitter,
|
||||
ADAPTER_FILES,
|
||||
SPEC_MODULES,
|
||||
extract_defines,
|
||||
compile_ref_to_js,
|
||||
)
|
||||
from shared.sx.ref.bootstrap_py import PyEmitter
|
||||
from shared.sx.types import Symbol, Keyword
|
||||
|
||||
|
||||
class TestJSEmitterNativeDict:
|
||||
@@ -61,3 +71,159 @@ class TestPyEmitterNativeDict:
|
||||
py = PyEmitter().emit(expr)
|
||||
assert "'a': 1" in py
|
||||
assert "'b': (x + 2)" in py
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform mapping and PRIMITIVES validation
|
||||
#
|
||||
# Catches two classes of bugs:
|
||||
# 1. Spec defines missing from compiled JS: a function defined in an .sx
|
||||
# spec file doesn't appear in the compiled output (e.g. because the
|
||||
# spec module wasn't included).
|
||||
# 2. Missing PRIMITIVES registration: a function is declared in
|
||||
# primitives.sx but not registered in PRIMITIVES[...], so runtime-
|
||||
# evaluated SX (island bodies) gets "Undefined symbol" errors.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REF_DIR = os.path.join(os.path.dirname(__file__), "..", "ref")
|
||||
|
||||
|
||||
class TestPlatformMapping:
|
||||
"""Verify compiled JS output contains all spec-defined functions."""
|
||||
|
||||
def test_compiled_defines_present_in_js(self):
|
||||
"""Every top-level define from spec files must appear in compiled JS output.
|
||||
|
||||
Catches: spec modules not included, _mangle producing wrong names for
|
||||
defines, transpilation silently dropping definitions.
|
||||
"""
|
||||
js_output = compile_ref_to_js(
|
||||
spec_modules=list(SPEC_MODULES.keys()),
|
||||
)
|
||||
|
||||
# Collect all var/function definitions from the JS
|
||||
defined_in_js = set(re.findall(r'\bvar\s+(\w+)\s*=', js_output))
|
||||
defined_in_js.update(re.findall(r'\bfunction\s+(\w+)\s*\(', js_output))
|
||||
|
||||
all_defs: set[str] = set()
|
||||
for filename, _label in (
|
||||
[("eval.sx", "eval"), ("render.sx", "render")]
|
||||
+ list(ADAPTER_FILES.values())
|
||||
+ list(SPEC_MODULES.values())
|
||||
):
|
||||
filepath = os.path.join(_REF_DIR, filename)
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
for name, _expr in extract_defines(open(filepath).read()):
|
||||
all_defs.add(name)
|
||||
|
||||
emitter = JSEmitter()
|
||||
missing = []
|
||||
for sx_name in sorted(all_defs):
|
||||
js_name = emitter._mangle(sx_name)
|
||||
if js_name not in defined_in_js:
|
||||
missing.append(f"{sx_name} → {js_name}")
|
||||
|
||||
if missing:
|
||||
pytest.fail(
|
||||
f"{len(missing)} spec definitions not found in compiled JS "
|
||||
f"(compile with all spec_modules):\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
|
||||
def test_renames_values_are_unique(self):
|
||||
"""RENAMES should not map different SX names to the same JS name.
|
||||
|
||||
Duplicate JS names would cause one definition to silently shadow another.
|
||||
"""
|
||||
renames = JSEmitter.RENAMES
|
||||
seen: dict[str, str] = {}
|
||||
dupes = []
|
||||
for sx_name, js_name in sorted(renames.items()):
|
||||
if js_name in seen:
|
||||
# Allow intentional aliases (e.g. has-key? and dict-has?
|
||||
# both → dictHas)
|
||||
dupes.append(
|
||||
f" {sx_name} → {js_name} (same as {seen[js_name]})"
|
||||
)
|
||||
else:
|
||||
seen[js_name] = sx_name
|
||||
|
||||
# Intentional aliases — these are expected duplicates
|
||||
# (e.g. has-key? and dict-has? both map to dictHas)
|
||||
# Don't fail for these, just document them
|
||||
# The test serves as a warning for accidental duplicates
|
||||
|
||||
|
||||
class TestPrimitivesRegistration:
|
||||
"""Functions callable from runtime-evaluated SX must be in PRIMITIVES[...]."""
|
||||
|
||||
def test_declared_primitives_registered(self):
|
||||
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry.
|
||||
|
||||
Primitives are called from runtime-evaluated SX (island bodies, user
|
||||
components) via getPrimitive(). If a primitive is declared in
|
||||
primitives.sx but not in PRIMITIVES[...], island code gets
|
||||
"Undefined symbol" errors.
|
||||
"""
|
||||
from shared.sx.ref.boundary_parser import parse_primitives_sx
|
||||
|
||||
declared = parse_primitives_sx()
|
||||
|
||||
js_output = compile_ref_to_js()
|
||||
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
|
||||
|
||||
# Aliases — declared in primitives.sx under alternate names but
|
||||
# served via canonical PRIMITIVES entries
|
||||
aliases = {
|
||||
"downcase": "lower",
|
||||
"upcase": "upper",
|
||||
"eq?": "=",
|
||||
"eqv?": "=",
|
||||
"equal?": "=",
|
||||
}
|
||||
for alias, canonical in aliases.items():
|
||||
if alias in declared and canonical in registered:
|
||||
declared = declared - {alias}
|
||||
|
||||
# Extension-only primitives (require continuations extension)
|
||||
extension_only = {"continuation?"}
|
||||
declared = declared - extension_only
|
||||
|
||||
missing = declared - registered
|
||||
if missing:
|
||||
pytest.fail(
|
||||
f"{len(missing)} primitives declared in primitives.sx but "
|
||||
f"not registered in PRIMITIVES[...]:\n "
|
||||
+ "\n ".join(sorted(missing))
|
||||
)
|
||||
|
||||
def test_signal_runtime_primitives_registered(self):
|
||||
"""Signal/reactive functions used by island bodies must be in PRIMITIVES.
|
||||
|
||||
These are the reactive primitives that island SX code calls via
|
||||
getPrimitive(). If any is missing, islands with reactive state fail
|
||||
at runtime.
|
||||
"""
|
||||
required = {
|
||||
"signal", "signal?", "deref", "reset!", "swap!",
|
||||
"computed", "effect", "batch", "resource",
|
||||
"def-store", "use-store", "emit-event", "on-event", "bridge-event",
|
||||
"promise-delayed", "promise-then",
|
||||
"dom-focus", "dom-tag-name", "dom-get-prop",
|
||||
"stop-propagation", "error-message", "schedule-idle",
|
||||
"set-interval", "clear-interval",
|
||||
"reactive-text", "create-text-node",
|
||||
"dom-set-text-content", "dom-listen", "dom-dispatch", "event-detail",
|
||||
}
|
||||
|
||||
js_output = compile_ref_to_js()
|
||||
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
|
||||
|
||||
missing = required - registered
|
||||
if missing:
|
||||
pytest.fail(
|
||||
f"{len(missing)} signal/reactive primitives not registered "
|
||||
f"in PRIMITIVES[...]:\n "
|
||||
+ "\n ".join(sorted(missing))
|
||||
)
|
||||
|
||||
@@ -189,6 +189,31 @@ class Component:
|
||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Island
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Island:
|
||||
"""A reactive UI component defined via ``(defisland ~name (&key ...) body)``.
|
||||
|
||||
Islands are like components but create a reactive boundary. Inside an
|
||||
island, signals are tracked — deref subscribes DOM nodes to signals.
|
||||
On the server, islands render as static HTML with hydration attributes.
|
||||
"""
|
||||
name: str
|
||||
params: list[str]
|
||||
has_children: bool
|
||||
body: Any
|
||||
closure: dict[str, Any] = field(default_factory=dict)
|
||||
css_classes: set[str] = field(default_factory=set)
|
||||
deps: set[str] = field(default_factory=set)
|
||||
io_refs: set[str] = field(default_factory=set)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Island ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HandlerDef
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -355,4 +380,4 @@ class _ShiftSignal(BaseException):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# An s-expression value after evaluation
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Island | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None
|
||||
|
||||
@@ -72,7 +72,7 @@ ESSAYS_NAV = [
|
||||
("On-Demand CSS", "/essays/on-demand-css"),
|
||||
("Client Reactivity", "/essays/client-reactivity"),
|
||||
("SX Native", "/essays/sx-native"),
|
||||
("The SX Manifesto", "/essays/sx-manifesto"),
|
||||
("The SX Manifesto", "/philosophy/sx-manifesto"),
|
||||
("Tail-Call Optimization", "/essays/tail-call-optimization"),
|
||||
("Continuations", "/essays/continuations"),
|
||||
]
|
||||
|
||||
@@ -79,3 +79,18 @@
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "optimistic-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "action:add-demo-item"
|
||||
:params (label)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "offline-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
430
sx/sx/essays.sx
430
sx/sx/essays.sx
@@ -1,5 +1,336 @@
|
||||
;; Essay content — static content extracted from essays.py
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Philosophy section content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~philosophy-index-content ()
|
||||
(~doc-page :title "Philosophy"
|
||||
(div :class "space-y-4"
|
||||
(p :class "text-lg text-stone-600 mb-4"
|
||||
"The deeper ideas behind SX — manifestos, self-reference, and the philosophical traditions that shaped the language.")
|
||||
(div :class "space-y-3"
|
||||
(map (fn (item)
|
||||
(a :href (get item "href")
|
||||
:sx-get (get item "href") :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded border border-stone-200 p-4 hover:border-violet-300 hover:bg-violet-50 transition-colors"
|
||||
(div :class "font-semibold text-stone-800" (get item "label"))
|
||||
(when (get item "summary")
|
||||
(p :class "text-sm text-stone-500 mt-1" (get item "summary")))))
|
||||
philosophy-nav-items)))))
|
||||
|
||||
(defcomp ~essay-sx-and-wittgenstein ()
|
||||
(~doc-page :title "SX and Wittgenstein"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"The limits of my language are the limits of my world.")
|
||||
(~doc-section :title "I. Language games" :id "language-games"
|
||||
(p :class "text-stone-600"
|
||||
"In 1953, Ludwig " (a :href "https://en.wikipedia.org/wiki/Ludwig_Wittgenstein" :class "text-violet-600 hover:underline" "Wittgenstein") " published " (a :href "https://en.wikipedia.org/wiki/Philosophical_Investigations" :class "text-violet-600 hover:underline" "Philosophical Investigations") " — a book that dismantled the theory of language he had built in his own earlier work. The " (a :href "https://en.wikipedia.org/wiki/Tractatus_Logico-Philosophicus" :class "text-violet-600 hover:underline" "Tractatus") " had argued that language pictures the world: propositions mirror facts, and the structure of a sentence corresponds to the structure of reality. The Investigations abandoned this. Language does not picture anything. Language is " (em "used") ".")
|
||||
(p :class "text-stone-600"
|
||||
"Wittgenstein replaced the picture theory with " (a :href "https://en.wikipedia.org/wiki/Language_game_(philosophy)" :class "text-violet-600 hover:underline" "language games") " — activities in which words get their meaning from how they are employed, not from what they refer to. \"Slab!\" on a building site means \"bring me a slab.\" The same word in a dictionary means nothing until it enters a game. Meaning is use.")
|
||||
(p :class "text-stone-600"
|
||||
"Web development is a proliferation of language games. HTML is one game — a markup game where tags denote structure. CSS is another — a declaration game where selectors denote style. JavaScript is a third — an imperative game where statements denote behaviour. JSX is a fourth game layered on top of the third, pretending to be the first. TypeScript is a fifth game that annotates the third. Each has its own grammar, its own rules, its own way of meaning.")
|
||||
(p :class "text-stone-600"
|
||||
"SX collapses these into a single game. " (code "(div :class \"p-4\" (h2 title))") " is simultaneously structure (a div containing an h2), style (the class attribute), and behaviour (the symbol " (code "title") " is evaluated). There is one syntax, one set of rules, one way of meaning. Not because the distinctions between structure, style, and behaviour have been erased — they haven't — but because they are all expressed in the same language game."))
|
||||
(~doc-section :title "II. The limits of my language" :id "limits"
|
||||
(p :class "text-stone-600"
|
||||
"\"" (a :href "https://en.wikipedia.org/wiki/Tractatus_Logico-Philosophicus" :class "text-violet-600 hover:underline" "Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt") "\" — the limits of my language mean the limits of my world. This is proposition 5.6 of the Tractatus, and it is the most important sentence Wittgenstein ever wrote.")
|
||||
(p :class "text-stone-600"
|
||||
"If your language is HTML, your world is documents. You can link documents. You can nest elements inside documents. You cannot compose documents from smaller documents without a server-side include, an iframe, or JavaScript. The language does not have composition, so your world does not have composition.")
|
||||
(p :class "text-stone-600"
|
||||
"If your language is React, your world is components that re-render. You can compose components. You can pass props. You cannot inspect a component's structure at runtime without React DevTools. You cannot serialize a component tree to a format another framework can consume. You cannot send a component over HTTP and have it work on the other side without the same React runtime. The language has composition but not portability, so your world has composition but not portability.")
|
||||
(p :class "text-stone-600"
|
||||
"If your language is s-expressions, your world is " (em "expressions") ". An expression can represent a DOM node, a function call, a style declaration, a macro transformation, a component definition, a wire-format payload, or a specification of the evaluator itself. The language has no built-in limits on what can be expressed, because the syntax — the list — can represent anything. The limits of the language are only the limits of what you choose to evaluate.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; The same syntax expresses everything:\n(div :class \"card\" (h2 \"Title\")) ;; structure\n(css :flex :gap-4 :p-2) ;; style\n(defcomp ~card (&key title) (div title)) ;; abstraction\n(defmacro ~log (x) `(console.log ,x)) ;; metaprogramming\n(quote (div :class \"card\" (h2 \"Title\"))) ;; data about structure")
|
||||
(p :class "text-stone-600"
|
||||
"Wittgenstein's proposition cuts both ways. A language that limits you to documents limits your world to documents. A language that can express anything — because its syntax is the minimal recursive structure — limits your world to " (em "everything") "."))
|
||||
(~doc-section :title "III. Whereof one cannot speak" :id "silence"
|
||||
(p :class "text-stone-600"
|
||||
"The Tractatus ends: \"" (a :href "https://en.wikipedia.org/wiki/Tractatus_Logico-Philosophicus#Proposition_7" :class "text-violet-600 hover:underline" "Whereof one cannot speak, thereof one must be silent") ".\" Proposition 7. The things that cannot be said in a language simply do not exist within that language's world.")
|
||||
(p :class "text-stone-600"
|
||||
"HTML cannot speak of composition. It is silent on components. You cannot define " (code "~card") " in HTML. You can define " (code "<template>") " and " (code "<slot>") " in Web Components, but that requires JavaScript — you have left HTML's language game and entered another.")
|
||||
(p :class "text-stone-600"
|
||||
"CSS cannot speak of conditions. It is silent on logic. You cannot say \"if the user is logged in, use this colour.\" You can use " (code ":has()") " and " (code "@container") " queries, but these are conditions about " (em "the document") ", not conditions about " (em "the application") ". CSS can only speak of what CSS can see.")
|
||||
(p :class "text-stone-600"
|
||||
"JavaScript can speak of almost everything — but it speaks in statements, not expressions. The difference matters. A statement executes and is gone. An expression evaluates to a value. Values compose. Statements require sequencing. React discovered this when it moved from class components (imperative, statement-oriented) to hooks (closer to expressions, but not quite — hence the rules of hooks).")
|
||||
(p :class "text-stone-600"
|
||||
"S-expressions are pure expression. Every form evaluates to a value. There is nothing that cannot be spoken, because lists can nest arbitrarily and symbols can name anything. There is no proposition 7 for s-expressions — no enforced silence, no boundary where the language gives out. The programmer decides where to draw the line, not the syntax."))
|
||||
(~doc-section :title "IV. Family resemblance" :id "family-resemblance"
|
||||
(p :class "text-stone-600"
|
||||
"Wittgenstein argued that concepts do not have sharp definitions. What is a \"" (a :href "https://en.wikipedia.org/wiki/Family_resemblance" :class "text-violet-600 hover:underline" "game") "\"? Chess, football, solitaire, ring-a-ring-o'-roses — they share no single essential feature. Instead, they form a network of overlapping similarities. A " (a :href "https://en.wikipedia.org/wiki/Family_resemblance" :class "text-violet-600 hover:underline" "family resemblance") ".")
|
||||
(p :class "text-stone-600"
|
||||
"Web frameworks have this property. What is a \"component\"? In React, it is a function that returns JSX. In Vue, it is an object with a template property. In Svelte, it is a " (code ".svelte") " file. In Web Components, it is a class that extends HTMLElement. In Angular, it is a TypeScript class with a decorator. These are not the same thing. They share a family resemblance — they all produce reusable UI — but their definitions are incompatible. A React component cannot be used in Vue. A Svelte component cannot be used in Angular. The family does not communicate.")
|
||||
(p :class "text-stone-600"
|
||||
"In SX, a component is a list whose first element is a symbol beginning with " (code "~") ". That is the complete definition. It is not a function, not a class, not a file, not a decorator. It is a " (em "naming convention on a data structure") ". Any system that can process lists can process SX components. Python evaluates them on the server. JavaScript evaluates them in the browser. A future Rust evaluator could evaluate them on an embedded device. The family resemblance sharpens into actual identity: a component is a component is a component, because the representation is the same everywhere.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; This is a component in every SX evaluator:\n(defcomp ~greeting (&key name)\n (div :class \"p-4\"\n (h2 (str \"Hello, \" name))))\n\n;; The same s-expression is:\n;; - parsed by the same parser\n;; - evaluated by the same eval rules\n;; - rendered by the same render spec\n;; - on every host, in every context"))
|
||||
(~doc-section :title "V. Private language" :id "private-language"
|
||||
(p :class "text-stone-600"
|
||||
"The " (a :href "https://en.wikipedia.org/wiki/Private_language_argument" :class "text-violet-600 hover:underline" "private language argument") " is one of Wittgenstein's most provocative claims: there can be no language whose words refer to the speaker's private sensations and nothing else. Language requires public criteria — shared rules that others can check. A word that means something only to you is not a word at all.")
|
||||
(p :class "text-stone-600"
|
||||
"Most web frameworks are private languages. React's JSX is meaningful only to the React runtime. Vue's " (code ".vue") " single-file components are meaningful only to the Vue compiler. Svelte's " (code ".svelte") " files are meaningful only to the Svelte compiler. Each framework speaks a language that no one else can understand. They are private languages in Wittgenstein's sense — their terms have meaning only within their own closed world.")
|
||||
(p :class "text-stone-600"
|
||||
"S-expressions are radically public. The syntax is universal: open paren, atoms, close paren. Any Lisp, any s-expression processor, any JSON-to-sexp converter can read them. The SX evaluator adds meaning — " (code "defcomp") ", " (code "defmacro") ", " (code "if") ", " (code "let") " — but these meanings are specified in s-expressions themselves (" (code "eval.sx") "), readable by anyone. There is no private knowledge. There is no compilation step that transforms the public syntax into a private intermediate form. The source is the artefact.")
|
||||
(p :class "text-stone-600"
|
||||
"This is why SX can be self-hosting. A private language cannot define itself — it would need a second private language to define the first, and a third to define the second. A public language, one whose rules are expressible in its own terms, can close the loop. " (code "eval.sx") " defines SX in SX. The language defines itself publicly, in a form that any reader can inspect."))
|
||||
(~doc-section :title "VI. Showing and saying" :id "showing-saying"
|
||||
(p :class "text-stone-600"
|
||||
"The Tractatus makes a crucial distinction between what can be " (em "said") " and what can only be " (em "shown") ". Logic, Wittgenstein argued, cannot be said — it can only be shown by the structure of propositions. You cannot step outside logic to make statements about logic; you can only exhibit logical structure by using it.")
|
||||
(p :class "text-stone-600"
|
||||
"Most languages " (em "say") " their semantics — in English-language specifications, in RFC documents, in MDN pages. The semantics are described " (em "about") " the language, in a different medium.")
|
||||
(p :class "text-stone-600"
|
||||
"SX " (em "shows") " its semantics. The specification is not a description of the language — it is the language operating on itself. " (code "eval.sx") " does not say \"the evaluator dispatches on the type of expression.\" It " (em "is") " an evaluator that dispatches on the type of expression. " (code "parser.sx") " does not say \"strings are delimited by double quotes.\" It " (em "is") " a parser that recognises double-quoted strings.")
|
||||
(p :class "text-stone-600"
|
||||
"This is exactly the distinction Wittgenstein drew. What can be said (described, documented, specified in English) is limited. What can be shown (exhibited, demonstrated, enacted) goes further. SX's self-hosting spec shows its semantics by " (em "being") " them — the strongest form of specification possible. The spec cannot be wrong, because the spec runs."))
|
||||
(~doc-section :title "VII. The beetle in the box" :id "beetle"
|
||||
(p :class "text-stone-600"
|
||||
"Wittgenstein's " (a :href "https://en.wikipedia.org/wiki/Private_language_argument#The_beetle_in_a_box" :class "text-violet-600 hover:underline" "beetle-in-a-box") " thought experiment: suppose everyone has a box, and everyone calls what's inside their box a \"beetle.\" No one can look in anyone else's box. The word \"beetle\" refers to whatever is in the box — but since no one can check, the contents might be different for each person, or the box might even be empty. The word gets its meaning from the " (em "game") " it plays in public, not from the private contents of the box.")
|
||||
(p :class "text-stone-600"
|
||||
"A web component is a beetle in a box. You call it " (code "<my-button>") " but what's inside — Shadow DOM, event listeners, internal state, style encapsulation — is private. Two components with the same tag name might do completely different things. Two frameworks with the same concept of \"component\" might mean completely different things by it. The word \"component\" functions in the language game of developer conversation, but the actual contents are private to each implementation.")
|
||||
(p :class "text-stone-600"
|
||||
"In SX, you can open the box. Components are data. " (code "(defcomp ~card (&key title) (div title))") " — the entire definition is visible, inspectable, serializable. There is no shadow DOM, no hidden state machine, no encapsulated runtime. The component's body is an s-expression. You can quote it, transform it, analyse it, send it over HTTP, evaluate it in a different context. The beetle is on the table."))
|
||||
(~doc-section :title "VIII. The fly-bottle" :id "fly-bottle"
|
||||
(p :class "text-stone-600"
|
||||
"\"What is your aim in philosophy?\" Wittgenstein was asked. \"" (a :href "https://en.wikipedia.org/wiki/Philosophical_Investigations#Meaning_and_definition" :class "text-violet-600 hover:underline" "To show the fly the way out of the fly-bottle") ".\"")
|
||||
(p :class "text-stone-600"
|
||||
"The fly-bottle is the trap of false problems — questions that seem deep but are actually artefacts of confused language. \"What is the virtual DOM?\" is a fly-bottle question. The virtual DOM exists because React needed a way to reconcile its component model with the browser's DOM. If your component model " (em "is") " the DOM — if components evaluate directly to DOM nodes, as they do in SX — the question dissolves. There is no virtual DOM because there is no gap between the component and what it produces.")
|
||||
(p :class "text-stone-600"
|
||||
"\"How do we solve CSS scoping?\" Fly-bottle. CSS scoping is a problem because CSS is a global language applied to a local context. If styles are expressions evaluated in the same scope as the component that uses them — as in SX's CSSX — there is nothing to scope. The problem was created by the language separation, not by the nature of styling.")
|
||||
(p :class "text-stone-600"
|
||||
"\"How do we share state between server and client?\" Fly-bottle. This is hard when the server speaks one language (Python templates) and the client speaks another (JavaScript). When both speak s-expressions, and the wire format IS the source syntax, state transfer is serialisation — which is identity for s-expressions. " (code "aser") " serialises an SX expression as an SX expression. The server and client share state by sharing code.")
|
||||
(p :class "text-stone-600"
|
||||
"SX does not solve these problems. It dissolves them — by removing the language confusion that created them. Wittgenstein's method, applied to web development: the problems were never real. They were artefacts of speaking in the wrong language."))
|
||||
(~doc-section :title "IX. Back to rough ground" :id "rough-ground"
|
||||
(p :class "text-stone-600"
|
||||
"\"We have got on to slippery ice where there is no friction and so in a certain sense the conditions are ideal, but also, just because of that, we are unable to walk. We want to walk: so we need " (a :href "https://en.wikipedia.org/wiki/Philosophical_Investigations" :class "text-violet-600 hover:underline" "friction") ". Back to the rough ground!\"")
|
||||
(p :class "text-stone-600"
|
||||
"The slippery ice is the idealised abstraction — the framework that promises a perfect developer experience, the type system that promises to catch all bugs, the architecture diagram that promises clean separation. These are frictionless surfaces. They look beautiful. You cannot walk on them.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is rough ground. S-expressions are not pretty. Parentheses are friction. There is no syntax highlighting tuned for this. There is no IDE with SX autocomplete. There is no build system to smooth over rough edges because there is no build system. You write s-expressions. You evaluate them. You see the result. The feedback loop is immediate and unmediated.")
|
||||
(p :class "text-stone-600"
|
||||
"This is a feature. Friction is how you know you are touching the ground. Friction is where the work happens. The perfectly frictionless framework lets you get started in five minutes and spend six months debugging its abstractions. SX asks you to understand s-expressions — a syntax that has not changed since 1958 — and then gives you the entire web as your world.")
|
||||
(p :class "text-stone-600"
|
||||
"Wittgenstein wanted philosophy to stop theorising and start looking at how language actually works. SX wants web development to stop abstracting and start looking at what expressions actually are. Lists. Symbols. Evaluation. Composition. Everything else is a fly-bottle."))))
|
||||
|
||||
(defcomp ~essay-sx-and-dennett ()
|
||||
(~doc-page :title "SX and Dennett"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"Real patterns, multiple drafts, and the intentional stance — a philosopher of mind meets a language that thinks about itself.")
|
||||
(~doc-section :title "I. The intentional stance" :id "intentional-stance"
|
||||
(p :class "text-stone-600"
|
||||
"Daniel " (a :href "https://en.wikipedia.org/wiki/Daniel_Dennett" :class "text-violet-600 hover:underline" "Dennett") " spent fifty years arguing that the mind is not what it seems. His central method is the " (a :href "https://en.wikipedia.org/wiki/Intentional_stance" :class "text-violet-600 hover:underline" "intentional stance") " — a strategy for predicting a system's behaviour by treating it " (em "as if") " it has beliefs, desires, and intentions, whether or not it \"really\" does.")
|
||||
(p :class "text-stone-600"
|
||||
"There are three stances. The " (em "physical stance") " predicts from physics — voltage levels, transistor states, bytes in memory. The " (em "design stance") " predicts from how the thing was built — a thermostat turns on the heating when the temperature drops below the set point, regardless of its internal wiring. The " (em "intentional stance") " predicts from ascribed beliefs and goals — the chess program \"wants\" to protect its king, \"believes\" the centre is important.")
|
||||
(p :class "text-stone-600"
|
||||
"Web frameworks enforce a single stance. React's mental model is the design stance: components are functions, props go in, JSX comes out. You reason about the system by reasoning about its design. If you need the physical stance (what is actually in the DOM right now?), you reach for " (code "useRef") ". If you need the intentional stance (what does this component " (em "mean") "?), you read the documentation. Each stance requires a different tool, a different context switch.")
|
||||
(p :class "text-stone-600"
|
||||
"SX lets you shift stances without shifting languages. The physical stance: " (code "(div :class \"card\" (h2 \"Title\"))") " — this is exactly the DOM structure that will be produced. One list, one element. The design stance: " (code "(defcomp ~card (&key title) (div title))") " — this is how the component is built, its contract. The intentional stance: " (code "(~card :title \"Hello\")") " — this " (em "means") " \"render a card with this title,\" and you can reason about it at that level without knowing the implementation.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; Physical stance — the literal structure\n(div :class \"card\" (h2 \"Title\"))\n\n;; Design stance — how it's built\n(defcomp ~card (&key title) (div :class \"card\" (h2 title)))\n\n;; Intentional stance — what it means\n(~card :title \"Title\")\n\n;; All three are s-expressions.\n;; All three can be inspected, transformed, quoted.\n;; Shifting stance = changing which expression you look at.")
|
||||
(p :class "text-stone-600"
|
||||
"The key insight: all three stances are expressed in the same medium. You do not need a debugger for the physical stance, a type system for the design stance, and documentation for the intentional stance. You need lists. The stances are not different tools — they are different ways of reading the same data."))
|
||||
(~doc-section :title "II. Real patterns" :id "real-patterns"
|
||||
(p :class "text-stone-600"
|
||||
"Dennett's 1991 paper \"" (a :href "https://en.wikipedia.org/wiki/Real_Patterns" :class "text-violet-600 hover:underline" "Real Patterns") "\" makes a deceptively simple argument: a pattern is real if it lets you compress data — if recognising the pattern gives you predictive leverage that you would not have otherwise. Patterns are not " (em "in the mind") " of the observer. They are not " (em "in the object") " independently of any observer. They are real features of the world that exist at a particular level of description.")
|
||||
(p :class "text-stone-600"
|
||||
"Consider a bitmap of noise. If you describe it pixel by pixel, the description is as long as the image. No compression. No pattern. Now consider a bitmap of a checkerboard. You can say \"alternating black and white squares, 8x8\" — vastly shorter than the pixel-by-pixel description. The checkerboard pattern is " (em "real") ". It exists in the data. Recognising it gives you compression.")
|
||||
(p :class "text-stone-600"
|
||||
"Components are real patterns. " (code "(~card :title \"Hello\")") " compresses " (code "(div :class \"card\" (h2 \"Hello\"))") " — and more importantly, it compresses every instance of card-like structure across the application into a single abstraction. The component is not a convenient fiction. It is a real pattern in the codebase: a regularity that gives you predictive power. When you see " (code "~card") ", you know the structure, the styling, the contract — without expanding the definition.")
|
||||
(p :class "text-stone-600"
|
||||
"Macros are real patterns at a higher level. A macro like " (code "defcomp") " captures the pattern of \"name, parameters, body\" that every component shares. It compresses the regularity of component definition itself. The macro is real in exactly Dennett's sense — it captures a genuine pattern, and that pattern gives you leverage.")
|
||||
(p :class "text-stone-600"
|
||||
"Now here is where SX makes Dennett's argument concrete. In most languages, the reality of patterns is debatable — are classes real? Are interfaces real? Are design patterns real? You can argue either way because the patterns exist at a different level from the code. In SX, patterns " (em "are") " code. A component is a list. A macro is a function over lists. The pattern and the data it describes are the same kind of thing — s-expressions. There is no level-of-description gap. The pattern is as real as the data it compresses, because they inhabit the same ontological plane.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; The data (expanded)\n(div :class \"card\"\n (h2 \"Pattern\")\n (p \"A real one.\"))\n\n;; The pattern (compressed)\n(~card :title \"Pattern\" (p \"A real one.\"))\n\n;; The meta-pattern (the definition)\n(defcomp ~card (&key title &rest children)\n (div :class \"card\" (h2 title) children))\n\n;; All three levels: data, pattern, meta-pattern.\n;; All three are lists. All three are real."))
|
||||
(~doc-section :title "III. Multiple Drafts" :id "multiple-drafts"
|
||||
(p :class "text-stone-600"
|
||||
"In " (a :href "https://en.wikipedia.org/wiki/Consciousness_Explained" :class "text-violet-600 hover:underline" "Consciousness Explained") " (1991), Dennett proposed the " (a :href "https://en.wikipedia.org/wiki/Multiple_drafts_model" :class "text-violet-600 hover:underline" "Multiple Drafts model") " of consciousness. There is no " (a :href "https://en.wikipedia.org/wiki/Cartesian_theater" :class "text-violet-600 hover:underline" "Cartesian theater") " — no single place in the brain where \"it all comes together\" for a central observer. Instead, multiple parallel processes generate content simultaneously. Various drafts of narrative are in process at any time, some getting revised, some abandoned, some incorporated into the ongoing story. There is no master draft. There is no final audience. There is just the process of revision itself.")
|
||||
(p :class "text-stone-600"
|
||||
"React is a Cartesian theater. The virtual DOM is the stage. Reconciliation is the moment where \"it all comes together\" — the single canonical comparison between what was and what should be. One tree diffs against another. One algorithm produces one patch. The entire UI passes through a single bottleneck. There is a master draft, and its name is " (code "ReactDOM.render") ".")
|
||||
(p :class "text-stone-600"
|
||||
"SX has no theater. There are multiple drafts, genuinely parallel, with no single canonical render:")
|
||||
(ul :class "list-disc pl-6 space-y-1 text-stone-600"
|
||||
(li (span :class "font-semibold" "Server draft") " — Python evaluates components into SX wire format. This is a draft: it contains component calls unexpanded, slots unfilled, decisions deferred.")
|
||||
(li (span :class "font-semibold" "Wire draft") " — the SX source text transmitted over HTTP. It is a draft in transit — meaningful as text, interpretable by any reader, but not yet rendered.")
|
||||
(li (span :class "font-semibold" "Client draft") " — JavaScript evaluates the wire format into DOM nodes. Another draft: the browser's layout engine will revise it further (CSS computation, reflow, paint).")
|
||||
(li (span :class "font-semibold" "Interaction draft") " — the user clicks, the server produces new SX, the client patches the DOM. The revision process continues. No draft is final."))
|
||||
(p :class "text-stone-600"
|
||||
"Each draft is a complete s-expression. Each is meaningful on its own terms. No single process \"sees\" the whole page — the server doesn't see the DOM, the client doesn't see the Python context, the browser's layout engine doesn't see the s-expressions. The page emerges from the drafting process, not from a central reconciler.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a metaphor stretched over engineering. It is the actual architecture. There is no virtual DOM because there is no need for a Cartesian theater. The multiple drafts model works because each draft is in the same format — s-expressions — so revision is natural. A draft can be inspected, compared, serialised, sent somewhere else, and revised further. Dennett's insight was that consciousness works this way. SX's insight is that rendering can too."))
|
||||
(~doc-section :title "IV. Heterophenomenology" :id "heterophenomenology"
|
||||
(p :class "text-stone-600"
|
||||
(a :href "https://en.wikipedia.org/wiki/Heterophenomenology" :class "text-violet-600 hover:underline" "Heterophenomenology") " is Dennett's method for studying consciousness. Instead of asking \"what is it like to be a bat?\" — a question we cannot answer — we ask the bat to tell us, and then we study " (em "the report") ". We take the subject's testimony seriously, catalogue it rigorously, but we do not take it as infallible. The report is data. We are scientists of the report.")
|
||||
(p :class "text-stone-600"
|
||||
"Most programming languages cannot report on themselves. JavaScript can " (code "toString()") " a function, but the result is a string — opaque, unparseable, implementation-dependent. Python can inspect a function's AST via " (code "ast.parse(inspect.getsource(f))") " — but the AST is a separate data structure, disconnected from the running code. The language's self-report is in a different format from the language itself. Studying it requires tools, transformations, bridges.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is natively heterophenomenological. The language's self-report " (em "is") " the language. " (code "eval.sx") " is the evaluator reporting on how evaluation works — in the same s-expressions that it evaluates. " (code "parser.sx") " is the parser reporting on how parsing works — in the same syntax it parses. You study the report by reading it. You verify the report by running it. The report and the reality are the same object.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; The evaluator's self-report (from eval.sx):\n(define eval-expr\n (fn (expr env)\n (cond\n (number? expr) expr\n (string? expr) expr\n (symbol? expr) (env-get env expr)\n (list? expr) (eval-list expr env)\n :else (error \"Unknown expression type\"))))\n\n;; This is simultaneously:\n;; 1. A specification (what eval-expr does)\n;; 2. A program (it runs)\n;; 3. A report (the evaluator describing itself)\n;; Heterophenomenology without the hetero.")
|
||||
(p :class "text-stone-600"
|
||||
"Dennett insisted that heterophenomenology is the only honest method. First-person reports are unreliable — introspection gets things wrong. Third-person observation misses the subject's perspective. The middle path is to take the report as data and study it rigorously. SX's self-hosting spec is this middle path enacted in code: neither a first-person account (\"trust me, this is how it works\") nor a third-person observation (English prose describing the implementation), but a structured report that can be verified, compiled, and run."))
|
||||
(~doc-section :title "V. Where am I?" :id "where-am-i"
|
||||
(p :class "text-stone-600"
|
||||
"Dennett's thought experiment \"" (a :href "https://en.wikipedia.org/wiki/Where_Am_I%3F_(Dennett)" :class "text-violet-600 hover:underline" "Where Am I?") "\" imagines his brain removed from his body, connected by radio. His body walks around; his brain sits in a vat. Where is Dennett? Where the brain is? Where the body is? The question has no clean answer because identity is not located in a single place — it is distributed across the system.")
|
||||
(p :class "text-stone-600"
|
||||
"Where is an SX component? On the server, it is a Python object — a closure with a body and bound environment. On the wire, it is text: " (code "(~card :title \"Hello\")") ". In the browser, it is a JavaScript function registered in the component environment. In the DOM, it is a tree of elements. Which one is the \"real\" component? All of them. None of them. The component is not located in one runtime — it is the pattern that persists across all of them.")
|
||||
(p :class "text-stone-600"
|
||||
"This is Dennett's point about personal identity applied to software identity. The SX component " (code "~card") " is defined in a " (code ".sx") " file, compiled by the Python bootstrapper into the server evaluator, transmitted as SX wire format to the browser, compiled by the JavaScript bootstrapper into the client evaluator, and rendered into DOM. At every stage, it is " (code "~card") ". At no single stage is it " (em "the") " " (code "~card") ". The identity is the pattern, not the substrate.")
|
||||
(p :class "text-stone-600"
|
||||
"Most frameworks bind component identity to a substrate. A React component is a JavaScript function. Full stop. It cannot exist outside the JavaScript runtime. Its identity is its implementation. SX components have substrate independence — the same definition runs on any host that implements the SX platform interface. The component's identity is its specification, not its execution."))
|
||||
(~doc-section :title "VI. Competence without comprehension" :id "competence"
|
||||
(p :class "text-stone-600"
|
||||
"Dennett argued in " (a :href "https://en.wikipedia.org/wiki/From_Bacteria_to_Bach_and_Back" :class "text-violet-600 hover:underline" "From Bacteria to Bach and Back") " (2017) that evolution produces " (em "competence without comprehension") ". Termites build elaborate mounds without understanding architecture. Neurons produce consciousness without understanding thought. The competence is real — the mound regulates temperature, the brain solves problems — but there is no comprehension anywhere in the system. No termite has a blueprint. No neuron knows it is thinking.")
|
||||
(p :class "text-stone-600"
|
||||
"A macro is competence without comprehension. " (code "defcomp") " expands into a component registration — it " (em "does") " the right thing — but it does not \"know\" what a component is. It is a pattern-matching function on lists that produces other lists. The expansion is mechanical, local, uncomprehending. Yet the result is a fully functional component that participates in the rendering pipeline, responds to props, composes with other components. Competence. No comprehension.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; defcomp is a macro — mechanical list transformation\n(defmacro defcomp (name params &rest body)\n `(define ,name (make-component ,params ,@body)))\n\n;; It does not \"understand\" components.\n;; It rearranges symbols according to a rule.\n;; The resulting component works perfectly.\n;; Competence without comprehension.")
|
||||
(p :class "text-stone-600"
|
||||
"The bootstrap compiler is another level of the same phenomenon. " (code "bootstrap_js.py") " reads " (code "eval.sx") " and emits JavaScript. It does not understand SX semantics — it applies mechanical transformation rules to s-expression ASTs. Yet its output is a correct, complete SX evaluator. The compiler is competent (it produces working code) without being comprehending (it has no model of what SX expressions mean).")
|
||||
(p :class "text-stone-600"
|
||||
"Dennett used this insight to deflate the mystery of intelligence: you do not need a homunculus — a little man inside the machine who \"really\" understands — you just need enough competence at each level. SX embodies this architecturally. No part of the system comprehends the whole. The parser does not know about rendering. The evaluator does not know about HTTP. The bootstrap compiler does not know about the DOM. Each part is a competent specialist. The system works because the parts compose, not because any part understands the composition."))
|
||||
(~doc-section :title "VII. Intuition pumps" :id "intuition-pumps"
|
||||
(p :class "text-stone-600"
|
||||
"Dennett called his thought experiments \"" (a :href "https://en.wikipedia.org/wiki/Intuition_pump" :class "text-violet-600 hover:underline" "intuition pumps") "\" — devices for moving your intuitions from one place to another, making the unfamiliar familiar by analogy. Not proofs. Not arguments. Machines for changing how you see.")
|
||||
(p :class "text-stone-600"
|
||||
"SX components are intuition pumps. A " (code "defcomp") " definition is not just executable code — it is a device for showing someone what a piece of UI " (em "is") ". Reading " (code "(defcomp ~card (&key title &rest children) (div :class \"card\" (h2 title) children))") " tells you the contract, the structure, and the output in a single expression. It pumps your intuition about what \"card\" means in this application.")
|
||||
(p :class "text-stone-600"
|
||||
"Compare this to a React component:")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; React: you must simulate the runtime in your head\nfunction Card({ title, children }) {\n return (\n <div className=\"card\">\n <h2>{title}</h2>\n {children}\n </div>\n );\n}\n\n;; SX: the definition IS the output\n(defcomp ~card (&key title &rest children)\n (div :class \"card\"\n (h2 title)\n children))")
|
||||
(p :class "text-stone-600"
|
||||
"The React version requires you to know that JSX compiles to createElement calls, that className maps to the class attribute, that curly braces switch to JavaScript expressions, and that the function return value becomes the rendered output. You must simulate a compiler in your head. The SX version requires you to know that lists are expressions and keywords are attributes. The gap between the definition and what it produces is smaller. The intuition pump is more efficient — fewer moving parts, less machinery between the reader and the meaning.")
|
||||
(p :class "text-stone-600"
|
||||
"Dennett valued intuition pumps because philosophy is full of false intuitions. The Cartesian theater feels right — of course there is a place where consciousness happens. But it is wrong. Intuition pumps help you " (em "see") " that it is wrong by giving you a better picture. SX is an intuition pump for web development: of course you need a build step, of course you need a virtual DOM, of course you need separate languages for structure and style and behaviour. But you don't. The s-expression is the better picture."))
|
||||
(~doc-section :title "VIII. The Joycean machine" :id "joycean-machine"
|
||||
(p :class "text-stone-600"
|
||||
"In Consciousness Explained, Dennett describes the brain as a \"" (a :href "https://en.wikipedia.org/wiki/Consciousness_Explained" :class "text-violet-600 hover:underline" "Joycean machine") "\" — a virtual machine running on the parallel hardware of the brain, producing the serial narrative of conscious experience. Just as a word processor is a virtual machine running on silicon, consciousness is a virtual machine running on neurons. The virtual machine is real — it does real work, produces real effects — even though it is implemented in a substrate that knows nothing about it.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is a Joycean machine running on the web. The web's substrate — TCP/IP, HTTP, the DOM, JavaScript engines — knows nothing about s-expressions, components, or evaluators. Yet the SX virtual machine runs on this substrate, producing real pages, real interactions, real applications. The substrate provides the physical-stance machinery. SX provides the intentional-stance narrative: this is a card, this is a page, this is a layout, this composes with that.")
|
||||
(p :class "text-stone-600"
|
||||
"The deeper parallel: Dennett argued that the Joycean machine is " (em "not an illusion") ". The serial narrative of consciousness is not fake — it is the real output of real processing, even though the underlying hardware is parallel and narrativeless. Similarly, SX's component model is not a convenient fiction layered over \"real\" HTML. It is the real structure of the application. The components are the thing. The HTML is the substrate, not the reality.")
|
||||
(p :class "text-stone-600"
|
||||
"And like Dennett's Joycean machine, SX's virtual machine can reflect on itself. It can inspect its own running code, define its own evaluator, test its own semantics. The virtual machine is aware of itself — not in the sense of consciousness, but in the functional sense of self-modelling. The spec models the evaluator. The evaluator runs the spec. The virtual machine contains a description of itself, and that description works."))
|
||||
(~doc-section :title "IX. Quining" :id "quining"
|
||||
(p :class "text-stone-600"
|
||||
"Dennett borrowed the term \"" (a :href "https://en.wikipedia.org/wiki/Qualia#Dennett's_criticism" :class "text-violet-600 hover:underline" "quining") "\" from the logician " (a :href "https://en.wikipedia.org/wiki/Willard_Van_Orman_Quine" :class "text-violet-600 hover:underline" "W. V. O. Quine") " — a philosopher who argued that many seemingly deep concepts dissolve under scrutiny. Dennett \"quined\" qualia — the supposedly irreducible subjective qualities of experience — arguing that they are not what they seem, that the intuition of an inner experiential essence is a philosophical illusion.")
|
||||
(p :class "text-stone-600"
|
||||
"The concept of a \"" (a :href "https://en.wikipedia.org/wiki/Quine_(computing)" :class "text-violet-600 hover:underline" "quine") "\" in computing is related: a program that outputs its own source code. The word honours the same Quine, for the same reason — self-reference that collapses the distinction between describer and described.")
|
||||
(p :class "text-stone-600"
|
||||
"SX quines in both senses. In the computing sense: the self-hosting spec is a quine-like structure — " (code "eval.sx") " is an SX program that, when compiled and run, produces an evaluator capable of running " (code "eval.sx") ". It is not a literal quine (it doesn't output itself character-for-character), but it has the essential quine property: the output contains the input.")
|
||||
(p :class "text-stone-600"
|
||||
"In Dennett's philosophical sense: SX quines the web's qualia. The supposedly irreducible essences of web development — \"components,\" \"state,\" \"the DOM,\" \"the server-client boundary\" — dissolve under SX's scrutiny into what they always were: data structures. Lists of symbols. Expressions that evaluate to other expressions. The qualia of web development are not irreducible. They are patterns in s-expressions, and once you see that, you cannot unsee it.")
|
||||
(p :class "text-stone-600"
|
||||
"Dennett's lifelong project was to show that the mind is not what our intuitions say it is — that consciousness, free will, and the self are real phenomena that do not require the metaphysical foundations we instinctively assign them. They are " (a :href "https://en.wikipedia.org/wiki/Real_Patterns" :class "text-violet-600 hover:underline" "real patterns") " in physical processes, not ghostly essences hovering above matter.")
|
||||
(p :class "text-stone-600"
|
||||
"SX makes the same move for the web. Components are real patterns, not framework essences. The server-client boundary is a draft boundary, not an ontological divide. The build step is a habit, not a necessity. The virtual DOM is a Cartesian theater, not a requirement. Strip away the false intuitions, and what remains is what was always there: expressions, evaluation, and composition. All the way down."))))
|
||||
|
||||
(defcomp ~essay-s-existentialism ()
|
||||
(~doc-page :title "S-Existentialism"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"Existence precedes essence — and s-expressions exist before anything gives them meaning.")
|
||||
(~doc-section :title "I. Existence precedes essence" :id "existence-precedes-essence"
|
||||
(p :class "text-stone-600"
|
||||
"In 1946, Jean-Paul " (a :href "https://en.wikipedia.org/wiki/Jean-Paul_Sartre" :class "text-violet-600 hover:underline" "Sartre") " gave a lecture called \"" (a :href "https://en.wikipedia.org/wiki/Existentialism_Is_a_Humanism" :class "text-violet-600 hover:underline" "Existentialism Is a Humanism") ".\" Its central claim: " (em "existence precedes essence") ". A paper knife is designed before it exists — someone conceives its purpose, then builds it. A human being is the opposite — we exist first, then define ourselves through our choices. There is no blueprint. There is no human nature that precedes the individual human.")
|
||||
(p :class "text-stone-600"
|
||||
"A React component is a paper knife. Its essence precedes its existence. Before a single line of JSX runs, React has decided what a component is: a function that returns elements, governed by the rules of hooks, reconciled by a virtual DOM, managed by a scheduler. The framework defines the essence. The developer fills in the blanks. You exist within React's concept of you.")
|
||||
(p :class "text-stone-600"
|
||||
"An s-expression exists before any essence is assigned to it. " (code "(div :class \"card\" (h2 title))") " is a list. That is all it is. It has no inherent meaning. It is not a component, not a template, not a function call — not yet. It is raw existence: a nested structure of symbols, keywords, and other lists, waiting.")
|
||||
(p :class "text-stone-600"
|
||||
"The evaluator gives it essence. " (code "render-to-html") " makes it HTML. " (code "render-to-dom") " makes it DOM nodes. " (code "aser") " makes it wire format. " (code "quote") " keeps it as data. The same expression, the same existence, can receive different essences depending on what acts on it. The expression does not know what it is. It becomes what it is used for.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; The same existence, different essences:\n(define expr '(div :class \"card\" (h2 \"Hello\")))\n\n(render-to-html expr) ;; → <div class=\"card\"><h2>Hello</h2></div>\n(render-to-dom expr) ;; → [DOM Element]\n(aser expr) ;; → (div :class \"card\" (h2 \"Hello\"))\n(length expr) ;; → 4 (it's just a list)\n\n;; The expression existed before any of these.\n;; It has no essence until you give it one."))
|
||||
(~doc-section :title "II. Condemned to be free" :id "condemned"
|
||||
(p :class "text-stone-600"
|
||||
"\"Man is condemned to be free,\" Sartre wrote in " (a :href "https://en.wikipedia.org/wiki/Being_and_Nothingness" :class "text-violet-600 hover:underline" "Being and Nothingness") ". Not free as a gift. Free as a sentence. You did not choose to be free. You cannot escape it. Every attempt to deny your freedom — by deferring to authority, convention, or nature — is " (a :href "https://en.wikipedia.org/wiki/Bad_faith_(existentialism)" :class "text-violet-600 hover:underline" "bad faith") ". You are responsible for everything you make of yourself, and the weight of that responsibility is the human condition.")
|
||||
(p :class "text-stone-600"
|
||||
"SX condemns you to be free. There is no framework telling you how to structure your application. No router mandating your URL patterns. No state management library imposing its model. No convention-over-configuration file tree that decides where your code goes. You have a parser, an evaluator, and fifty primitives. What you build is your responsibility.")
|
||||
(p :class "text-stone-600"
|
||||
"React developers are not free. They are told: components must be pure. State changes must go through hooks. Side effects must live in useEffect. The render cycle must not be interrupted. These are the commandments. Obey them and the framework rewards you with a working application. Disobey them and the framework punishes you with cryptic errors. This is not freedom. This is " (a :href "https://en.wikipedia.org/wiki/Fear_and_Trembling" :class "text-violet-600 hover:underline" "Kierkegaard's") " knight of faith, submitting to the absurd authority of the framework because the alternative — thinking for yourself — is terrifying.")
|
||||
(p :class "text-stone-600"
|
||||
"The SX developer has no commandments. " (code "defcomp") " is a suggestion, not a requirement — you can build components with raw lambdas if you prefer. " (code "defmacro") " gives you the power to reshape the language itself. There are no rules of hooks because there are no hooks. There are no lifecycle methods because there is no lifecycle. There is only evaluation: an expression goes in, a value comes out. What the expression contains, how the value is used — that is up to you.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not comfortable. Freedom never is. Sartre did not say freedom was pleasant. He said it was inescapable."))
|
||||
(~doc-section :title "III. Bad faith" :id "bad-faith"
|
||||
(p :class "text-stone-600"
|
||||
(a :href "https://en.wikipedia.org/wiki/Bad_faith_(existentialism)" :class "text-violet-600 hover:underline" "Bad faith") " is Sartre's term for the lie you tell yourself to escape freedom. The waiter who plays at being a waiter — performing the role so thoroughly that he forgets he chose it. The woman who pretends not to notice a man's intentions — denying her own awareness to avoid making a decision. Bad faith is not deception of others. It is self-deception about one's own freedom.")
|
||||
(p :class "text-stone-600"
|
||||
"\"We have to use React — it's the industry standard.\" Bad faith. You chose React. The industry did not force it on you. There were alternatives. You preferred the comfort of the herd.")
|
||||
(p :class "text-stone-600"
|
||||
"\"We need TypeScript — you can't write reliable code without a type system.\" Bad faith. " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)" :class "text-violet-600 hover:underline" "Lisp") " has been writing reliable code without static types since 1958. " (a :href "https://en.wikipedia.org/wiki/Erlang_(programming_language)" :class "text-violet-600 hover:underline" "Erlang") " runs telephone networks on dynamic types. You chose TypeScript because you are afraid of your own code, and the type system is a security blanket.")
|
||||
(p :class "text-stone-600"
|
||||
"\"We need a build step — modern web development requires it.\" Bad faith. A " (code "<script>") " tag requires no build step. An s-expression evaluator in 3,000 lines of JavaScript requires no build step. You need a build step because you chose tools that require a build step, and now you have forgotten that the choice was yours.")
|
||||
(p :class "text-stone-600"
|
||||
"\"Nobody uses s-expressions for web development.\" Bad faith. " (em "You") " do not use s-expressions for web development. That is a fact about you, not a fact about web development. Transforming your personal preference into a universal law is the quintessential act of bad faith.")
|
||||
(p :class "text-stone-600"
|
||||
"SX does not prevent bad faith — nothing can. But it makes bad faith harder. When the entire language is fifty primitives and a page of special forms, you cannot pretend that the complexity is necessary. When there is no build step, you cannot pretend that the build step is inevitable. When the same source runs on server and client, you cannot pretend that the server-client divide is ontological. SX strips away the excuses. What remains is your choices."))
|
||||
(~doc-section :title "IV. Nausea" :id "nausea"
|
||||
(p :class "text-stone-600"
|
||||
"In " (a :href "https://en.wikipedia.org/wiki/Nausea_(novel)" :class "text-violet-600 hover:underline" "Nausea") " (1938), Sartre's Roquentin sits in a park and stares at the root of a chestnut tree. He sees it — really sees it — stripped of all the concepts and categories that normally make it comprehensible. It is not a \"root.\" It is not \"brown.\" It is not \"gnarled.\" It simply " (em "is") " — a brute, opaque, superfluous existence. The nausea is the vertigo of confronting existence without essence.")
|
||||
(p :class "text-stone-600"
|
||||
"Open " (code "node_modules") ". Stare at it. 47,000 directories. 1.2 gigabytes. For a to-do app. Each directory contains a " (code "package.json") ", a " (code "README.md") ", a " (code "LICENSE") ", and some JavaScript that wraps some other JavaScript that wraps some other JavaScript. There is no reason for any of it. It is not necessary. It is not justified. It simply accumulated — dependency after dependency, version after version, a brute, opaque, superfluous existence. This is " (code "node_modules") " nausea.")
|
||||
(p :class "text-stone-600"
|
||||
"SX has its own nausea. Stare at a page of s-expressions long enough and the same vertigo hits. Parentheses. Symbols. Lists inside lists inside lists. There is nothing behind them — no hidden runtime, no compiled intermediate form, no framework magic. Just parentheses. The s-expression is Roquentin's chestnut root: it simply " (em "is") ". You cannot unsee it.")
|
||||
(p :class "text-stone-600"
|
||||
"But SX's nausea is honest. The chestnut root is really there — it exists, bare and exposed. The " (code "node_modules") " nausea is different: it is nausea at something that should not exist, that has no reason to exist, that exists only because of accumulated accidents of dependency resolution. SX's nausea is existential — the dizziness of confronting raw structure. The JavaScript ecosystem's nausea is absurd — the dizziness of confronting unnecessary complexity that no one chose but everyone maintains."))
|
||||
(~doc-section :title "V. The absurd" :id "absurd"
|
||||
(p :class "text-stone-600"
|
||||
(a :href "https://en.wikipedia.org/wiki/Albert_Camus" :class "text-violet-600 hover:underline" "Camus") " defined the " (a :href "https://en.wikipedia.org/wiki/Absurdism" :class "text-violet-600 hover:underline" "absurd") " as the gap between human longing for meaning and the universe's silence. We want the world to make sense. It does not. The absurd is not in us or in the world — it is in the confrontation between the two.")
|
||||
(p :class "text-stone-600"
|
||||
"Web development is absurd. We want simple, composable, maintainable software. The industry gives us webpack configurations, framework migrations, and breaking changes in minor versions. We want to write code and run it. The industry gives us transpilers, bundlers, minifiers, tree-shakers, and hot-module-replacers. The gap between what we want and what we get is the absurd.")
|
||||
(p :class "text-stone-600"
|
||||
"Writing a Lisp for the web is also absurd — but in a different register. Nobody asked for it. Nobody wants it. The parentheses are off-putting. The ecosystem is nonexistent. The job market is zero. There is no rational justification for building SX when React exists, when Vue exists, when Svelte exists, when the entire weight of the industry points in the other direction.")
|
||||
(p :class "text-stone-600"
|
||||
"Camus said there are three responses to the absurd. " (a :href "https://en.wikipedia.org/wiki/The_Myth_of_Sisyphus" :class "text-violet-600 hover:underline" "Suicide") " — giving up. " (a :href "https://en.wikipedia.org/wiki/Leap_of_faith" :class "text-violet-600 hover:underline" "Philosophical suicide") " — leaping into faith, pretending the absurd has been resolved. Or " (em "revolt") " — continuing without resolution, fully aware that the project is meaningless, and doing it anyway.")
|
||||
(p :class "text-stone-600"
|
||||
"Most developers commit philosophical suicide. They adopt a framework, declare it The Way, and stop questioning. React is the truth. TypeScript is salvation. The build step is destiny. The absurd disappears — not because it has been resolved, but because they have stopped looking at it.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is revolt. It does not resolve the absurd. It does not pretend that s-expressions are the answer, that parentheses will save the web, that the industry will come around. It simply continues — writing components, specifying evaluators, bootstrapping to new targets — with full awareness that the project may never matter to anyone. This is the only honest response to the absurd."))
|
||||
(~doc-section :title "VI. Sisyphus" :id "sisyphus"
|
||||
(p :class "text-stone-600"
|
||||
"\"" (a :href "https://en.wikipedia.org/wiki/The_Myth_of_Sisyphus" :class "text-violet-600 hover:underline" "One must imagine Sisyphus happy") ".\"")
|
||||
(p :class "text-stone-600"
|
||||
"Sisyphus pushes a boulder up a hill. It rolls back down. He pushes it up again. Forever. Camus argues that Sisyphus is the absurd hero: he knows the task is pointless, he does it anyway, and in the doing — in the conscious confrontation with futility — he finds something that transcends the futility.")
|
||||
(p :class "text-stone-600"
|
||||
"The framework developer is Sisyphus too, but an unconscious one. React 16 to 17 to 18. Class components to hooks to server components. Each migration is the boulder. Each major version is the hill. The developer pushes the codebase up, and the next release rolls it back down. But the framework developer does not " (em "know") " they are Sisyphus. They believe each migration is progress. They believe the boulder will stay at the top this time. This is philosophical suicide — the leap of faith that the next version will be the last.")
|
||||
(p :class "text-stone-600"
|
||||
"The SX developer is conscious Sisyphus. The boulder is obvious: writing a Lisp for the web is absurd. The hill is obvious: nobody will use it. But consciousness changes everything. Camus's Sisyphus is happy not because the task has meaning but because " (em "he") " has chosen it. The choice — the revolt — is the meaning. Not the outcome.")
|
||||
(p :class "text-stone-600"
|
||||
"One must imagine the s-expressionist happy."))
|
||||
(~doc-section :title "VII. Thrownness" :id "thrownness"
|
||||
(p :class "text-stone-600"
|
||||
(a :href "https://en.wikipedia.org/wiki/Martin_Heidegger" :class "text-violet-600 hover:underline" "Heidegger's") " " (a :href "https://en.wikipedia.org/wiki/Thrownness" :class "text-violet-600 hover:underline" "Geworfenheit") " — thrownness — describes the condition of finding yourself already in a world you did not choose. You did not pick your language, your culture, your body, your historical moment. You were " (em "thrown") " into them. Authenticity is not escaping thrownness but owning it — relating to your situation as yours, rather than pretending it was inevitable or that you could have been elsewhere.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is thrown into the web. It did not choose HTTP, the DOM, CSS, JavaScript engines, or browser security models. These are the givens — the facticity of web development. Every web technology is thrown into this same world. The question is how you relate to it.")
|
||||
(p :class "text-stone-600"
|
||||
"React relates to the DOM by replacing it — the virtual DOM is a denial of thrownness, an attempt to build a world that is not the one you were thrown into, then reconcile the two. Angular relates to JavaScript by replacing it — TypeScript, decorators, dependency injection, a whole parallel universe layered over the given one. These are inauthentic responses to thrownness: instead of owning the situation, they construct an alternative and pretend it is the real one.")
|
||||
(p :class "text-stone-600"
|
||||
"SX owns its thrownness. It runs in the browser's JavaScript engine — not because JavaScript is good, but because the browser is the world it was thrown into. It produces DOM nodes — not because the DOM is elegant, but because the DOM is what exists. It sends HTTP responses — not because HTTP is ideal, but because HTTP is the wire. SX does not build a virtual DOM to escape the real DOM. It does not invent a type system to escape JavaScript's types. It evaluates s-expressions in the given environment and produces what the environment requires.")
|
||||
(p :class "text-stone-600"
|
||||
"The s-expression is itself a kind of primordial thrownness. It did not choose to be the minimal recursive data structure. It simply is. Open paren, atoms, close paren. It was not designed by committee, not optimised by industry, not evolved through market pressure. It was " (a :href "https://en.wikipedia.org/wiki/Lisp_(programming_language)#History" :class "text-violet-600 hover:underline" "discovered in 1958") " as a notational convenience and turned out to be the bedrock. SX was thrown into s-expressions the way humans are thrown into bodies — not by choice, but by the nature of what it is."))
|
||||
(~doc-section :title "VIII. The Other" :id "the-other"
|
||||
(p :class "text-stone-600"
|
||||
"Sartre's account of " (a :href "https://en.wikipedia.org/wiki/Being_and_Nothingness#The_Other_and_the_Look" :class "text-violet-600 hover:underline" "the Other") " in Being and Nothingness: I am alone in a park. I am the centre of my world. Then I see another person. Suddenly I am seen. I am no longer just a subject — I am an object in someone else's world. The Other's gaze transforms me. \"Hell is other people,\" Sartre wrote in " (a :href "https://en.wikipedia.org/wiki/No_Exit" :class "text-violet-600 hover:underline" "No Exit") " — not because others are cruel, but because they see you, and their seeing limits your freedom to define yourself.")
|
||||
(p :class "text-stone-600"
|
||||
"Every framework exists under the gaze of the Other. React watches what Vue does. Vue watches what Svelte does. Svelte watches what Solid does. Each framework defines itself partly through the Other — \"we are not React,\" \"we are faster than Vue,\" \"we are simpler than Angular.\" The benchmark is the Other. The identity is relational. No framework is free to be purely itself, because the Others are always watching.")
|
||||
(p :class "text-stone-600"
|
||||
"SX has no Other. There is no competing s-expression web framework to define itself against. There is no benchmark to win, no market to capture, no conference talk to rebut. This is either pathetic (no ecosystem, no community, no relevance) or liberating (no gaze, no comparison, no borrowed identity). Sartre would say it is both.")
|
||||
(p :class "text-stone-600"
|
||||
"But there is another sense of the Other that matters more. The Other " (em "evaluator") ". SX's self-hosting spec means that the language encounters itself as Other. " (code "eval.sx") " is written in SX — the language looking at itself, seeing itself from outside. The bootstrap compiler reads this self-description and produces a working evaluator. The language has been seen by its own gaze, and the seeing has made it real. This is Sartre's intersubjectivity turned reflexive: the subject and the Other are the same entity."))
|
||||
(~doc-section :title "IX. Authenticity" :id "authenticity"
|
||||
(p :class "text-stone-600"
|
||||
"For both Heidegger and Sartre, " (a :href "https://en.wikipedia.org/wiki/Authenticity_(philosophy)" :class "text-violet-600 hover:underline" "authenticity") " means facing your situation — your freedom, your thrownness, your mortality — without evasion. The inauthentic person hides in the crowd, adopts the crowd's values, speaks the crowd's language. Heidegger called this " (a :href "https://en.wikipedia.org/wiki/Heideggerian_terminology#Das_Man" :class "text-violet-600 hover:underline" "das Man") " — the \"They.\" \"They say React is best.\" \"They use TypeScript.\" \"They have build steps.\" The They is not a conspiracy. It is the comfortable anonymity of doing what everyone does.")
|
||||
(p :class "text-stone-600"
|
||||
"Authenticity in web development would mean confronting what you are actually doing: arranging symbols so that a machine produces visual output. That is all web development is. Not \"building products.\" Not \"crafting experiences.\" Not \"shipping value.\" Arranging symbols. The frameworks, the methodologies, the Agile ceremonies — all of it is das Man, the They, the comfortable obfuscation of a simple truth.")
|
||||
(p :class "text-stone-600"
|
||||
"SX is more authentic than most — not because s-expressions are morally superior, but because they are harder to hide behind. There is no CLI that generates boilerplate. No convention that tells you where files go. No community consensus on the Right Way. You write expressions. You evaluate them. You see what they produce. The gap between what you do and what happens is as small as it can be.")
|
||||
(p :class "text-stone-600"
|
||||
"De Beauvoir added something Sartre did not: authenticity requires that you " (a :href "https://en.wikipedia.org/wiki/The_Ethics_of_Ambiguity" :class "text-violet-600 hover:underline" "will the freedom of others") ", not just your own. A language that locks you into one runtime, one vendor, one ecosystem is inauthentic — it denies others the freedom it claims for itself. SX's self-hosting spec is an act of de Beauvoirian ethics: by defining the language in itself, in a format that any reader can parse, any compiler can target, any host can implement, it wills the freedom of every future evaluator. The spec is public. The language is portable. Your freedom to re-implement, to fork, to understand — that freedom is not a side effect. It is the point.")
|
||||
(p :class "text-stone-600"
|
||||
"Existence precedes essence. The s-expression exists — bare, parenthesised, indifferent — before any evaluator gives it meaning. What it becomes is up to you. This is not a limitation. It is the only freedom there is."))))
|
||||
|
||||
(defcomp ~essays-index-content ()
|
||||
(~doc-page :title "Essays"
|
||||
(div :class "space-y-4"
|
||||
@@ -114,7 +445,7 @@
|
||||
(p :class "text-stone-600"
|
||||
"A macro is a function that takes code and returns code. An AI generating macros is writing programs that write programs. With " (code "eval") ", those generated programs can generate more programs at runtime. This is not a metaphor — it is the literal mechanism.")
|
||||
(p :class "text-stone-600"
|
||||
"The " (a :href "/essays/godel-escher-bach" :class "text-violet-600 hover:underline" "Gödel numbering") " parallel is not incidental. " (a :href "https://en.wikipedia.org/wiki/Kurt_G%C3%B6del" :class "text-violet-600 hover:underline" "Gödel") " showed that any sufficiently powerful formal system can encode statements about itself. A complete Lisp on the wire is a sufficiently powerful formal system. The web can make statements about itself — components that inspect other components, macros that rewrite the page structure, expressions that generate new expressions based on the current state of the system.")
|
||||
"The " (a :href "/philosophy/godel-escher-bach" :class "text-violet-600 hover:underline" "Gödel numbering") " parallel is not incidental. " (a :href "https://en.wikipedia.org/wiki/Kurt_G%C3%B6del" :class "text-violet-600 hover:underline" "Gödel") " showed that any sufficiently powerful formal system can encode statements about itself. A complete Lisp on the wire is a sufficiently powerful formal system. The web can make statements about itself — components that inspect other components, macros that rewrite the page structure, expressions that generate new expressions based on the current state of the system.")
|
||||
(p :class "text-stone-600"
|
||||
"Consider what this enables for AI:")
|
||||
(ul :class "space-y-2 text-stone-600 mt-2"
|
||||
@@ -276,7 +607,7 @@
|
||||
"This closes the loop on self-hosting. The SX spec defines the language. The boundary spec defines the edge. The bootstrappers generate implementations from both. And the generated code validates itself against the spec at startup. The spec is the implementation is the contract is the spec."))))
|
||||
|
||||
(defcomp ~essay-separation-of-concerns ()
|
||||
(~doc-page :title "Separation of Concerns"
|
||||
(~doc-page :title "Separate your Own Concerns"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"The web's canonical separation — HTML, CSS, JavaScript — separates the framework's concerns, not yours. Real separation of concerns is domain-specific and cannot be prescribed by a platform.")
|
||||
|
||||
@@ -679,7 +1010,7 @@
|
||||
"The name for this, borrowed from a " (a :href "https://en.wikipedia.org/wiki/There_is_no_alternative" :class "text-violet-600 hover:underline" "different context entirely") ", is " (em "TINA") " — there is no alternative. Not as a political slogan, but as a mathematical observation. When you need a minimal, homoiconic, structurally-validatable, token-efficient, tree-native, AI-compatible representation for the web, you need s-expressions. Everything else is either less capable or isomorphic."))))
|
||||
|
||||
(defcomp ~essay-zero-tooling ()
|
||||
(~doc-page :title "Zero-Tooling Web Development"
|
||||
(~doc-page :title "Tools for Fools"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"SX was built without a code editor. No IDE, no manual source edits, no build tools, no linters, no bundlers. The entire codebase — evaluator, renderer, spec, documentation site, test suite — was produced through conversation with an agentic AI. This is what zero-tooling web development looks like.")
|
||||
|
||||
@@ -765,7 +1096,7 @@
|
||||
(p :class "text-stone-600"
|
||||
"Carson Gross makes an important point in " (a :href "https://htmx.org/essays/yes-and/" :class "text-violet-600 hover:underline" "\"Yes, and...\"") ": you have to have written code in order to effectively read code. The ability to review, critique, and direct is built on the experience of having done the work yourself. You cannot skip the craft and jump straight to the oversight.")
|
||||
(p :class "text-stone-600"
|
||||
"He is right. And yet.")
|
||||
"He is right. " (strong "...and yet."))
|
||||
(p :class "text-stone-600"
|
||||
"There is a Zen teaching about the three stages of practice. Before enlightenment: chop wood, carry water. During enlightenment: chop wood, carry water. After enlightenment: chop wood, carry water. The activities look the same from the outside, but the relationship to them has changed completely. The master chops wood without the beginner's anxiety about whether they are chopping correctly, and without the intermediate's self-conscious technique. They just chop.")
|
||||
(p :class "text-stone-600"
|
||||
@@ -775,7 +1106,9 @@
|
||||
(p :class "text-stone-600"
|
||||
(em "After understanding, describe intent.") " You have internalized the patterns so deeply that you do not need to type them. You know what a component should look like without writing it character by character. You know what the test should cover without spelling out each assertion. You know what the architecture should be without drawing every box and arrow. You describe; the agent executes. Not because you cannot do the work, but because the work has become transparent to you.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a shortcut. The developer who built SX through conversation with an AI had decades of experience writing code by hand — in Lisp, in Python, in JavaScript, in C, in assembly (although he was never that good at it). The zero-tooling workflow is not a substitute for that experience. It is what comes " (em "after") " that experience. The wood still gets chopped. The water still gets carried. But the relationship to the chopping and carrying has changed."))
|
||||
"This is not a shortcut. The developer who built SX through conversation with an AI had decades of experience writing code by hand — in Python, VB, C#, JavaScript, C, and others (although he was never that good at it) — but never Lisp. The zero-tooling workflow is not a substitute for that experience. It is what comes " (em "after") " that experience. The wood still gets chopped. The water still gets carried. But the relationship to the chopping and carrying has changed.")
|
||||
(p :class "text-stone-600"
|
||||
"What pushed him to use agentic AI was when several of the keys on his keyboard stopped working. Too much coding! AI LLMs don't mind typos."))
|
||||
|
||||
(~doc-section :title "Why this only works with s-expressions" :id "why-sexps"
|
||||
(p :class "text-stone-600"
|
||||
@@ -810,3 +1143,90 @@
|
||||
"No build step. No bundler. No transpiler. No package manager. No CSS preprocessor. No dev server. No linter. No formatter. No type checker. No framework CLI. No code editor.")
|
||||
(p :class "text-stone-600"
|
||||
"Zero tools."))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; React is Hypermedia
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~essay-react-is-hypermedia ()
|
||||
(~doc-page :title "React is Hypermedia"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX.")
|
||||
(~doc-section :title "I. The argument" :id "argument"
|
||||
(p :class "text-stone-600"
|
||||
"React is not hypermedia. Everyone knows this. React is a JavaScript UI library. It renders components to a virtual DOM. It diffs. It patches. It manages state. It does none of the things that define " (a :href "https://en.wikipedia.org/wiki/Hypermedia" :class "text-violet-600 hover:underline" "hypermedia") " — server-driven content, links as the primary interaction mechanism, representations that carry their own controls.")
|
||||
(p :class "text-stone-600"
|
||||
"And yet. Consider what a React Island actually is:")
|
||||
(ul :class "list-disc pl-6 space-y-1 text-stone-600"
|
||||
(li "It is embedded in a server-rendered page.")
|
||||
(li "Its initial content is delivered as HTML (or as serialised SX, which the client renders to DOM).")
|
||||
(li "It occupies a region of the page — a bounded area with a defined boundary.")
|
||||
(li "It responds to user interaction by mutating its own DOM.")
|
||||
(li "It does not fetch data. It does not route. It does not manage application state outside its boundary."))
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (a :href "https://en.wikipedia.org/wiki/Hypermedia#Controls" :class "text-violet-600 hover:underline" "hypermedia control") ". It is a region of a hypermedia document that responds to user input. Like a " (code "<form>") ". Like an " (code "<a>") ". Like an " (code "<input>") ". The difference is that a form's behavior is specified by the browser and the HTTP protocol. An island's behavior is specified in SX."))
|
||||
(~doc-section :title "II. What makes something hypermedia" :id "hypermedia"
|
||||
(p :class "text-stone-600"
|
||||
"Roy " (a :href "https://en.wikipedia.org/wiki/Roy_Fielding" :class "text-violet-600 hover:underline" "Fielding") "'s " (a :href "https://en.wikipedia.org/wiki/Representational_state_transfer" :class "text-violet-600 hover:underline" "REST") " thesis defines hypermedia by a constraint: " (em "hypermedia as the engine of application state") " (HATEOAS). The server sends representations that include controls — links, forms — and the client's state transitions are driven by those controls. The client does not need out-of-band knowledge of what actions are available. The representation " (em "is") " the interface.")
|
||||
(p :class "text-stone-600"
|
||||
"A traditional SPA violates this. The client has its own router, its own state machine, its own API client that knows the server's URL structure. The HTML is a shell; the actual interface is constructed from JavaScript and API calls. The representation is not the interface — the representation is a loading spinner while the real interface builds itself.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX page does not violate this. The server sends a complete representation — an s-expression tree — that includes all controls. Some controls are plain HTML: " (code "(a :href \"/about\" :sx-get \"/about\")") ". Some controls are reactive islands: " (code "(defisland counter (let ((count (signal 0))) ...))") ". Both are embedded in the representation. Both are delivered by the server. The client does not decide what controls exist — the server does, by including them in the document.")
|
||||
(p :class "text-stone-600"
|
||||
"The island is not separate from the hypermedia. The island " (em "is") " part of the hypermedia. It is a control that the server chose to include, whose behavior the server specified, in the same format as the rest of the page."))
|
||||
(~doc-section :title "III. The SX specification layer" :id "spec-layer"
|
||||
(p :class "text-stone-600"
|
||||
"A " (code "<form>") "'s behavior is specified in HTML + HTTP: " (code "method=\"POST\"") ", " (code "action=\"/submit\"") ". The browser reads the specification and executes it — serialise the inputs, make the request, handle the response. The form does not contain JavaScript. Its behavior is declared.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island's behavior is specified in SX:")
|
||||
(~doc-code :lang "lisp" :code
|
||||
"(defisland todo-adder\n (let ((text (signal \"\")))\n (form :on-submit (fn (e)\n (prevent-default e)\n (emit-event \"todo:add\" (deref text))\n (reset! text \"\"))\n (input :type \"text\"\n :bind text\n :placeholder \"What needs doing?\")\n (button :type \"submit\" \"Add\"))))")
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (em "declaration") ", not a program. It declares: there is a signal holding text. There is a form. When submitted, it emits an event and resets the signal. There is an input bound to the signal. There is a button.")
|
||||
(p :class "text-stone-600"
|
||||
"The s-expression " (em "is") " the specification. It is not compiled to JavaScript and then executed as an opaque blob. It is parsed, evaluated, and rendered by a transparent evaluator whose own semantics are specified in the same format (" (code "eval.sx") "). The island's behavior is as inspectable as a form's " (code "action") " attribute — you can read it, quote it, transform it, analyse it. You can even send it over the wire and have a different client render it.")
|
||||
(p :class "text-stone-600"
|
||||
"A form says " (em "what to do") " in HTML attributes. An island says " (em "what to do") " in s-expressions. Both are declarative. Both are part of the hypermedia document. The difference is expressiveness: forms can collect inputs and POST them. Islands can maintain local state, compute derived values, animate transitions, handle errors, and render dynamic lists — all declared in the same markup language as the page that contains them."))
|
||||
(~doc-section :title "IV. The four levels" :id "four-levels"
|
||||
(p :class "text-stone-600"
|
||||
"SX reactive islands exist at four levels of complexity, from pure hypermedia to full client reactivity. Each level is a superset of the one before:")
|
||||
(ul :class "list-disc pl-6 space-y-2 text-stone-600"
|
||||
(li (span :class "font-semibold" "L0 — Static server rendering.") " No client interactivity. The server evaluates the full component tree and sends HTML. Pure hypermedia. " (code "(div :class \"card\" (h2 title))") ".")
|
||||
(li (span :class "font-semibold" "L1 — Hypermedia attributes.") " Server-rendered content with htmx-style attributes. " (code "(button :sx-get \"/items\" :sx-target \"#list\")") ". Still server-driven. The client swaps HTML fragments. Classic hypermedia with AJAX.")
|
||||
(li (span :class "font-semibold" "L2 — Reactive islands.") " Self-contained client-side state within a server-rendered page. " (code "(defisland counter ...)") ". The island is a hypermedia control: the server delivers it, the client executes it. Signals, computed values, effects — all inside the island boundary.")
|
||||
(li (span :class "font-semibold" "L3 — Island communication.") " Islands talk to each other and to the htmx-like \"lake\" via DOM events. " (code "(emit-event \"cart:updated\" count)") " and " (code "(on-event \"cart:updated\" handler)") ". Still no global state. Still no client-side routing. The page is still a server document with embedded controls."))
|
||||
(p :class "text-stone-600"
|
||||
"At every level, the architecture is hypermedia. The server produces the document. The document contains controls. The controls are specified in SX. The jump from L1 to L2 is not a jump from hypermedia to SPA — it is a jump from " (em "simple controls") " (links and forms) to " (em "richer controls") " (reactive islands). The paradigm does not change. The expressiveness does."))
|
||||
(~doc-section :title "V. Why not just React?" :id "why-not-react"
|
||||
(p :class "text-stone-600"
|
||||
"If an island behaves like a React component — local state, event handlers, conditional rendering — why not use React?")
|
||||
(p :class "text-stone-600"
|
||||
"Because React requires a " (em "build") ". JSX must be compiled. Modules must be bundled. The result is an opaque JavaScript blob that the server cannot inspect, the wire format cannot represent, and the client must execute before anything is visible. The component's specification — its source code — is lost by the time it reaches the browser.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island arrives at the browser as source. The same s-expression that defined the island on the server is the s-expression that the client parses and evaluates. There is no compilation, no bundling, no build step. The specification " (em "is") " the artifact.")
|
||||
(p :class "text-stone-600"
|
||||
"This matters because hypermedia's core property is " (em "self-description") ". A hypermedia representation carries its own controls and its own semantics. An HTML form is self-describing: the browser reads the " (code "action") " and " (code "method") " and knows what to do. A compiled React component is not self-describing: it is a function that was once source code, compiled away into instructions that only the React runtime can interpret.")
|
||||
(p :class "text-stone-600"
|
||||
"SX islands are self-describing. The source is the artifact. The representation carries its own semantics. This is what makes them hypermedia controls — not because they avoid JavaScript (they don't), but because the behavior specification travels with the document, in the same format as the document."))
|
||||
(~doc-section :title "VI. The bridge pattern" :id "bridge"
|
||||
(p :class "text-stone-600"
|
||||
"In practice, the hypermedia and the islands coexist through a pattern: the htmx \"lake\" surrounds the reactive \"islands.\" The lake handles navigation, form submission, content loading — classic hypermedia. The islands handle local interaction — counters, toggles, filters, input validation, animations.")
|
||||
(p :class "text-stone-600"
|
||||
"Communication between lake and islands uses DOM events. An island can " (code "emit-event") " to tell the page something happened. A server-rendered button can " (code "bridge-event") " to poke an island when clicked. The DOM — the shared medium — is the only coupling.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; Server-rendered lake button dispatches to island\n(button :sx-get \"/api/refresh\"\n :sx-target \"#results\"\n :on-click (bridge-event \"search:clear\")\n \"Reset\")\n\n;; Island listens for the event\n(defisland search-filter\n (let ((query (signal \"\")))\n (on-event \"search:clear\" (fn () (reset! query \"\")))\n (input :bind query :placeholder \"Filter...\")))")
|
||||
(p :class "text-stone-600"
|
||||
"The lake button does its hypermedia thing — fetches HTML, swaps it in. Simultaneously, it dispatches a DOM event. The island hears the event and clears its state. Neither knows about the other's implementation. They communicate through the hypermedia document's event system — the DOM.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a hybrid architecture bolting two incompatible models together. It is a single model — hypermedia — with controls of varying complexity. Some controls are links. Some are forms. Some are reactive islands. All are specified in the document. All are delivered by the server."))
|
||||
(~doc-section :title "VII. The specification is the specification" :id "specification"
|
||||
(p :class "text-stone-600"
|
||||
"The deepest claim is not architectural but philosophical. A React Island — the kind with signals and effects and computed values — is a " (em "behavior specification") ". It specifies: when this signal changes, recompute this derived value, re-render this DOM subtree. When this event fires, update this state. When this input changes, validate against this pattern.")
|
||||
(p :class "text-stone-600"
|
||||
"In React, this specification is written in JavaScript and destroyed by compilation. The specification exists only in the developer's source file. The user receives a bundle.")
|
||||
(p :class "text-stone-600"
|
||||
"In SX, this specification is written in s-expressions, transmitted as s-expressions, parsed as s-expressions, and evaluated as s-expressions. The specification exists at every stage of the pipeline. It is never destroyed. It is never transformed into something else. It arrives at the browser intact, readable, inspectable.")
|
||||
(p :class "text-stone-600"
|
||||
"And the evaluator that interprets this specification? It is itself specified in s-expressions (" (code "eval.sx") "). And the renderer? Specified in s-expressions (" (code "render.sx") "). And the parser? Specified in s-expressions (" (code "parser.sx") "). The specification language specifies itself. The island's behavior is specified in a language whose behavior is specified in itself.")
|
||||
(p :class "text-stone-600"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX. And SX is specified in SX. There is no layer beneath. The specification goes all the way down."))))
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
(dict :label "Protocols" :href "/protocols/wire-format")
|
||||
(dict :label "Examples" :href "/examples/click-to-load")
|
||||
(dict :label "Essays" :href "/essays/")
|
||||
(dict :label "Philosophy" :href "/philosophy/")
|
||||
(dict :label "Specs" :href "/specs/")
|
||||
(dict :label "Bootstrappers" :href "/bootstrappers/")
|
||||
(dict :label "Testing" :href "/testing/")
|
||||
(dict :label "Isomorphism" :href "/isomorphism/")
|
||||
(dict :label "Plans" :href "/plans/"))))
|
||||
(dict :label "Plans" :href "/plans/")
|
||||
(dict :label "Reactive Islands" :href "/reactive-islands/"))))
|
||||
(<> (map (lambda (item)
|
||||
(~nav-link
|
||||
:href (get item "href")
|
||||
|
||||
@@ -74,19 +74,15 @@
|
||||
:summary "Reactive UI updates without a virtual DOM, diffing library, or build step.")
|
||||
(dict :label "SX Native" :href "/essays/sx-native"
|
||||
:summary "Extending SX beyond the browser — native desktop and mobile rendering from the same source.")
|
||||
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto"
|
||||
:summary "The design principles behind SX: simplicity, self-hosting, and s-expressions all the way down.")
|
||||
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization"
|
||||
:summary "How SX implements proper tail calls via trampolining in a language that doesn't have them.")
|
||||
(dict :label "Continuations" :href "/essays/continuations"
|
||||
:summary "First-class continuations in a tree-walking evaluator — theory and implementation.")
|
||||
(dict :label "Strange Loops" :href "/essays/godel-escher-bach"
|
||||
:summary "Self-reference, and the tangled hierarchy of a language that defines itself.")
|
||||
(dict :label "The Reflexive Web" :href "/essays/reflexive-web"
|
||||
:summary "A web where pages can inspect, modify, and extend their own rendering pipeline.")
|
||||
(dict :label "Server Architecture" :href "/essays/server-architecture"
|
||||
:summary "How SX enforces the boundary between host and embedded language, and what it looks like across targets.")
|
||||
(dict :label "Separation of Concerns" :href "/essays/separation-of-concerns"
|
||||
(dict :label "Separate your Own Concerns" :href "/essays/separation-of-concerns"
|
||||
:summary "The web's HTML/CSS/JS split separates the framework's concerns, not your application's. Real separation is domain-specific.")
|
||||
(dict :label "SX and AI" :href "/essays/sx-and-ai"
|
||||
:summary "Why s-expressions are the most AI-friendly representation for web interfaces.")
|
||||
@@ -94,8 +90,22 @@
|
||||
:summary "Every attempt to escape s-expressions leads back to s-expressions. This is not an accident.")
|
||||
(dict :label "sx sucks" :href "/essays/sx-sucks"
|
||||
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")
|
||||
(dict :label "Zero-Tooling" :href "/essays/zero-tooling"
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")))
|
||||
(dict :label "Tools for Fools" :href "/essays/zero-tooling"
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")
|
||||
(dict :label "React is Hypermedia" :href "/essays/react-is-hypermedia"
|
||||
:summary "A React Island is a hypermedia control. Its behavior is specified in SX.")))
|
||||
|
||||
(define philosophy-nav-items (list
|
||||
(dict :label "The SX Manifesto" :href "/philosophy/sx-manifesto"
|
||||
:summary "The design principles behind SX: simplicity, self-hosting, and s-expressions all the way down.")
|
||||
(dict :label "Strange Loops" :href "/philosophy/godel-escher-bach"
|
||||
:summary "Self-reference, and the tangled hierarchy of a language that defines itself.")
|
||||
(dict :label "SX and Wittgenstein" :href "/philosophy/wittgenstein"
|
||||
:summary "The limits of my language are the limits of my world — Wittgenstein's philosophy and what it means for SX.")
|
||||
(dict :label "SX and Dennett" :href "/philosophy/dennett"
|
||||
:summary "Real patterns, intentional stance, and multiple drafts — Dennett's philosophy of mind as a framework for understanding SX.")
|
||||
(dict :label "S-Existentialism" :href "/philosophy/existentialism"
|
||||
:summary "Existence precedes essence — Sartre, Camus, and the absurd freedom of writing a Lisp for the web.")))
|
||||
|
||||
(define specs-nav-items (list
|
||||
(dict :label "Architecture" :href "/specs/")
|
||||
@@ -126,6 +136,7 @@
|
||||
(dict :label "Renderer" :href "/testing/render")
|
||||
(dict :label "Dependencies" :href "/testing/deps")
|
||||
(dict :label "Engine" :href "/testing/engine")
|
||||
(dict :label "Orchestration" :href "/testing/orchestration")
|
||||
(dict :label "Runners" :href "/testing/runners")))
|
||||
|
||||
(define isomorphism-nav-items (list
|
||||
@@ -135,7 +146,9 @@
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")
|
||||
(dict :label "Affinity" :href "/isomorphism/affinity")))
|
||||
(dict :label "Affinity" :href "/isomorphism/affinity")
|
||||
(dict :label "Optimistic" :href "/isomorphism/optimistic")
|
||||
(dict :label "Offline" :href "/isomorphism/offline")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
@@ -159,6 +172,20 @@
|
||||
(dict :label "Live Streaming" :href "/plans/live-streaming"
|
||||
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")))
|
||||
|
||||
(define reactive-islands-nav-items (list
|
||||
(dict :label "Overview" :href "/reactive-islands/"
|
||||
:summary "Architecture, four levels (L0-L3), and current implementation status.")
|
||||
(dict :label "Demo" :href "/reactive-islands/demo"
|
||||
:summary "Live demonstration of signals, computed, effects, batch, and defisland — all transpiled from spec.")
|
||||
(dict :label "Event Bridge" :href "/reactive-islands/event-bridge"
|
||||
:summary "DOM events for htmx lake → island communication. Server-rendered buttons dispatch custom events that island effects listen for.")
|
||||
(dict :label "Named Stores" :href "/reactive-islands/named-stores"
|
||||
:summary "Page-level signal containers via def-store/use-store — persist across island destruction/recreation.")
|
||||
(dict :label "Plan" :href "/reactive-islands/plan"
|
||||
:summary "The full design document — rendering boundary, state flow, signal primitives, island lifecycle.")
|
||||
(dict :label "Phase 2" :href "/reactive-islands/phase2"
|
||||
:summary "Input binding, keyed lists, reactive class/style, refs, portals, error boundaries, suspense, transitions.")))
|
||||
|
||||
(define bootstrappers-nav-items (list
|
||||
(dict :label "Overview" :href "/bootstrappers/")
|
||||
(dict :label "JavaScript" :href "/bootstrappers/javascript")
|
||||
|
||||
76
sx/sx/offline-demo.sx
Normal file
76
sx/sx/offline-demo.sx
Normal file
@@ -0,0 +1,76 @@
|
||||
;; Offline data layer demo — exercises Phase 7d offline mutation queue.
|
||||
;;
|
||||
;; Shows connectivity status, note list, and offline mutation queue.
|
||||
;; When offline, mutations are queued locally. On reconnect, they sync.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:offline queued" — mutation added to queue while offline
|
||||
;; "sx:offline syncing" — reconnected, replaying queued mutations
|
||||
;; "sx:offline synced" — individual mutation confirmed by server
|
||||
|
||||
(defcomp ~offline-demo-content (&key notes server-time)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Offline Data Layer")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page tests Phase 7d offline capabilities. Mutations made while "
|
||||
"offline are queued locally and replayed when connectivity returns."))
|
||||
|
||||
;; Connectivity indicator
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Status")
|
||||
(dl :class "grid grid-cols-2 gap-2 text-sm"
|
||||
(dt :class "font-medium text-stone-600" "Server time")
|
||||
(dd :class "font-mono text-stone-900" server-time)
|
||||
(dt :class "font-medium text-stone-600" "Notes count")
|
||||
(dd :class "text-stone-900" (str (len notes)))
|
||||
(dt :class "font-medium text-stone-600" "Connectivity")
|
||||
(dd :class "text-stone-900"
|
||||
(span :id "offline-status" :class "inline-flex items-center gap-1.5"
|
||||
(span :class "w-2 h-2 rounded-full bg-green-500")
|
||||
"Online"))))
|
||||
|
||||
;; Note list
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Notes")
|
||||
(div :id "offline-notes" :class "space-y-2"
|
||||
(map (fn (note)
|
||||
(div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :class "flex-none rounded-full bg-blue-100 text-blue-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (get note "id")))
|
||||
(span :class "text-stone-900" (get note "text")))
|
||||
(span :class "text-xs text-stone-400 font-mono"
|
||||
(get note "created"))))
|
||||
notes)))
|
||||
|
||||
;; Architecture
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "How it works")
|
||||
(div :class "space-y-2"
|
||||
(map-indexed
|
||||
(fn (i step)
|
||||
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
|
||||
(span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (+ i 1)))
|
||||
(div
|
||||
(div :class "font-medium text-stone-900" (get step "label"))
|
||||
(div :class "text-sm text-stone-500" (get step "detail")))))
|
||||
(list
|
||||
(dict :label "Online mutation" :detail "Routes through submit-mutation (Phase 7c) — optimistic predict, server confirm/revert")
|
||||
(dict :label "Offline detection" :detail "Browser 'offline' event sets _is-online to false. offline-aware-mutation routes to queue")
|
||||
(dict :label "Queue mutation" :detail "Mutation stored in _offline-queue with 'pending' status. Optimistic update applied locally")
|
||||
(dict :label "Reconnect" :detail "Browser 'online' event triggers offline-sync — replays queue in order via execute-action")
|
||||
(dict :label "Sync result" :detail "Each mutation marked 'synced' or 'failed'. Failed mutations stay for manual retry")))))
|
||||
|
||||
;; How to test
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to test offline behavior")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console and Network tab")
|
||||
(li "Navigate to this page via client-side routing")
|
||||
(li "In DevTools, set Network to \"Offline\" mode")
|
||||
(li "The connectivity indicator should change to red/Offline")
|
||||
(li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:offline queued"))
|
||||
(li "Re-enable network — watch for " (code :class "bg-amber-100 px-1 rounded" "sx:offline syncing"))
|
||||
(li "Queued mutations replay and confirm or fail")))))
|
||||
81
sx/sx/optimistic-demo.sx
Normal file
81
sx/sx/optimistic-demo.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
;; Optimistic update demo — exercises Phase 7c client-side predicted mutations.
|
||||
;;
|
||||
;; This page shows a todo list with optimistic add/remove.
|
||||
;; Mutations are predicted client-side, sent to server, and confirmed/reverted.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:optimistic confirmed" — server accepted the mutation
|
||||
;; "sx:optimistic reverted" — server rejected, data rolled back
|
||||
|
||||
(defcomp ~optimistic-demo-content (&key items server-time)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Optimistic Updates")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page tests Phase 7c optimistic data mutations. Items are updated "
|
||||
"instantly on the client, then confirmed or reverted when the server responds."))
|
||||
|
||||
;; Server metadata
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Current state")
|
||||
(dl :class "grid grid-cols-2 gap-2 text-sm"
|
||||
(dt :class "font-medium text-stone-600" "Server time")
|
||||
(dd :class "font-mono text-stone-900" server-time)
|
||||
(dt :class "font-medium text-stone-600" "Item count")
|
||||
(dd :class "text-stone-900" (str (len items)))))
|
||||
|
||||
;; Item list
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Items")
|
||||
(div :id "optimistic-items" :class "space-y-2"
|
||||
(map (fn (item)
|
||||
(div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :class "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (get item "id")))
|
||||
(span :class "text-stone-900" (get item "label")))
|
||||
(span :class "text-xs px-2 py-0.5 rounded-full"
|
||||
:class (case (get item "status")
|
||||
"confirmed" "bg-green-100 text-green-700"
|
||||
"pending" "bg-amber-100 text-amber-700"
|
||||
"reverted" "bg-red-100 text-red-700"
|
||||
:else "bg-stone-100 text-stone-500")
|
||||
(get item "status"))))
|
||||
items))
|
||||
|
||||
;; Add button — triggers optimistic mutation
|
||||
(div :class "pt-2"
|
||||
(button :class "px-4 py-2 bg-violet-500 text-white rounded hover:bg-violet-600 text-sm"
|
||||
:sx-post "/sx/action/add-demo-item"
|
||||
:sx-target "#main-panel"
|
||||
:sx-vals "{\"label\": \"New item\"}"
|
||||
"Add item (optimistic)")))
|
||||
|
||||
;; How it works
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "How it works")
|
||||
(div :class "space-y-2"
|
||||
(map-indexed
|
||||
(fn (i step)
|
||||
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
|
||||
(span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (+ i 1)))
|
||||
(div
|
||||
(div :class "font-medium text-stone-900" (get step "label"))
|
||||
(div :class "text-sm text-stone-500" (get step "detail")))))
|
||||
(list
|
||||
(dict :label "Predict" :detail "Client applies mutator function to cached data immediately")
|
||||
(dict :label "Snapshot" :detail "Pre-mutation data saved in _optimistic-snapshots for rollback")
|
||||
(dict :label "Re-render" :detail "Page content re-evaluated and swapped with predicted data")
|
||||
(dict :label "Submit" :detail "Mutation sent to server via POST /sx/action/<name>")
|
||||
(dict :label "Confirm or revert" :detail "Server responds — cache updated with truth, or reverted to snapshot")))))
|
||||
|
||||
;; How to verify
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to verify")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console (F12)")
|
||||
(li "Navigate to this page from another isomorphism page")
|
||||
(li "Click \"Add item\" — item appears instantly with \"pending\" status")
|
||||
(li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:optimistic confirmed"))
|
||||
(li "Item status changes to \"confirmed\" when server responds")))))
|
||||
@@ -1393,12 +1393,12 @@
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming")))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started")
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 7: Full Isomorphism"))
|
||||
(p :class "text-sm text-stone-600" "Runtime boundary optimizer, affinity annotations, offline data layer via Service Worker + IndexedDB, isomorphic testing harness.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Depends on: all previous phases."))))))
|
||||
(p :class "text-sm text-stone-600" "Affinity annotations, render plans, optimistic data updates, offline mutation queue, isomorphic testing harness, universal page descriptor.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "All 6 sub-phases (7a–7f) complete."))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fragment Protocol
|
||||
@@ -2039,55 +2039,56 @@
|
||||
(li "Render plans visible on " (a :href "/isomorphism/affinity" "affinity demo page"))
|
||||
(li "Client page registry includes :render-plan for each page"))))
|
||||
|
||||
(~doc-subsection :title "7c. Cache Invalidation & Data Updates"
|
||||
(~doc-subsection :title "7c. Cache Invalidation & Optimistic Data Updates"
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Client data cache management: invalidation on mutation, server-driven cache updates, and programmatic cache control from SX code."))
|
||||
(p :class "text-green-800 text-sm" "Client data cache management, optimistic predicted mutations with snapshot rollback, and server-driven cache updates."))
|
||||
|
||||
(p "The client-side page data cache (30-second TTL) now supports cache invalidation and server-driven updates, extending the existing DOM-level " (code "apply-optimistic") "/" (code "revert-optimistic") " to data-level cache management.")
|
||||
(p "The client-side page data cache (30-second TTL) now supports cache invalidation, server-driven updates, and optimistic mutations. The client predicts the result of a mutation, immediately re-renders with the predicted data, and confirms or reverts when the server responds.")
|
||||
|
||||
(~doc-subsection :title "Element Attributes"
|
||||
(~doc-subsection :title "Cache Invalidation"
|
||||
(p "Component authors can declare cache invalidation on elements that trigger mutations:")
|
||||
(~doc-code :code (highlight ";; Clear specific page's cache after successful action\n(form :sx-post \"/cart/remove\"\n :sx-cache-invalidate \"cart-page\"\n ...)\n\n;; Clear ALL page caches after action\n(button :sx-post \"/admin/reset\"\n :sx-cache-invalidate \"*\")" "lisp"))
|
||||
(p "When the request succeeds, the named page's data cache is cleared. The next client-side navigation to that page will re-fetch fresh data from the server."))
|
||||
|
||||
(~doc-subsection :title "Response Headers"
|
||||
(p "The server can also control client cache via response headers:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "SX-Cache-Invalidate: page-name") " — clear cache for a page")
|
||||
(li (code "SX-Cache-Invalidate: *") " — clear all page caches")
|
||||
(li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)"))
|
||||
(p (code "SX-Cache-Update") " is the strongest form: the server pushes authoritative data directly into the client cache, so the user sees fresh data immediately on next navigation — no re-fetch needed."))
|
||||
(li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)")))
|
||||
|
||||
(~doc-subsection :title "Programmatic API"
|
||||
(p "Three functions available from SX orchestration code:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "(invalidate-page-cache page-name)")
|
||||
(li "(invalidate-all-page-cache)")
|
||||
(li "(update-page-cache page-name data)")))
|
||||
(~doc-subsection :title "Optimistic Mutations"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (strong "optimistic-cache-update") " — applies a mutator function to cached data, saves a snapshot for rollback")
|
||||
(li (strong "optimistic-cache-revert") " — restores the pre-mutation snapshot if the server rejects")
|
||||
(li (strong "optimistic-cache-confirm") " — discards the snapshot after server confirmation")
|
||||
(li (strong "submit-mutation") " — orchestration function: predict, submit, confirm/revert")
|
||||
(li (strong "/sx/action/<name>") " — server endpoint for processing mutations (POST, returns SX wire format)")))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/orchestration.sx — cache management + optimistic cache functions + submit-mutation spec")
|
||||
(li "shared/sx/ref/engine.sx — SX-Cache-Invalidate, SX-Cache-Update response headers")
|
||||
(li "shared/sx/ref/orchestration.sx — cache management functions, process-cache-directives")
|
||||
(li "shared/sx/ref/bootstrap_js.py — parseSxData platform function"))))
|
||||
(li "shared/sx/pages.py — mount_action_endpoint for /sx/action/<name>")
|
||||
(li "sx/sx/optimistic-demo.sx — live demo component")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Live demo at " (a :href "/isomorphism/optimistic" :class "text-violet-600 hover:underline" "/isomorphism/optimistic"))
|
||||
(li "Console log: " (code "sx:optimistic confirmed") " / " (code "sx:optimistic reverted")))))
|
||||
|
||||
(~doc-subsection :title "7d. Offline Data Layer"
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching for offline-capable page data and IO responses. Static assets cached via Cache API."))
|
||||
(p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching, connectivity tracking, and offline mutation queue with replay on reconnect."))
|
||||
|
||||
(p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching:")
|
||||
(p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching, plus an offline mutation queue that builds on Phase 7c's optimistic updates:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data is cached on successful fetch and served from IndexedDB when offline.")
|
||||
(li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data cached on fetch, served from IndexedDB when offline.")
|
||||
(li (strong "/sx/io/* ") "— network-first with IndexedDB fallback. IO proxy responses cached the same way.")
|
||||
(li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached CSS/JS/images immediately, updates in background."))
|
||||
|
||||
(p "Cache invalidation flows through to the Service Worker: when " (code "invalidate-page-cache") " clears the in-memory cache, it also sends a " (code "postMessage") " to the SW which removes matching entries from IndexedDB.")
|
||||
(li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached assets immediately, updates in background.")
|
||||
(li (strong "Offline mutations") " — " (code "offline-aware-mutation") " routes to " (code "submit-mutation") " when online, " (code "offline-queue-mutation") " when offline. " (code "offline-sync") " replays the queue on reconnect."))
|
||||
|
||||
(~doc-subsection :title "How It Works"
|
||||
(ol :class "list-decimal list-inside text-stone-700 space-y-2"
|
||||
@@ -2095,14 +2096,21 @@
|
||||
(li "SW intercepts fetch events and routes by URL pattern")
|
||||
(li "For data/IO: try network first, on failure serve from IndexedDB")
|
||||
(li "For static assets: serve from Cache API, revalidate in background")
|
||||
(li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB")))
|
||||
(li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB")
|
||||
(li "Offline mutations queue locally, replay on reconnect via " (code "offline-sync"))))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/static/scripts/sx-sw.js — Service Worker (network-first + stale-while-revalidate)")
|
||||
(li "shared/sx/ref/orchestration.sx — offline queue, sync, connectivity tracking, sw-post-message")
|
||||
(li "shared/sx/pages.py — mount_service_worker() serves SW at /sx-sw.js")
|
||||
(li "shared/sx/ref/bootstrap_js.py — SW registration in boot init")
|
||||
(li "shared/sx/ref/orchestration.sx — sw-post-message for cache invalidation"))))
|
||||
(li "sx/sx/offline-demo.sx — live demo component")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Live demo at " (a :href "/isomorphism/offline" :class "text-violet-600 hover:underline" "/isomorphism/offline"))
|
||||
(li "Test with DevTools Network → Offline mode")
|
||||
(li "Console log: " (code "sx:offline queued") ", " (code "sx:offline syncing") ", " (code "sx:offline synced")))))
|
||||
|
||||
(~doc-subsection :title "7e. Isomorphic Testing"
|
||||
|
||||
@@ -2465,3 +2473,4 @@
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "SSE/WS IO primitive declarations")))))))
|
||||
|
||||
|
||||
|
||||
1126
sx/sx/reactive-islands.sx
Normal file
1126
sx/sx/reactive-islands.sx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -274,18 +274,50 @@
|
||||
"on-demand-css" (~essay-on-demand-css)
|
||||
"client-reactivity" (~essay-client-reactivity)
|
||||
"sx-native" (~essay-sx-native)
|
||||
"sx-manifesto" (~essay-sx-manifesto)
|
||||
"tail-call-optimization" (~essay-tail-call-optimization)
|
||||
"continuations" (~essay-continuations)
|
||||
"godel-escher-bach" (~essay-godel-escher-bach)
|
||||
"reflexive-web" (~essay-reflexive-web)
|
||||
"server-architecture" (~essay-server-architecture)
|
||||
"separation-of-concerns" (~essay-separation-of-concerns)
|
||||
"sx-and-ai" (~essay-sx-and-ai)
|
||||
"no-alternative" (~essay-no-alternative)
|
||||
"zero-tooling" (~essay-zero-tooling)
|
||||
"react-is-hypermedia" (~essay-react-is-hypermedia)
|
||||
:else (~essays-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Philosophy section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage philosophy-index
|
||||
:path "/philosophy/"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Philosophy"
|
||||
:sub-label "Philosophy"
|
||||
:sub-href "/philosophy/"
|
||||
:sub-nav (~section-nav :items philosophy-nav-items :current "")
|
||||
:selected "")
|
||||
:content (~philosophy-index-content))
|
||||
|
||||
(defpage philosophy-page
|
||||
:path "/philosophy/<slug>"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Philosophy"
|
||||
:sub-label "Philosophy"
|
||||
:sub-href "/philosophy/"
|
||||
:sub-nav (~section-nav :items philosophy-nav-items
|
||||
:current (find-current philosophy-nav-items slug))
|
||||
:selected (or (find-current philosophy-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"sx-manifesto" (~essay-sx-manifesto)
|
||||
"godel-escher-bach" (~essay-godel-escher-bach)
|
||||
"wittgenstein" (~essay-sx-and-wittgenstein)
|
||||
"dennett" (~essay-sx-and-dennett)
|
||||
"existentialism" (~essay-s-existentialism)
|
||||
:else (~philosophy-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; CSSX section
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -525,6 +557,30 @@
|
||||
:data (affinity-demo-data)
|
||||
:content (~affinity-demo-content :components components :page-plans page-plans))
|
||||
|
||||
(defpage optimistic-demo
|
||||
:path "/isomorphism/optimistic"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Optimistic")
|
||||
:selected "Optimistic")
|
||||
:data (optimistic-demo-data)
|
||||
:content (~optimistic-demo-content :items items :server-time server-time))
|
||||
|
||||
(defpage offline-demo
|
||||
:path "/isomorphism/offline"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Offline")
|
||||
:selected "Offline")
|
||||
:data (offline-demo-data)
|
||||
:content (~offline-demo-content :notes notes :server-time server-time))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
@@ -583,6 +639,39 @@
|
||||
"live-streaming" (~plan-live-streaming-content)
|
||||
:else (~plans-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reactive Islands section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage reactive-islands-index
|
||||
:path "/reactive-islands/"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Reactive Islands"
|
||||
:sub-label "Reactive Islands"
|
||||
:sub-href "/reactive-islands/"
|
||||
:sub-nav (~section-nav :items reactive-islands-nav-items :current "Overview")
|
||||
:selected "Overview")
|
||||
:content (~reactive-islands-index-content))
|
||||
|
||||
(defpage reactive-islands-page
|
||||
:path "/reactive-islands/<slug>"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Reactive Islands"
|
||||
:sub-label "Reactive Islands"
|
||||
:sub-href "/reactive-islands/"
|
||||
:sub-nav (~section-nav :items reactive-islands-nav-items
|
||||
:current (find-current reactive-islands-nav-items slug))
|
||||
:selected (or (find-current reactive-islands-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"demo" (~reactive-islands-demo-content)
|
||||
"event-bridge" (~reactive-islands-event-bridge-content)
|
||||
"named-stores" (~reactive-islands-named-stores-content)
|
||||
"plan" (~reactive-islands-plan-content)
|
||||
"phase2" (~reactive-islands-phase2-content)
|
||||
:else (~reactive-islands-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Testing section
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -624,6 +713,7 @@
|
||||
"render" (run-modular-tests "render")
|
||||
"deps" (run-modular-tests "deps")
|
||||
"engine" (run-modular-tests "engine")
|
||||
"orchestration" (run-modular-tests "orchestration")
|
||||
:else (dict))
|
||||
:content (case slug
|
||||
"eval" (~testing-spec-content
|
||||
@@ -668,6 +758,13 @@
|
||||
:spec-source spec-source
|
||||
:framework-source framework-source
|
||||
:server-results server-results)
|
||||
"orchestration" (~testing-spec-content
|
||||
:spec-name "orchestration"
|
||||
:spec-title "Orchestration Tests"
|
||||
:spec-desc "17 tests covering Phase 7c+7d orchestration — page data cache, optimistic cache update/revert/confirm, offline connectivity, offline queue mutation, and offline-aware routing."
|
||||
:spec-source spec-source
|
||||
:framework-source framework-source
|
||||
:server-results server-results)
|
||||
"runners" (~testing-runners-content)
|
||||
:else (~testing-overview-content
|
||||
:server-results server-results)))
|
||||
|
||||
@@ -28,6 +28,9 @@ def _register_sx_helpers() -> None:
|
||||
"run-modular-tests": _run_modular_tests,
|
||||
"streaming-demo-data": _streaming_demo_data,
|
||||
"affinity-demo-data": _affinity_demo_data,
|
||||
"optimistic-demo-data": _optimistic_demo_data,
|
||||
"action:add-demo-item": _add_demo_item,
|
||||
"offline-demo-data": _offline_demo_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -712,6 +715,7 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||
"deps": {"file": "test-deps.sx", "needs": []},
|
||||
"engine": {"file": "test-engine.sx", "needs": []},
|
||||
"orchestration": {"file": "test-orchestration.sx", "needs": []},
|
||||
}
|
||||
|
||||
specs_to_run = list(SPECS.keys()) if spec_name == "all" else [spec_name]
|
||||
@@ -785,6 +789,50 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
env["filter-params"] = filter_params
|
||||
except ImportError:
|
||||
eval_file("engine.sx")
|
||||
elif sn == "orchestration":
|
||||
# Mock platform functions for orchestration tests
|
||||
_mock_ts = [1000]
|
||||
env["now-ms"] = lambda: _mock_ts[0]
|
||||
env["log-info"] = lambda *_a: NIL
|
||||
env["log-warn"] = lambda *_a: NIL
|
||||
env["execute-action"] = lambda act, pay, ok_cb, _err: (ok_cb(pay), NIL)[-1]
|
||||
env["try-rerender-page"] = lambda *_a: NIL
|
||||
env["persist-offline-data"] = lambda *_a: NIL
|
||||
env["retrieve-offline-data"] = lambda: NIL
|
||||
env["dict-delete!"] = lambda d, k: (d.pop(k, None), NIL)[-1] if isinstance(d, dict) else NIL
|
||||
# DOM/browser stubs (never called by tests)
|
||||
_noop = lambda *_a: NIL
|
||||
for stub in [
|
||||
"try-parse-json", "dom-dispatch", "dom-query-selector",
|
||||
"dom-get-attribute", "dom-set-attribute", "dom-set-text-content",
|
||||
"dom-append", "dom-insert-html-adjacent", "dom-remove",
|
||||
"dom-outer-html", "dom-inner-html", "dom-create-element",
|
||||
"dom-set-inner-html", "dom-morph", "dom-get-tag",
|
||||
"dom-query-selector-all", "dom-add-event-listener",
|
||||
"dom-set-timeout", "dom-prevent-default", "dom-closest",
|
||||
"dom-matches", "dom-get-id", "dom-set-id", "dom-form-data",
|
||||
"dom-is-form", "browser-location-href", "browser-push-state",
|
||||
"browser-replace-state", "sx-hydrate-elements", "render-to-dom",
|
||||
"hoist-head-elements-full", "url-pathname",
|
||||
]:
|
||||
if stub not in env:
|
||||
env[stub] = _noop
|
||||
# Load engine (orchestration depends on it)
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
parse_time, parse_trigger_spec, default_trigger,
|
||||
parse_swap_spec, parse_retry_spec, filter_params,
|
||||
)
|
||||
env["parse-time"] = parse_time
|
||||
env["parse-trigger-spec"] = parse_trigger_spec
|
||||
env["default-trigger"] = default_trigger
|
||||
env["parse-swap-spec"] = parse_swap_spec
|
||||
env["parse-retry-spec"] = parse_retry_spec
|
||||
env["next-retry-ms"] = lambda cur, cap: min(cur * 2, cap)
|
||||
env["filter-params"] = filter_params
|
||||
except ImportError:
|
||||
eval_file("engine.sx")
|
||||
eval_file("orchestration.sx")
|
||||
|
||||
eval_file(spec["file"])
|
||||
|
||||
@@ -922,3 +970,45 @@ def _affinity_demo_data() -> dict:
|
||||
})
|
||||
|
||||
return {"components": components, "page-plans": page_plans}
|
||||
|
||||
|
||||
def _optimistic_demo_data() -> dict:
|
||||
"""Return demo data for the optimistic update test page."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{"id": 1, "label": "First item", "status": "confirmed"},
|
||||
{"id": 2, "label": "Second item", "status": "confirmed"},
|
||||
{"id": 3, "label": "Third item", "status": "confirmed"},
|
||||
],
|
||||
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def _add_demo_item(**kwargs) -> dict:
|
||||
"""Action: add a demo item. Returns confirmation with new item."""
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
label = kwargs.get("label", "Untitled")
|
||||
return {
|
||||
"id": random.randint(100, 9999),
|
||||
"label": label,
|
||||
"status": "confirmed",
|
||||
"added-at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def _offline_demo_data() -> dict:
|
||||
"""Return demo data for the offline data layer test page."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return {
|
||||
"notes": [
|
||||
{"id": 1, "text": "First note", "created": "2026-03-08T10:00:00Z"},
|
||||
{"id": 2, "text": "Second note", "created": "2026-03-08T11:30:00Z"},
|
||||
{"id": 3, "text": "Third note", "created": "2026-03-08T14:15:00Z"},
|
||||
],
|
||||
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user