Compare commits

...

13 Commits

Author SHA1 Message Date
652e7f81c8 Add Isomorphism as top-level section in sx-docs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m34s
Move isomorphic architecture roadmap and bundle analyzer from Plans
into their own top-level "Isomorphism" section. The roadmap is the
default page at /isomorphism/, bundle analyzer at /isomorphism/bundle-analyzer.

Plans section retains reader macros and SX-Activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:57:17 +00:00
8ff9827d7b Skip boundary.sx in component loader
boundary.sx files use define-page-helper which isn't an SX eval form —
they're parsed by boundary_parser.py. Exclude them from load_sx_dir()
to prevent EvalError on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:51:49 +00:00
07a73821e7 Fix boundary parser Docker path: handle /app/sx/boundary.sx layout
In Docker, each service's sx/ dir is copied directly to /app/sx/,
not /app/{service}/sx/. Add fallback search for /app/sx/boundary.sx
alongside the dev glob pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:47:50 +00:00
44d5414bc6 Split boundary.sx: separate language contract from app-specific declarations
boundary.sx was mixing three concerns in one file:
- Core SX I/O primitives (the language contract)
- Deployment-specific layout I/O (app architecture)
- Per-service page helpers (fully app-specific)

Now split into three tiers:
1. shared/sx/ref/boundary.sx — core I/O only (frag, query, current-user, etc.)
2. shared/sx/ref/boundary-app.sx — deployment layout contexts (*-header-ctx, *-ctx)
3. {service}/sx/boundary.sx — per-service page helpers

The boundary parser loads all three tiers automatically. Validation error
messages now point to the correct file for each tier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:41:38 +00:00
a90c8bf3fc Fix: use len (not count) in analyzer.sx — matches primitives.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:36:13 +00:00
a06400370a Fix: use count instead of length in analyzer.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:33:04 +00:00
0191948b6e Declare bundle-analyzer-data page helper in boundary.sx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:28:32 +00:00
9ac1d273e2 Rewrite Phase 1 plan: express in SX terms, not Python
Remove Python-specific references (deps.py, sx_ref.py, bootstrap_py.py,
test_deps.py). Phase 1 is about deps.sx the spec module — hosts are
interchangeable. Show SX code examples, describe platform interface
abstractly, link to live bundle analyzer for proof.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:26:22 +00:00
e36a036873 Add live bundle analyzer page to sx-docs
Demonstrates Phase 1 dep analysis in action: computes per-page component
bundles for all sx-docs pages using the deps.sx transitive closure
algorithm, showing needed vs total components with visual progress bars.

- New page at /plans/bundle-analyzer with Python data helper
- New components: ~bundle-analyzer-content, ~analyzer-stat, ~analyzer-row
- Linked from Phase 1 section and Plans nav
- Added sx/sx/ to tailwind content paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:23:58 +00:00
d6ca185975 Update sx-docs: add deps spec to viewer, mark Phase 1 complete
Add deps.sx to the spec navigator in sx-docs (nav-data, specs page).
Update isomorphic architecture plan to show Phase 1 as complete with
link to the canonical spec at /specs/deps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:10:52 +00:00
0ebf3c27fd Enable bootstrapped SX evaluator in production
Add SX_USE_REF=1 to production docker-compose.yml so all services
use the spec-bootstrapped evaluator, renderer, and deps analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:05:24 +00:00
4c97b03dda Wire deps.sx into both bootstrappers, rebootstrap Python + JS
deps.sx is now a spec module that both bootstrap_py.py and bootstrap_js.py
can include via --spec-modules deps. Platform functions (component-deps,
component-set-deps!, component-css-classes, env-components, regex-find-all,
scan-css-classes) implemented natively in both Python and JS.

- Fix deps.sx: env-get-or → env-get, extract nested define to top-level
- bootstrap_py.py: SPEC_MODULES, PLATFORM_DEPS_PY, mangle entries, CLI arg
- bootstrap_js.py: SPEC_MODULES, PLATFORM_DEPS_JS, mangle entries, CLI arg
- Regenerate sx_ref.py and sx-ref.js with deps module
- deps.py: thin dispatcher (SX_USE_REF=1 → bootstrapped, else fallback)
- scan_components_from_sx now returns ~prefixed names (consistent with spec)

Verified: 541 Python tests pass, JS deps tested with Node.js, both code
paths (fallback + bootstrapped) produce identical results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:55:32 +00:00
6739343a06 Add deps.sx spec: component dependency analysis
Canonical specification for per-page component bundling. Pure functions
for AST scanning, transitive closure, page bundle computation, and
per-page CSS class collection. deps.py becomes a thin host wrapper;
future hosts (Go, Rust, Haskell, etc.) bootstrap from this spec.

Defines 8 functions: scan-refs, scan-refs-walk, transitive-deps,
compute-all-deps, scan-components-from-source, components-needed,
page-component-bundle, page-css-classes.

Platform interface: component-body, component-name, component-deps,
component-set-deps!, component-css-classes, macro-body, env-components,
regex-find-all, scan-css-classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:31:43 +00:00
25 changed files with 1565 additions and 432 deletions

41
blog/sx/boundary.sx Normal file
View File

@@ -0,0 +1,41 @@
;; Blog service — page helper declarations.
(define-page-helper "editor-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "editor-page-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "post-admin-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-data-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-preview-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-entries-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-settings-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-edit-data"
:params (&key slug)
:returns "dict"
:service "blog")

View File

@@ -57,6 +57,7 @@ x-app-env: &app-env
AP_DOMAIN_EVENTS: events.rose-ash.com
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
SX_BOUNDARY_STRICT: "1"
SX_USE_REF: "1"
services:
blog:

61
events/sx/boundary.sx Normal file
View File

@@ -0,0 +1,61 @@
;; Events service — page helper declarations.
(define-page-helper "calendar-admin-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "day-admin-data"
:params (&key calendar-slug year month day)
:returns "dict"
:service "events")
(define-page-helper "slots-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "slot-data"
:params (&key calendar-slug slot-id)
:returns "dict"
:service "events")
(define-page-helper "entry-data"
:params (&key calendar-slug entry-id)
:returns "dict"
:service "events")
(define-page-helper "entry-admin-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-types-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-type-data"
:params (&key calendar-slug entry-id ticket-type-id year month day)
:returns "dict"
:service "events")
(define-page-helper "tickets-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "ticket-detail-data"
:params (&key code)
:returns "dict"
:service "events")
(define-page-helper "ticket-admin-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "markets-data"
:params (&key)
:returns "dict"
:service "events")

21
market/sx/boundary.sx Normal file
View File

@@ -0,0 +1,21 @@
;; Market service — page helper declarations.
(define-page-helper "all-markets-data"
:params (&key)
:returns "dict"
:service "market")
(define-page-helper "page-markets-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "page-admin-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "market-home-data"
:params (&key page-slug market-slug)
:returns "dict"
:service "market")

View File

