Compare commits
13 Commits
2866bcbfc3
...
652e7f81c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 652e7f81c8 | |||
| 8ff9827d7b | |||
| 07a73821e7 | |||
| 44d5414bc6 | |||
| a90c8bf3fc | |||
| a06400370a | |||
| 0191948b6e | |||
| 9ac1d273e2 | |||
| e36a036873 | |||
| d6ca185975 | |||
| 0ebf3c27fd | |||
| 4c97b03dda | |||
| 6739343a06 |
41
blog/sx/boundary.sx
Normal file
41
blog/sx/boundary.sx
Normal 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")
|
||||
@@ -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
61
events/sx/boundary.sx
Normal 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
21
market/sx/boundary.sx
Normal 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")
|
||||
@@ -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)"
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
118
shared/sx/ref/boundary-app.sx
Normal file
118
shared/sx/ref/boundary-app.sx
Normal 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)
|
||||
@@ -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
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
230
shared/sx/ref/deps.sx
Normal 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
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
67
sx/sx/analyzer.sx
Normal 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
51
sx/sx/boundary.sx
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.py — dependency 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.sx — canonical 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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user