@@ -532,6 +532,84 @@
return NIL;
}
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\s*@css\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
// =========================================================================
// Platform interface — Parser
// =========================================================================
@@ -2303,6 +2381,82 @@ callExpr.push(dictGet(kwargs, k)); } }
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
// scan-refs
var scanRefs = function(node) { return (function() {
var refs = [];
scanRefsWalk(node, refs);
return refs;
})(); };
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
// transitive-deps
var transitiveDeps = function(name, env) { return (function() {
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !(x == key); }, seen);
})(); };
// compute-all-deps
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
})(); }, envComponents(env)); };
// scan-components-from-source
var scanComponentsFromSource = function(source) { return (function() {
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
return map(function(m) { return (String("~") + String(m)); }, matches);
})(); };
// components-needed
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
})(); };
// page-component-bundle
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
// page-css-classes
var pageCssClasses = function(pageSource, env) { return (function() {
var needed = componentsNeeded(pageSource, env);
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
classes.push(cls);
} } }
return classes;
})(); };
// =========================================================================
// Platform interface — DOM adapter (browser-only)
// =========================================================================
@@ -3317,6 +3471,87 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
// =========================================================================
// Extension: Delimited continuations (shift/reset)
// =========================================================================
function Continuation(fn) { this.fn = fn; }
Continuation.prototype._continuation = true;
Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); };
function ShiftSignal(kName, body, env) {
this.kName = kName;
this.body = body;
this.env = env;
}
PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; };
var _resetResume = [];
function sfReset(args, env) {
var body = args[0];
try {
return trampoline(evalExpr(body, env));
} catch (e) {
if (e instanceof ShiftSignal) {
var sig = e;
var cont = new Continuation(function(value) {
if (value === undefined) value = NIL;
_resetResume.push(value);
try {
return trampoline(evalExpr(body, env));
} finally {
_resetResume.pop();
}
});
var sigEnv = merge(sig.env);
sigEnv[sig.kName] = cont;
return trampoline(evalExpr(sig.body, sigEnv));
}
throw e;
}
}
function sfShift(args, env) {
if (_resetResume.length > 0) {
return _resetResume[_resetResume.length - 1];
}
var kName = symbolName(args[0]);
var body = args[1];
throw new ShiftSignal(kName, body, env);
}
// Wrap evalList to intercept reset/shift
var _baseEvalList = evalList;
evalList = function(expr, env) {
var head = expr[0];
if (isSym(head)) {
var name = head.name;
if (name === "reset") return sfReset(expr.slice(1), env);
if (name === "shift") return sfShift(expr.slice(1), env);
}
return _baseEvalList(expr, env);
};
// Wrap aserSpecial to handle reset/shift in SX wire mode
if (typeof aserSpecial === "function") {
var _baseAserSpecial = aserSpecial;
aserSpecial = function(name, expr, env) {
if (name === "reset") return sfReset(expr.slice(1), env);
if (name === "shift") return sfShift(expr.slice(1), env);
return _baseAserSpecial(name, expr, env);
};
}
// Wrap typeOf to recognize continuations
var _baseTypeOf = typeOf;
typeOf = function(x) {
if (x != null && x._continuation) return "continuation";
return _baseTypeOf(x);
};
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
var parse = sxParse;
@@ -3385,6 +3620,12 @@ callExpr.push(dictGet(kwargs, k)); } }
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
getEnv: function() { return componentEnv; },
init: typeof bootInit === "function" ? bootInit : null,
scanRefs: scanRefs,
transitiveDeps: transitiveDeps,
computeAllDeps: computeAllDeps,
componentsNeeded: componentsNeeded,
pageComponentBundle: pageComponentBundle,
pageCssClasses: pageCssClasses,
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
};

View File

@@ -31,6 +31,7 @@ module.exports = {
'/root/rose-ash/federation/sx/sx_components.py',
'/root/rose-ash/account/sx/sx_components.py',
'/root/rose-ash/orders/sx/sx_components.py',
'/root/rose-ash/sx/sx/**/*.sx',
'/root/rose-ash/sx/sxc/**/*.sx',
'/root/rose-ash/sx/sxc/sx_components.py',
'/root/rose-ash/sx/content/highlight.py',

View File

@@ -69,22 +69,25 @@ def validate_primitive(name: str) -> None:
def validate_io(name: str) -> None:
"""Validate that an I/O primitive is declared in boundary.sx."""
"""Validate that an I/O primitive is declared in boundary.sx or boundary-app.sx."""
_load_declarations()
assert _DECLARED_IO is not None
if name not in _DECLARED_IO:
_report(f"Undeclared I/O primitive: {name!r}. Add to boundary.sx.")
_report(
f"Undeclared I/O primitive: {name!r}. "
f"Add to boundary.sx (core) or boundary-app.sx (deployment)."
)
def validate_helper(service: str, name: str) -> None:
"""Validate that a page helper is declared in boundary.sx."""
"""Validate that a page helper is declared in {service}/sx/boundary.sx."""
_load_declarations()
assert _DECLARED_HELPERS is not None
svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
if name not in svc_helpers:
_report(
f"Undeclared page helper: {name!r} for service {service!r}. "
f"Add to boundary.sx."
f"Add to {service}/sx/boundary.sx."
)

View File

@@ -1,56 +1,48 @@
"""
Component dependency analysis.
Walks component AST bodies to compute transitive dependency sets.
A component's deps are all other components (~name references) it
can potentially render, including through control flow branches.
Thin host wrapper over bootstrapped deps module from shared/sx/ref/deps.sx.
The canonical logic lives in the spec; this module provides Python-typed
entry points for the rest of the codebase.
"""
from __future__ import annotations
import os
from typing import Any
from .types import Component, Macro, Symbol
def _scan_ast(node: Any) -> set[str]:
"""Scan an AST node for ~component references.
def _use_ref() -> bool:
return os.environ.get("SX_USE_REF") == "1"
Walks all branches of control flow (if/when/cond/case) to find
every component that *could* be rendered. Returns a set of
component names (with ~ prefix).
"""
# ---------------------------------------------------------------------------
# Hand-written fallback (used when SX_USE_REF != 1)
# ---------------------------------------------------------------------------
def _scan_ast(node: Any) -> set[str]:
refs: set[str] = set()
_walk(node, refs)
return refs
def _walk(node: Any, refs: set[str]) -> None:
"""Recursively walk an AST node collecting ~name references."""
if isinstance(node, Symbol):
if node.name.startswith("~"):
refs.add(node.name)
return
if isinstance(node, list):
for item in node:
_walk(item, refs)
return
if isinstance(node, dict):
for v in node.values():
_walk(v, refs)
return
# Literals (str, int, float, bool, None, Keyword) — no refs
return
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
"""Compute transitive component dependencies for *name*.
Returns the set of all component names (with ~ prefix) that
*name* can transitively render, NOT including *name* itself.
"""
def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
seen: set[str] = set()
def walk(n: str) -> None:
@@ -70,37 +62,19 @@ def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
return seen - {key}
def compute_all_deps(env: dict[str, Any]) -> None:
"""Compute and cache deps for all Component entries in *env*.
Mutates each Component's ``deps`` field in place.
"""
def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
for key, val in env.items():
if isinstance(val, Component):
val.deps = transitive_deps(key, env)
val.deps = _transitive_deps_fallback(key, env)
def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text.
Uses regex to find (~name patterns in serialized SX wire format.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
"""
def _scan_components_from_sx_fallback(source: str) -> set[str]:
import re
return set(re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source))
return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)}
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
"""Compute the full set of component names needed for a page.
Scans *page_sx* for direct component references, then computes
the transitive closure over the component dependency graph.
Returns names with ~ prefix.
"""
# Direct refs from the page source
direct = {f"~{n}" for n in scan_components_from_sx(page_sx)}
# Transitive closure
def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
direct = _scan_components_from_sx_fallback(page_sx)
all_needed: set[str] = set()
for name in direct:
all_needed.add(name)
@@ -108,7 +82,52 @@ def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
if isinstance(val, Component) and val.deps:
all_needed.update(val.deps)
else:
# deps not cached yet — compute on the fly
all_needed.update(transitive_deps(name, env))
all_needed.update(_transitive_deps_fallback(name, env))
return all_needed
# ---------------------------------------------------------------------------
# Public API — dispatches to bootstrapped or fallback
# ---------------------------------------------------------------------------
def transitive_deps(name: str, env: dict[str, Any]) -> set[str]:
"""Compute transitive component dependencies for *name*.
Returns the set of all component names (with ~ prefix) that
*name* can transitively render, NOT including *name* itself.
"""
if _use_ref():
from .ref.sx_ref import transitive_deps as _ref_td
return set(_ref_td(name, env))
return _transitive_deps_fallback(name, env)
def compute_all_deps(env: dict[str, Any]) -> None:
"""Compute and cache deps for all Component entries in *env*."""
if _use_ref():
from .ref.sx_ref import compute_all_deps as _ref_cad
_ref_cad(env)
return
_compute_all_deps_fallback(env)
def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}.
"""
if _use_ref():
from .ref.sx_ref import scan_components_from_source as _ref_sc
return set(_ref_sc(source))
return _scan_components_from_sx_fallback(source)
def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
"""Compute the full set of component names needed for a page.
Returns names with ~ prefix.
"""
if _use_ref():
from .ref.sx_ref import components_needed as _ref_cn
return set(_ref_cn(page_sx, env))
return _components_needed_fallback(page_sx, env)

View File

@@ -86,10 +86,15 @@ def _compute_component_hash() -> None:
def load_sx_dir(directory: str) -> None:
"""Load all .sx files from a directory and register components."""
"""Load all .sx files from a directory and register components.
Skips boundary.sx — those are parsed separately by the boundary validator.
"""
for filepath in sorted(
glob.glob(os.path.join(directory, "*.sx"))
):
if os.path.basename(filepath) == "boundary.sx":
continue
with open(filepath, encoding="utf-8") as f:
register_components(f.read())

View File

@@ -490,6 +490,21 @@ class JSEmitter:
"log-info": "logInfo",
"log-parse-error": "logParseError",
"parse-and-load-style-dict": "parseAndLoadStyleDict",
# deps.sx
"scan-refs": "scanRefs",
"scan-refs-walk": "scanRefsWalk",
"transitive-deps": "transitiveDeps",
"compute-all-deps": "computeAllDeps",
"scan-components-from-source": "scanComponentsFromSource",
"components-needed": "componentsNeeded",
"page-component-bundle": "pageComponentBundle",
"page-css-classes": "pageCssClasses",
"component-deps": "componentDeps",
"component-set-deps!": "componentSetDeps",
"component-css-classes": "componentCssClasses",
"env-components": "envComponents",
"regex-find-all": "regexFindAll",
"scan-css-classes": "scanCssClasses",
}
if name in RENAMES:
return RENAMES[name]
@@ -1001,6 +1016,10 @@ ADAPTER_DEPS = {
"parser": [],
}
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
}
EXTENSION_NAMES = {"continuations"}
@@ -1091,6 +1110,7 @@ def compile_ref_to_js(
adapters: list[str] | None = None,
modules: list[str] | None = None,
extensions: list[str] | None = None,
spec_modules: list[str] | None = None,
) -> str:
"""Read reference .sx files and emit JavaScript.
@@ -1104,6 +1124,9 @@ def compile_ref_to_js(
extensions: List of optional extensions to include.
Valid names: continuations.
None = no extensions.
spec_modules: List of spec module names to include.
Valid names: deps.
None = no spec modules.
"""
ref_dir = os.path.dirname(os.path.abspath(__file__))
emitter = JSEmitter()
@@ -1131,7 +1154,16 @@ def compile_ref_to_js(
for dep in ADAPTER_DEPS.get(a, []):
adapter_set.add(dep)
# Core files always included, then selected adapters
# Resolve spec modules
spec_mod_set = set()
if spec_modules:
for sm in spec_modules:
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
# Core files always included, then selected adapters, then spec modules
sx_files = [
("eval.sx", "eval"),
("render.sx", "render (core)"),
@@ -1139,6 +1171,8 @@ def compile_ref_to_js(
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "cssx", "boot"):
if name in adapter_set:
sx_files.append(ADAPTER_FILES[name])
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
all_sections = []
for filename, label in sx_files:
@@ -1190,6 +1224,9 @@ def compile_ref_to_js(
parts.append(_assemble_primitives_js(prim_modules))
parts.append(PLATFORM_JS_POST)
if has_deps:
parts.append(PLATFORM_DEPS_JS)
# Parser platform must come before compiled parser.sx
if has_parser:
parts.append(adapter_platform["parser"])
@@ -1211,7 +1248,7 @@ def compile_ref_to_js(
parts.append(fixups_js(has_html, has_sx, has_dom))
if has_continuations:
parts.append(CONTINUATIONS_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label))
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps))
parts.append(EPILOGUE)
return "\n".join(parts)
@@ -1790,6 +1827,85 @@ PLATFORM_JS_POST = '''
return NIL;
}'''
PLATFORM_DEPS_JS = '''
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\\s*@css\\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
'''
PLATFORM_PARSER_JS = r"""
// =========================================================================
// Platform interface — Parser
@@ -2836,7 +2952,7 @@ def fixups_js(has_html, has_sx, has_dom):
return "\n".join(lines)
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label):
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False):
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
if has_parser:
parser = '''
@@ -2958,6 +3074,13 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
elif has_orch:
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
if has_deps:
api_lines.append(' scanRefs: scanRefs,')
api_lines.append(' transitiveDeps: transitiveDeps,')
api_lines.append(' computeAllDeps: computeAllDeps,')
api_lines.append(' componentsNeeded: componentsNeeded,')
api_lines.append(' pageComponentBundle: pageComponentBundle,')
api_lines.append(' pageCssClasses: pageCssClasses,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
@@ -3015,6 +3138,8 @@ if __name__ == "__main__":
help="Comma-separated primitive modules (core.* always included). Default: all")
p.add_argument("--extensions",
help="Comma-separated extensions (continuations). Default: none.")
p.add_argument("--spec-modules",
help="Comma-separated spec modules (deps). Default: none.")
p.add_argument("--output", "-o",
help="Output file (default: stdout)")
args = p.parse_args()
@@ -3022,7 +3147,8 @@ if __name__ == "__main__":
adapters = args.adapters.split(",") if args.adapters else None
modules = args.modules.split(",") if args.modules else None
extensions = args.extensions.split(",") if args.extensions else None
js = compile_ref_to_js(adapters, modules, extensions)
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
if args.output:
with open(args.output, "w") as f:

View File

@@ -235,6 +235,21 @@ class PyEmitter:
"map-dict": "map_dict",
"eval-cond": "eval_cond",
"process-bindings": "process_bindings",
# deps.sx
"scan-refs": "scan_refs",
"scan-refs-walk": "scan_refs_walk",
"transitive-deps": "transitive_deps",
"compute-all-deps": "compute_all_deps",
"scan-components-from-source": "scan_components_from_source",
"components-needed": "components_needed",
"page-component-bundle": "page_component_bundle",
"page-css-classes": "page_css_classes",
"component-deps": "component_deps",
"component-set-deps!": "component_set_deps",
"component-css-classes": "component_css_classes",
"env-components": "env_components",
"regex-find-all": "regex_find_all",
"scan-css-classes": "scan_css_classes",
}
if name in RENAMES:
return RENAMES[name]
@@ -803,6 +818,11 @@ ADAPTER_FILES = {
}
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
}
EXTENSION_NAMES = {"continuations"}
# Extension-provided special forms (not in eval.sx core)
@@ -889,6 +909,7 @@ def compile_ref_to_py(
adapters: list[str] | None = None,
modules: list[str] | None = None,
extensions: list[str] | None = None,
spec_modules: list[str] | None = None,
) -> str:
"""Read reference .sx files and emit Python.
@@ -902,6 +923,9 @@ def compile_ref_to_py(
extensions: List of optional extensions to include.
Valid names: continuations.
None = no extensions.
spec_modules: List of spec module names to include.
Valid names: deps.
None = no spec modules.
"""
# Determine which primitive modules to include
prim_modules = None # None = all
@@ -926,7 +950,16 @@ def compile_ref_to_py(
raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}")
adapter_set.add(a)
# Core files always included, then selected adapters
# Resolve spec modules
spec_mod_set = set()
if spec_modules:
for sm in spec_modules:
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
# Core files always included, then selected adapters, then spec modules
sx_files = [
("eval.sx", "eval"),
("forms.sx", "forms (server definition forms)"),
@@ -935,6 +968,8 @@ def compile_ref_to_py(
for name in ("html", "sx"):
if name in adapter_set:
sx_files.append(ADAPTER_FILES[name])
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
all_sections = []
for filename, label in sx_files:
@@ -969,6 +1004,9 @@ def compile_ref_to_py(
parts.append(_assemble_primitives_py(prim_modules))
parts.append(PRIMITIVES_PY_POST)
if has_deps:
parts.append(PLATFORM_DEPS_PY)
for label, defines in all_sections:
parts.append(f"\n# === Transpiled from {label} ===\n")
for name, expr in defines:
@@ -979,7 +1017,7 @@ def compile_ref_to_py(
parts.append(FIXUPS_PY)
if has_continuations:
parts.append(CONTINUATIONS_PY)
parts.append(public_api_py(has_html, has_sx))
parts.append(public_api_py(has_html, has_sx, has_deps))
return "\n".join(parts)
@@ -1903,6 +1941,50 @@ assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
'''
PLATFORM_DEPS_PY = (
'\n'
'# =========================================================================\n'
'# Platform: deps module — component dependency analysis\n'
'# =========================================================================\n'
'\n'
'import re as _re\n'
'\n'
'def component_deps(c):\n'
' """Return cached deps list for a component (may be empty)."""\n'
' return list(c.deps) if hasattr(c, "deps") and c.deps else []\n'
'\n'
'def component_set_deps(c, deps):\n'
' """Cache deps on a component."""\n'
' c.deps = set(deps) if not isinstance(deps, set) else deps\n'
'\n'
'def component_css_classes(c):\n'
' """Return pre-scanned CSS class list for a component."""\n'
' 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'
' return [k for k, v in env.items()\n'
' if isinstance(v, (Component, Macro))]\n'
'\n'
'def regex_find_all(pattern, source):\n'
' """Return list of capture group 1 matches."""\n'
' return [m.group(1) for m in _re.finditer(pattern, source)]\n'
'\n'
'def scan_css_classes(source):\n'
' """Extract CSS class strings from SX source."""\n'
' classes = set()\n'
' for m in _re.finditer(r\':class\\s+"([^"]*)"\', source):\n'
' classes.update(m.group(1).split())\n'
' for m in _re.finditer(r\':class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)\', source):\n'
' for s in _re.findall(r\'"([^"]*)"\', m.group(1)):\n'
' classes.update(s.split())\n'
' for m in _re.finditer(r\';;\\s*@css\\s+(.+)\', source):\n'
' classes.update(m.group(1).split())\n'
' return list(classes)\n'
)
FIXUPS_PY = '''
# =========================================================================
# Fixups -- wire up render adapter dispatch
@@ -1996,7 +2078,7 @@ aser_special = _aser_special_with_continuations
'''
def public_api_py(has_html: bool, has_sx: bool) -> str:
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
lines = [
'',
'# =========================================================================',
@@ -2059,11 +2141,17 @@ def main():
default=None,
help="Comma-separated extensions (continuations). Default: none.",
)
parser.add_argument(
"--spec-modules",
default=None,
help="Comma-separated spec modules (deps). Default: none.",
)
args = parser.parse_args()
adapters = args.adapters.split(",") if args.adapters else None
modules = args.modules.split(",") if args.modules else None
extensions = args.extensions.split(",") if args.extensions else None
print(compile_ref_to_py(adapters, modules, extensions))
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
print(compile_ref_to_py(adapters, modules, extensions, spec_modules))
if __name__ == "__main__":

View File

@@ -0,0 +1,118 @@
;; ==========================================================================
;; boundary-app.sx — Deployment-specific boundary declarations
;;
;; Layout context I/O primitives for THIS deployment's service architecture.
;; These are NOT part of the SX language contract — a different deployment
;; would declare different layout contexts here.
;;
;; The core SX I/O contract lives in boundary.sx.
;; Per-service page helpers live in {service}/sx/boundary.sx.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Layout context providers — deployment-specific I/O
;; --------------------------------------------------------------------------
;; Shared across all services (root layout)
(define-io-primitive "root-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
:context :request)
(define-io-primitive "select-colours"
:params ()
:returns "string"
:async true
:doc "Shared select/hover CSS class string."
:context :request)
(define-io-primitive "account-nav-ctx"
:params ()
:returns "any"
:async true
:doc "Account nav fragments, or nil."
:context :request)
(define-io-primitive "app-rights"
:params ()
:returns "dict"
:async true
:doc "User rights dict from g.rights."
:context :request)
;; Blog service layout
(define-io-primitive "post-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with post-level header values."
:context :request)
;; Cart service layout
(define-io-primitive "cart-page-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with cart page header values."
:context :request)
;; Events service layouts
(define-io-primitive "events-calendar-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events calendar header values."
:context :request)
(define-io-primitive "events-day-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events day header values."
:context :request)
(define-io-primitive "events-entry-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events entry header values."
:context :request)
(define-io-primitive "events-slot-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events slot header values."
:context :request)
(define-io-primitive "events-ticket-type-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with ticket type header values."
:context :request)
;; Market service layout
(define-io-primitive "market-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with market header data."
:context :request)
;; Federation service layout
(define-io-primitive "federation-actor-ctx"
:params ()
:returns "dict?"
:async true
:doc "Serialized ActivityPub actor dict or nil."
:context :request)

View File

@@ -1,12 +1,12 @@
;; ==========================================================================
;; boundary.sx — SX boundary contract
;; boundary.sx — SX language boundary contract
;;
;; Declares everything allowed to cross the host-SX boundary:
;; I/O primitives (Tier 2) and page helpers (Tier 3).
;; Declares the core I/O primitives that any SX host must provide.
;; This is the LANGUAGE contract — not deployment-specific.
;;
;; Pure primitives (Tier 1) are declared in primitives.sx.
;; This file declares what primitives.sx does NOT cover:
;; async/side-effectful host functions that need request context.
;; Deployment-specific I/O (layout contexts) lives in boundary-app.sx.
;; Per-service page helpers live in {service}/sx/boundary.sx.
;;
;; Format:
;; (define-io-primitive "name"
@@ -16,13 +16,6 @@
;; :doc "description"
;; :context :request)
;;
;; (define-page-helper "name"
;; :params (param1 param2)
;; :returns "type"
;; :service "service-name")
;;
;; Bootstrappers read this file and emit frozen sets + validation
;; functions for the target language.
;; ==========================================================================
@@ -34,9 +27,11 @@
;; --------------------------------------------------------------------------
;; Tier 2: I/O primitives — async, side-effectful, need host context
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
;; --------------------------------------------------------------------------
;; Cross-service communication
(define-io-primitive "frag"
:params (service frag-type &key)
:returns "string"
@@ -58,6 +53,15 @@
:doc "Call an action on another service via internal HTTP."
:context :request)
(define-io-primitive "service"
:params (service-or-method &rest args &key)
:returns "any"
:async true
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
:context :request)
;; Request context
(define-io-primitive "current-user"
:params ()
:returns "dict?"
@@ -72,13 +76,6 @@
:doc "True if current request has HX-Request header."
:context :request)
(define-io-primitive "service"
:params (service-or-method &rest args &key)
:returns "any"
:async true
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
:context :request)
(define-io-primitive "request-arg"
:params (name &rest default)
:returns "any"
@@ -93,18 +90,11 @@
:doc "Current request path."
:context :request)
(define-io-primitive "nav-tree"
:params ()
:returns "list"
(define-io-primitive "request-view-args"
:params (key)
:returns "any"
:async true
:doc "Navigation tree as list of node dicts."
:context :request)
(define-io-primitive "get-children"
:params (&key parent-type parent-id)
:returns "list"
:async true
:doc "Fetch child entities for a parent."
:doc "Read a URL view argument from the current request."
:context :request)
(define-io-primitive "g"
@@ -128,6 +118,8 @@
:doc "Raise HTTP error from SX."
:context :request)
;; Routing
(define-io-primitive "url-for"
:params (endpoint &key)
:returns "string"
@@ -142,105 +134,23 @@
:doc "Service URL prefix for dev/prod routing."
:context :request)
(define-io-primitive "root-header-ctx"
;; Navigation and relations
(define-io-primitive "nav-tree"
:params ()
:returns "dict"
:returns "list"
:async true
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
:doc "Navigation tree as list of node dicts."
:context :request)
(define-io-primitive "post-header-ctx"
:params ()
:returns "dict"
(define-io-primitive "get-children"
:params (&key parent-type parent-id)
:returns "list"
:async true
:doc "Dict with post-level header values."
:doc "Fetch child entities for a parent."
:context :request)
(define-io-primitive "select-colours"
:params ()
:returns "string"
:async true
:doc "Shared select/hover CSS class string."
:context :request)
(define-io-primitive "account-nav-ctx"
:params ()
:returns "any"
:async true
:doc "Account nav fragments, or nil."
:context :request)
(define-io-primitive "app-rights"
:params ()
:returns "dict"
:async true
:doc "User rights dict from g.rights."
:context :request)
(define-io-primitive "federation-actor-ctx"
:params ()
:returns "dict?"
:async true
:doc "Serialized ActivityPub actor dict or nil."
:context :request)
(define-io-primitive "request-view-args"
:params (key)
:returns "any"
:async true
:doc "Read a URL view argument from the current request."
:context :request)
(define-io-primitive "cart-page-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with cart page header values."
:context :request)
(define-io-primitive "events-calendar-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events calendar header values."
:context :request)
(define-io-primitive "events-day-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events day header values."
:context :request)
(define-io-primitive "events-entry-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events entry header values."
:context :request)
(define-io-primitive "events-slot-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events slot header values."
:context :request)
(define-io-primitive "events-ticket-type-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with ticket type header values."
:context :request)
(define-io-primitive "market-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with market header data."
:context :request)
;; Moved from primitives.py — these need host context (infra/config/Quart)
;; Config and host context (sync — no await needed)
(define-io-primitive "app-url"
:params (service &rest path)
@@ -278,180 +188,6 @@
:context :config)
;; --------------------------------------------------------------------------
;; Tier 3: Page helpers — service-scoped, registered per app
;; --------------------------------------------------------------------------
;; SX docs service
(define-page-helper "highlight"
:params (code lang)
:returns "sx-source"
:service "sx")
(define-page-helper "primitives-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "special-forms-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "reference-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "attr-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "header-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "event-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "read-spec-file"
:params (filename)
:returns "string"
:service "sx")
(define-page-helper "bootstrapper-data"
:params (target)
:returns "dict"
:service "sx")
;; Blog service
(define-page-helper "editor-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "editor-page-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "post-admin-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-data-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-preview-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-entries-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-settings-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-edit-data"
:params (&key slug)
:returns "dict"
:service "blog")
;; Events service
(define-page-helper "calendar-admin-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "day-admin-data"
:params (&key calendar-slug year month day)
:returns "dict"
:service "events")
(define-page-helper "slots-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "slot-data"
:params (&key calendar-slug slot-id)
:returns "dict"
:service "events")
(define-page-helper "entry-data"
:params (&key calendar-slug entry-id)
:returns "dict"
:service "events")
(define-page-helper "entry-admin-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-types-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-type-data"
:params (&key calendar-slug entry-id ticket-type-id year month day)
:returns "dict"
:service "events")
(define-page-helper "tickets-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "ticket-detail-data"
:params (&key code)
:returns "dict"
:service "events")
(define-page-helper "ticket-admin-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "markets-data"
:params (&key)
:returns "dict"
:service "events")
;; Market service
(define-page-helper "all-markets-data"
:params (&key)
:returns "dict"
:service "market")
(define-page-helper "page-markets-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "page-admin-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "market-home-data"
:params (&key page-slug market-slug)
:returns "dict"
:service "market")
;; --------------------------------------------------------------------------
;; Boundary types — what's allowed to cross the host-SX boundary
;; --------------------------------------------------------------------------

View File

@@ -1,14 +1,23 @@
"""
Parse boundary.sx and primitives.sx to extract declared names.
Parse boundary declarations from multiple sources.
Three tiers of boundary files:
1. shared/sx/ref/boundary.sx — core SX language I/O contract
2. shared/sx/ref/boundary-app.sx — deployment-specific layout I/O
3. {service}/sx/boundary.sx — per-service page helpers
Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
by the validation module.
"""
from __future__ import annotations
import glob
import logging
import os
from typing import Any
logger = logging.getLogger("sx.boundary_parser")
# Allow standalone use (from bootstrappers) or in-project imports
try:
from shared.sx.parser import parse_all
@@ -26,12 +35,37 @@ def _ref_dir() -> str:
return os.path.dirname(os.path.abspath(__file__))
def _project_root() -> str:
"""Return the project root containing service directories.
Dev: shared/sx/ref -> shared/sx -> shared -> project root
Docker: /app/shared/sx/ref -> /app (shared is inside /app)
"""
ref = _ref_dir()
# Go up 3 levels: shared/sx/ref -> project root
root = os.path.abspath(os.path.join(ref, "..", "..", ".."))
# Verify by checking for a known service directory or shared/
if os.path.isdir(os.path.join(root, "shared")):
return root
# Docker: /app/shared/sx/ref -> /app
# shared is INSIDE /app, not a sibling — go up to parent of shared
root = os.path.abspath(os.path.join(ref, "..", ".."))
if os.path.isdir(os.path.join(root, "sx")): # /app/sx exists in Docker
return root
return root
def _read_file(filename: str) -> str:
filepath = os.path.join(_ref_dir(), filename)
with open(filepath, encoding="utf-8") as f:
return f.read()
def _read_file_path(filepath: str) -> str:
with open(filepath, encoding="utf-8") as f:
return f.read()
def _extract_keyword_arg(expr: list, key: str) -> Any:
"""Extract :key value from a flat keyword-arg list."""
for i, item in enumerate(expr):
@@ -40,6 +74,64 @@ def _extract_keyword_arg(expr: list, key: str) -> Any:
return None
def _extract_declarations(
source: str,
) -> tuple[set[str], dict[str, set[str]]]:
"""Extract I/O primitive names and page helper names from boundary source.
Returns (io_names, {service: helper_names}).
"""
exprs = parse_all(source)
io_names: set[str] = set()
helpers: dict[str, set[str]] = {}
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if not isinstance(head, Symbol):
continue
if head.name == "define-io-primitive":
name = expr[1]
if isinstance(name, str):
io_names.add(name)
elif head.name == "define-page-helper":
name = expr[1]
service = _extract_keyword_arg(expr, "service")
if isinstance(name, str) and isinstance(service, str):
helpers.setdefault(service, set()).add(name)
return io_names, helpers
def _find_service_boundary_files() -> list[str]:
"""Find service boundary.sx files.
Dev: {project}/{service}/sx/boundary.sx (e.g. blog/sx/boundary.sx)
Docker: /app/sx/boundary.sx (service's sx/ dir copied directly into /app/)
"""
root = _project_root()
files: list[str] = []
# Dev layout: {root}/{service}/sx/boundary.sx
for f in glob.glob(os.path.join(root, "*/sx/boundary.sx")):
if "/shared/" not in f:
files.append(f)
# Docker layout: service's sx/ dir is at {root}/sx/boundary.sx
docker_path = os.path.join(root, "sx", "boundary.sx")
if os.path.exists(docker_path) and docker_path not in files:
files.append(docker_path)
return files
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_primitives_sx() -> frozenset[str]:
"""Parse primitives.sx and return frozenset of declared pure primitive names."""
by_module = parse_primitives_by_module()
@@ -50,12 +142,7 @@ def parse_primitives_sx() -> frozenset[str]:
def parse_primitives_by_module() -> dict[str, frozenset[str]]:
"""Parse primitives.sx and return primitives grouped by module.
Returns:
Dict mapping module name (e.g. "core.arithmetic") to frozenset of
primitive names declared under that module.
"""
"""Parse primitives.sx and return primitives grouped by module."""
source = _read_file("primitives.sx")
exprs = parse_all(source)
modules: dict[str, set[str]] = {}
@@ -83,37 +170,40 @@ def parse_primitives_by_module() -> dict[str, frozenset[str]]:
def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
"""Parse boundary.sx and return (io_names, {service: helper_names}).
"""Parse all boundary sources and return (io_names, {service: helper_names}).
Returns:
io_names: frozenset of declared I/O primitive names
helpers: dict mapping service name to frozenset of helper names
Loads three tiers:
1. boundary.sx — core language I/O
2. boundary-app.sx — deployment-specific I/O
3. {service}/sx/boundary.sx — per-service page helpers
"""
source = _read_file("boundary.sx")
exprs = parse_all(source)
io_names: set[str] = set()
helpers: dict[str, set[str]] = {}
all_io: set[str] = set()
all_helpers: dict[str, set[str]] = {}
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if not isinstance(head, Symbol):
continue
def _merge(source: str, label: str) -> None:
io_names, helpers = _extract_declarations(source)
all_io.update(io_names)
for svc, names in helpers.items():
all_helpers.setdefault(svc, set()).update(names)
logger.debug("Boundary %s: %d io, %d helpers", label, len(io_names), sum(len(v) for v in helpers.values()))
if head.name == "define-io-primitive":
name = expr[1]
if isinstance(name, str):
io_names.add(name)
# 1. Core language contract
_merge(_read_file("boundary.sx"), "core")
elif head.name == "define-page-helper":
name = expr[1]
service = _extract_keyword_arg(expr, "service")
if isinstance(name, str) and isinstance(service, str):
helpers.setdefault(service, set()).add(name)
# 2. Deployment-specific I/O
app_path = os.path.join(_ref_dir(), "boundary-app.sx")
if os.path.exists(app_path):
_merge(_read_file("boundary-app.sx"), "app")
frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
return frozenset(io_names), frozen_helpers
# 3. Per-service boundary files
for filepath in _find_service_boundary_files():
try:
_merge(_read_file_path(filepath), filepath)
except Exception as e:
logger.warning("Failed to parse %s: %s", filepath, e)
frozen_helpers = {svc: frozenset(names) for svc, names in all_helpers.items()}
return frozenset(all_io), frozen_helpers
def parse_boundary_types() -> frozenset[str]:
@@ -126,7 +216,6 @@ def parse_boundary_types() -> frozenset[str]:
and expr[0].name == "define-boundary-types"):
type_list = expr[1]
if isinstance(type_list, list):
# (list "number" "string" ...)
return frozenset(
item for item in type_list
if isinstance(item, str)

230
shared/sx/ref/deps.sx Normal file
View File

@@ -0,0 +1,230 @@
;; ==========================================================================
;; deps.sx — Component dependency analysis specification
;;
;; Pure functions for analyzing component dependency graphs.
;; Used by the bundling system to compute per-page component bundles
;; instead of sending every definition to every page.
;;
;; All functions are pure — no IO, no platform-specific operations.
;; Each host bootstraps this to native code alongside eval.sx/render.sx.
;;
;; From eval.sx platform (already provided by every host):
;; (type-of x) → type string
;; (symbol-name s) → string name of symbol
;; (component-body c) → unevaluated AST of component body
;; (component-name c) → string name (without ~)
;; (macro-body m) → macro body AST
;; (env-get env k) → value or nil
;;
;; New platform functions for deps (each host implements):
;; (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
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. AST scanning — collect ~component references from an AST node
;; --------------------------------------------------------------------------
;; Walks all branches of control flow (if/when/cond/case) to find
;; every component that *could* be rendered.
(define scan-refs
(fn (node)
(let ((refs (list)))
(scan-refs-walk node refs)
refs)))
(define scan-refs-walk
(fn (node refs)
(cond
;; Symbol starting with ~ → component reference
(= (type-of node) "symbol")
(let ((name (symbol-name node)))
(when (starts-with? name "~")
(when (not (contains? refs name))
(append! refs name))))
;; List → recurse into all elements (covers all control flow branches)
(= (type-of node) "list")
(for-each (fn (item) (scan-refs-walk item refs)) node)
;; Dict → recurse into values
(= (type-of node) "dict")
(for-each (fn (key) (scan-refs-walk (dict-get node key) refs))
(keys node))
;; Literals (number, string, boolean, nil, keyword) → no refs
:else nil)))
;; --------------------------------------------------------------------------
;; 2. Transitive dependency closure
;; --------------------------------------------------------------------------
;; Given a component name and an environment, compute all components
;; that it can transitively render. Handles cycles via seen-set.
(define transitive-deps-walk
(fn (n seen env)
(when (not (contains? seen n))
(append! seen n)
(let ((val (env-get env n)))
(cond
(= (type-of val) "component")
(for-each (fn (ref) (transitive-deps-walk ref seen env))
(scan-refs (component-body val)))
(= (type-of val) "macro")
(for-each (fn (ref) (transitive-deps-walk ref seen env))
(scan-refs (macro-body val)))
:else nil)))))
(define transitive-deps
(fn (name env)
(let ((seen (list))
(key (if (starts-with? name "~") name (str "~" name))))
(transitive-deps-walk key seen env)
(filter (fn (x) (not (= x key))) seen))))
;; --------------------------------------------------------------------------
;; 3. Compute deps for all components in an environment
;; --------------------------------------------------------------------------
;; Iterates env, calls transitive-deps for each component, and
;; stores the result via the platform's component-set-deps! function.
;;
;; Platform interface:
;; (env-components env) → list of component names in env
;; (component-set-deps! comp deps) → store deps on component
(define compute-all-deps
(fn (env)
(for-each
(fn (name)
(let ((val (env-get env name)))
(when (= (type-of val) "component")
(component-set-deps! val (transitive-deps name env)))))
(env-components env))))
;; --------------------------------------------------------------------------
;; 4. Scan serialized SX source for component references
;; --------------------------------------------------------------------------
;; Regex-based extraction of (~name patterns from SX wire format.
;; Returns list of names WITH ~ prefix.
;;
;; Platform interface:
;; (regex-find-all pattern source) → list of matched group strings
(define scan-components-from-source
(fn (source)
(let ((matches (regex-find-all "\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)" source)))
(map (fn (m) (str "~" m)) matches))))
;; --------------------------------------------------------------------------
;; 5. Components needed for a page
;; --------------------------------------------------------------------------
;; Scans page source for direct component references, then computes
;; the transitive closure. Returns list of ~names.
(define components-needed
(fn (page-source env)
(let ((direct (scan-components-from-source page-source))
(all-needed (list)))
;; Add each direct ref + its transitive deps
(for-each
(fn (name)
(when (not (contains? all-needed name))
(append! all-needed name))
(let ((val (env-get env name)))
(let ((deps (if (and (= (type-of val) "component")
(not (empty? (component-deps val))))
(component-deps val)
(transitive-deps name env))))
(for-each
(fn (dep)
(when (not (contains? all-needed dep))
(append! all-needed dep)))
deps))))
direct)
all-needed)))
;; --------------------------------------------------------------------------
;; 6. Build per-page component bundle
;; --------------------------------------------------------------------------
;; Given page source and env, returns list of component names needed.
;; The host uses this list to serialize only the needed definitions
;; and compute a page-specific hash.
;;
;; This replaces the "send everything" approach with per-page bundles.
(define page-component-bundle
(fn (page-source env)
(components-needed page-source env)))
;; --------------------------------------------------------------------------
;; 7. CSS classes for a page
;; --------------------------------------------------------------------------
;; Returns the union of CSS classes from components this page uses,
;; plus classes from the page source itself.
;;
;; Platform interface:
;; (component-css-classes c) → set/list of class strings
;; (scan-css-classes source) → set/list of class strings from source
(define page-css-classes
(fn (page-source env)
(let ((needed (components-needed page-source env))
(classes (list)))
;; Collect classes from needed components
(for-each
(fn (name)
(let ((val (env-get env name)))
(when (= (type-of val) "component")
(for-each
(fn (cls)
(when (not (contains? classes cls))
(append! classes cls)))
(component-css-classes val)))))
needed)
;; Add classes from page source
(for-each
(fn (cls)
(when (not (contains? classes cls))
(append! classes cls)))
(scan-css-classes page-source))
classes)))
;; --------------------------------------------------------------------------
;; Platform interface summary
;; --------------------------------------------------------------------------
;;
;; From eval.sx (already provided):
;; (type-of x) → type string
;; (symbol-name s) → string name of symbol
;; (env-get env k) → value or nil
;;
;; New for deps.sx (each host implements):
;; (component-body c) → AST body of component
;; (component-name c) → name string
;; (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
;; (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
;; --------------------------------------------------------------------------

View File

@@ -1,4 +1,3 @@
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
"""
sx_ref.py -- Generated from reference SX evaluator specification.
@@ -879,6 +878,46 @@ assoc = PRIMITIVES["assoc"]
concat = PRIMITIVES["concat"]
# =========================================================================
# Platform: deps module — component dependency analysis
# =========================================================================
import re as _re
def component_deps(c):
"""Return cached deps list for a component (may be empty)."""
return list(c.deps) if hasattr(c, "deps") and c.deps else []
def component_set_deps(c, deps):
"""Cache deps on a component."""
c.deps = set(deps) if not isinstance(deps, set) else deps
def component_css_classes(c):
"""Return pre-scanned CSS class list for a component."""
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."""
return [k for k, v in env.items()
if isinstance(v, (Component, Macro))]
def regex_find_all(pattern, source):
"""Return list of capture group 1 matches."""
return [m.group(1) for m in _re.finditer(pattern, source)]
def scan_css_classes(source):
"""Extract CSS class strings from SX source."""
classes = set()
for m in _re.finditer(r':class\s+"([^"]*)"', source):
classes.update(m.group(1).split())
for m in _re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
for s in _re.findall(r'"([^"]*)"', m.group(1)):
classes.update(s.split())
for m in _re.finditer(r';;\s*@css\s+(.+)', source):
classes.update(m.group(1).split())
return list(classes)
# === Transpiled from eval ===
# trampoline
@@ -1139,6 +1178,39 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
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])
# === Transpiled from deps (component dependency analysis) ===
# scan-refs
scan_refs = lambda node: (lambda refs: _sx_begin(scan_refs_walk(node, refs), refs))([])
# scan-refs-walk
scan_refs_walk = lambda node, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(starts_with_p(name, '~')) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_refs_walk(item, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_refs_walk(dict_get(node, key), refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
# transitive-deps-walk
transitive_deps_walk = lambda n, seen, env: (_sx_begin(_sx_append(seen, n), (lambda val: (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(component_body(val))) if sx_truthy((type_of(val) == 'component')) else (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(macro_body(val))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
# transitive-deps
transitive_deps = lambda name, env: (lambda seen: (lambda key: _sx_begin(transitive_deps_walk(key, seen, env), filter(lambda x: (not sx_truthy((x == key))), seen)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([])
# compute-all-deps
compute_all_deps = lambda env: for_each(lambda name: (lambda val: (component_set_deps(val, transitive_deps(name, env)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
# scan-components-from-source
scan_components_from_source = lambda source: (lambda matches: map(lambda m: sx_str('~', m), matches))(regex_find_all('\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)', source))
# components-needed
components_needed = lambda page_source, env: (lambda direct: (lambda all_needed: _sx_begin(for_each(_sx_fn(lambda name: (
(_sx_append(all_needed, name) if sx_truthy((not sx_truthy(contains_p(all_needed, name)))) else NIL),
(lambda val: (lambda deps: for_each(lambda dep: (_sx_append(all_needed, dep) if sx_truthy((not sx_truthy(contains_p(all_needed, dep)))) else NIL), deps))((component_deps(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(empty_p(component_deps(val)))))) else transitive_deps(name, env))))(env_get(env, name))
)[-1]), direct), all_needed))([]))(scan_components_from_source(page_source))
# page-component-bundle
page_component_bundle = lambda page_source, env: components_needed(page_source, env)
# page-css-classes
page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env))
# =========================================================================
# Fixups -- wire up render adapter dispatch
# =========================================================================
@@ -1171,6 +1243,64 @@ def _wrap_aser_outputs():
aser_fragment = _aser_fragment_wrapped
# =========================================================================
# Extension: delimited continuations (shift/reset)
# =========================================================================
_RESET_RESUME = [] # stack of resume values; empty = not resuming
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
def sf_reset(args, env):
"""(reset body) -- establish a continuation delimiter."""
body = first(args)
try:
return trampoline(eval_expr(body, env))
except _ShiftSignal as sig:
def cont_fn(value=NIL):
_RESET_RESUME.append(value)
try:
return trampoline(eval_expr(body, env))
finally:
_RESET_RESUME.pop()
k = Continuation(cont_fn)
sig_env = dict(sig.env)
sig_env[sig.k_name] = k
return trampoline(eval_expr(sig.body, sig_env))
def sf_shift(args, env):
"""(shift k body) -- capture continuation to nearest reset."""
if _RESET_RESUME:
return _RESET_RESUME[-1]
k_name = symbol_name(first(args))
body = nth(args, 1)
raise _ShiftSignal(k_name, body, env)
# Wrap eval_list to inject shift/reset dispatch
_base_eval_list = eval_list
def _eval_list_with_continuations(expr, env):
head = first(expr)
if type_of(head) == "symbol":
name = symbol_name(head)
args = rest(expr)
if name == "reset":
return sf_reset(args, env)
if name == "shift":
return sf_shift(args, env)
return _base_eval_list(expr, env)
eval_list = _eval_list_with_continuations
# Inject into aser_special
_base_aser_special = aser_special
def _aser_special_with_continuations(name, expr, env):
if name == "reset":
return sf_reset(expr[1:], env)
if name == "shift":
return sf_shift(expr[1:], env)
return _base_aser_special(name, expr, env)
aser_special = _aser_special_with_continuations
# =========================================================================
# Public API
# =========================================================================

View File

@@ -147,7 +147,7 @@ class TestScanComponentsFromSx:
def test_basic(self):
source = '(~card :title "hi" (~badge :label "new"))'
refs = scan_components_from_sx(source)
assert refs == {"card", "badge"}
assert refs == {"~card", "~badge"}
def test_no_components(self):
source = '(div :class "p-4" (p "hello"))'

67
sx/sx/analyzer.sx Normal file
View File

@@ -0,0 +1,67 @@
;; Bundle analyzer — live demonstration of Phase 1 component dependency analysis.
;; Shows per-page component bundles vs total, visualizing payload savings.
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3
(defcomp ~bundle-analyzer-content (&key pages total-components total-macros)
(~doc-page :title "Page Bundle Analyzer"
(p :class "text-stone-600 mb-6"
"Live analysis of component dependency graphs across all pages in this app. "
"Each bar shows how many of the "
(strong (str total-components))
" total components a page actually needs, computed by the "
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
" transitive closure algorithm.")
(div :class "mb-8 grid grid-cols-3 gap-4"
(~analyzer-stat :label "Total Components" :value (str total-components)
:cls "text-violet-600")
(~analyzer-stat :label "Total Macros" :value (str total-macros)
:cls "text-stone-600")
(~analyzer-stat :label "Pages Analyzed" :value (str (len pages))
:cls "text-green-600"))
(~doc-section :title "Per-Page Bundles" :id "bundles"
(div :class "space-y-3"
(map (fn (page)
(~analyzer-row
:name (get page "name")
:path (get page "path")
:needed (get page "needed")
:direct (get page "direct")
:total total-components
:pct (get page "pct")
:savings (get page "savings")))
pages)))
(~doc-section :title "How It Works" :id "how"
(ol :class "list-decimal pl-5 space-y-2 text-stone-700"
(li (strong "Scan: ") "Regex finds all " (code "(~name") " patterns in the page's content expression.")
(li (strong "Resolve: ") "Each referenced component's body AST is walked to find transitive " (code "~") " references.")
(li (strong "Closure: ") "The full set is the union of direct + transitive deps, following chains through the component graph.")
(li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted."))
(p :class "mt-4 text-stone-600"
"The analysis handles circular references (via seen-set), "
"walks all branches of control flow (if/when/cond/case), "
"and includes macro definitions shared across components."))))
(defcomp ~analyzer-stat (&key label value cls)
(div :class "rounded-lg border border-stone-200 p-4 text-center"
(div :class (str "text-3xl font-bold " cls) value)
(div :class "text-sm text-stone-500 mt-1" label)))
(defcomp ~analyzer-row (&key name path needed direct total pct savings)
(div :class "rounded border border-stone-200 p-4"
(div :class "flex items-center justify-between mb-2"
(div
(span :class "font-mono font-semibold text-stone-800" name)
(span :class "text-stone-400 text-sm ml-2" path))
(div :class "text-right"
(span :class "font-mono text-sm"
(span :class "text-violet-700 font-bold" (str needed))
(span :class "text-stone-400" (str " / " total)))
(span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
(str savings "% saved"))))
(div :class "w-full bg-stone-200 rounded-full h-2.5"
(div :class "bg-violet-600 h-2.5 rounded-full transition-all"
:style (str "width: " pct "%")))))

51
sx/sx/boundary.sx Normal file
View File

@@ -0,0 +1,51 @@
;; SX docs service — page helper declarations.
(define-page-helper "highlight"
:params (code lang)
:returns "sx-source"
:service "sx")
(define-page-helper "primitives-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "special-forms-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "reference-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "attr-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "header-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "event-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "read-spec-file"
:params (filename)
:returns "string"
:service "sx")
(define-page-helper "bootstrapper-data"
:params (target)
:returns "dict"
:service "sx")
(define-page-helper "bundle-analyzer-data"
:params ()
:returns "dict"
:service "sx")

View File

@@ -14,6 +14,7 @@
(dict :label "Essays" :href "/essays/")
(dict :label "Specs" :href "/specs/")
(dict :label "Bootstrappers" :href "/bootstrappers/")
(dict :label "Isomorphism" :href "/isomorphism/")
(dict :label "Plans" :href "/plans/"))))
(<> (map (lambda (item)
(~nav-link

View File

@@ -99,11 +99,14 @@
(dict :label "Boot" :href "/specs/boot")
(dict :label "CSSX" :href "/specs/cssx")
(dict :label "Continuations" :href "/specs/continuations")
(dict :label "call/cc" :href "/specs/callcc")))
(dict :label "call/cc" :href "/specs/callcc")
(dict :label "Deps" :href "/specs/deps")))
(define isomorphism-nav-items (list
(dict :label "Roadmap" :href "/isomorphism/")
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")))
(define plans-nav-items (list
(dict :label "Isomorphic Architecture" :href "/plans/isomorphic-architecture"
:summary "Making the server/client boundary a sliding window — per-page bundles, smart expansion, SPA routing, client IO, streaming suspense.")
(dict :label "Reader Macros" :href "/plans/reader-macros"
:summary "Extensible parse-time transformations via # dispatch — datum comments, raw strings, and quote shorthand.")
(dict :label "SX-Activity" :href "/plans/sx-activity"
@@ -169,7 +172,12 @@
:desc "Full first-class continuations — call-with-current-continuation."
:prose "Full call/cc captures the entire remaining computation as a first-class function — not just up to a delimiter, but all the way to the top level. Invoking the continuation abandons the current computation entirely and resumes from where it was captured. Strictly more powerful than delimited continuations, but harder to implement in targets that don't support it natively. Recommended for Scheme and Haskell targets where it's natural. Python, JavaScript, and Rust targets should prefer delimited continuations (continuations.sx) unless full escape semantics are genuinely needed. Optional extension: the continuation type is shared with continuations.sx if both are loaded.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items extension-spec-items))))
(define module-spec-items (list
(dict :slug "deps" :filename "deps.sx" :title "Deps"
:desc "Component dependency analysis — per-page bundling, transitive closure, CSS scoping."
:prose "The deps module analyzes component dependency graphs to enable per-page bundling. Instead of sending every component definition to every page, deps.sx walks component AST bodies to find transitive ~component references, then computes the minimal set needed. It also collects per-page CSS classes from only the used components. All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")))
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))
(define find-spec
(fn (slug)

View File

@@ -621,51 +621,63 @@
(~doc-section :title "Phase 1: Component Distribution & Dependency Analysis" :id "phase-1"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
(p :class "text-violet-800" "Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates."))
(div :class "rounded border border-green-300 bg-green-50 p-4 mb-4"
(div :class "flex items-center gap-2 mb-2"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
(a :href "/specs/deps" :class "text-green-700 underline text-sm font-medium" "View canonical spec: deps.sx")
(a :href "/isomorphism/bundle-analyzer" :class "text-green-700 underline text-sm font-medium" "Live bundle analyzer"))
(p :class "text-green-900 font-medium" "What it enables")
(p :class "text-green-800" "Per-page component bundles instead of sending every definition to every page. Smaller payloads, faster boot, better cache hit rates."))
(~doc-subsection :title "The Problem"
(p "client_components_tag() in jinja_bridge.py serializes ALL entries in _COMPONENT_ENV. The sx_page() template sends everything or nothing based on a single global hash. No mechanism determines which components a page actually needs."))
(p "The page boot payload serializes every component definition in the environment. A page that uses 5 components still receives all 50+. No mechanism determines which components a page actually needs — the boundary between \"loaded\" and \"used\" is invisible."))
(~doc-subsection :title "Approach"
(~doc-subsection :title "Implementation"
(p "The dependency analysis algorithm is defined in "
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
" — a spec module bootstrapped to every host. Each host loads it via " (code "--spec-modules deps") " and provides 6 platform functions. The spec is the single source of truth; hosts are interchangeable.")
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Transitive closure analyzer")
(p "New module shared/sx/deps.py:")
(h4 :class "font-semibold text-stone-700" "1. Transitive closure (deps.sx)")
(p "9 functions that walk the component graph. The core:")
(~doc-code :code (highlight "(define (transitive-deps name env)\n (let ((key (if (starts-with? name \"~\") name\n (concat \"~\" name)))\n (seen (set-create)))\n (transitive-deps-walk key env seen)\n (set-remove seen key)))" "lisp"))
(p (code "scan-refs") " walks a component body AST collecting " (code "~") " symbols. "
(code "transitive-deps") " follows references recursively through the env. "
(code "compute-all-deps") " batch-computes and caches deps for every component. "
"Circular references terminate safely via a seen-set."))
(div
(h4 :class "font-semibold text-stone-700" "2. Page scanning")
(~doc-code :code (highlight "(define (components-needed page-source env)\n (let ((direct (scan-components-from-source page-source))\n (all-needed (set-create)))\n (for-each (fn (name) ...\n (set-add! all-needed name)\n (set-union! all-needed (component-deps comp)))\n direct)\n all-needed))" "lisp"))
(p (code "scan-components-from-source") " finds " (code "(~name") " patterns in serialized SX via regex. " (code "components-needed") " combines scanning with the cached transitive closure to produce the minimal component set for a page."))
(div
(h4 :class "font-semibold text-stone-700" "3. Per-page CSS scoping")
(p (code "page-css-classes") " unions the CSS classes from only the components in the page bundle. Pages that don't use a component never pay for its styles."))
(div
(h4 :class "font-semibold text-stone-700" "4. Platform interface")
(p "The spec declares 6 functions each host implements natively — the only host-specific code:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Walk Component.body AST, collect all Symbol refs starting with ~")
(li "Recursively follow into their bodies")
(li "Handle control forms (if/when/cond/case) — include ALL branches")
(li "Handle macros — expand during walk using limited eval"))
(~doc-code :code (highlight "def transitive_deps(name: str, env: dict) -> set[str]:\n \"\"\"Compute transitive component dependencies.\"\"\"\n seen = set()\n def walk(n):\n if n in seen: return\n seen.add(n)\n comp = env.get(n)\n if comp:\n for dep in _scan_ast(comp.body):\n walk(dep)\n walk(name)\n return seen - {name}" "python")))
(li (code "component-deps") " / " (code "component-set-deps!") " — read/write the cached deps set on a component object")
(li (code "component-css-classes") " — read pre-scanned CSS class names from a component")
(li (code "env-components") " — enumerate all component entries in an environment")
(li (code "regex-find-all") " / " (code "scan-css-classes") " — host-native regex and CSS scanning")))))
(div
(h4 :class "font-semibold text-stone-700" "2. Runtime component scanning")
(p "After _aser serializes page content, scan the SX string for (~name patterns (parallel to existing scan_classes_from_sx for CSS). Then compute transitive closure to get sub-components."))
(div
(h4 :class "font-semibold text-stone-700" "3. Per-page component block")
(p "In sx_page() — replace all-or-nothing with page-specific bundle. Hash changes per page, localStorage cache keyed by route pattern."))
(div
(h4 :class "font-semibold text-stone-700" "4. SX partial responses")
(p "components_for_request() already diffs against SX-Components header. Enhance with transitive closure so only truly needed missing components are sent."))))
(~doc-subsection :title "Files"
(~doc-subsection :title "Spec module"
(p "deps.sx is loaded as a " (strong "spec module") " — an optional extension to the core spec. The bootstrapper flag " (code "--spec-modules deps") " includes it in the generated output alongside the core evaluator, parser, and renderer. The same mechanism can carry future modules (e.g., io-detection for Phase 2) without changing the bootstrapper architecture.")
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "New: shared/sx/deps.pydependency analysis")
(li "shared/sx/jinja_bridge.py — per-page bundle generation")
(li "shared/sx/helpers.py — modify sx_page() and sx_response()")
(li "shared/sx/types.py — add deps: set[str] to Component")
(li "shared/sx/ref/boot.sx — per-page component caching")))
(li "shared/sx/ref/deps.sxcanonical spec (9 functions, 6 platform declarations)")
(li "Bootstrapped to all host targets via --spec-modules deps")))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Page using 5/50 components → data-components block contains only those 5 + transitive deps")
(li "No \"Unknown component\" errors after bundle reduction")
(li "Payload size reduction measurable"))))
(li "15 dedicated tests: scan, transitive closure, circular deps, compute-all, components-needed")
(li "Bootstrapped output verified on both host targets")
(li "Full test suite passes with zero regressions")
(li (a :href "/isomorphism/bundle-analyzer" :class "text-violet-700 underline" "Live bundle analyzer") " shows real per-page savings on this app"))))
;; -----------------------------------------------------------------------
;; Phase 2

View File

@@ -179,7 +179,10 @@ boot.sx depends on: cssx, orchestration, adapter-dom, render
;; Extensions (optional — loaded only when target requests them)
continuations.sx depends on: eval (optional)
callcc.sx depends on: eval (optional)")))
callcc.sx depends on: eval (optional)
;; Spec modules (optional — loaded via --spec-modules)
deps.sx depends on: eval (optional)")))
(div :class "space-y-3"
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions")

View File

@@ -387,6 +387,49 @@
:bootstrapper-source bootstrapper-source
:bootstrapped-output bootstrapped-output))))
;; ---------------------------------------------------------------------------
;; Isomorphism section
;; ---------------------------------------------------------------------------
(defpage isomorphism-index
:path "/isomorphism/"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Roadmap")
:selected "Roadmap")
:content (~plan-isomorphic-content))
(defpage isomorphism-page
:path "/isomorphism/<slug>"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items
:current (find-current isomorphism-nav-items slug))
:selected (or (find-current isomorphism-nav-items slug) ""))
:content (case slug
"bundle-analyzer" (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros)
:else (~plan-isomorphic-content)))
(defpage bundle-analyzer
:path "/isomorphism/bundle-analyzer"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Bundle Analyzer")
:selected "Bundle Analyzer")
:data (bundle-analyzer-data)
:content (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros))
;; ---------------------------------------------------------------------------
;; Plans section
;; ---------------------------------------------------------------------------
@@ -413,7 +456,6 @@
:current (find-current plans-nav-items slug))
:selected (or (find-current plans-nav-items slug) ""))
:content (case slug
"isomorphic-architecture" (~plan-isomorphic-content)
"reader-macros" (~plan-reader-macros-content)
"sx-activity" (~plan-sx-activity-content)
:else (~plans-index-content)))

View File

@@ -21,6 +21,7 @@ def _register_sx_helpers() -> None:
"event-detail-data": _event_detail_data,
"read-spec-file": _read_spec_file,
"bootstrapper-data": _bootstrapper_data,
"bundle-analyzer-data": _bundle_analyzer_data,
})
@@ -265,6 +266,44 @@ def _bootstrapper_data(target: str) -> dict:
}
def _bundle_analyzer_data() -> dict:
"""Compute per-page component bundle analysis for the sx-docs app."""
from shared.sx.jinja_bridge import get_component_env
from shared.sx.pages import get_all_pages
from shared.sx.deps import components_needed, scan_components_from_sx
from shared.sx.parser import serialize
from shared.sx.types import Component, Macro
env = get_component_env()
total_components = sum(1 for v in env.values() if isinstance(v, Component))
total_macros = sum(1 for v in env.values() if isinstance(v, Macro))
pages_data = []
for name, page_def in sorted(get_all_pages("sx").items()):
content_sx = serialize(page_def.content_expr)
direct = scan_components_from_sx(content_sx)
needed = components_needed(content_sx, env)
n = len(needed)
pct = round(n / total_components * 100) if total_components else 0
savings = 100 - pct
pages_data.append({
"name": name,
"path": page_def.path,
"direct": len(direct),
"needed": n,
"pct": pct,
"savings": savings,
})
pages_data.sort(key=lambda p: p["needed"], reverse=True)
return {
"pages": pages_data,
"total-components": total_components,
"total-macros": total_macros,
}
def _attr_detail_data(slug: str) -> dict:
"""Return attribute detail data for a specific attribute slug.