Phase 5: async IO rendering — components call IO primitives client-side
Wire async rendering into client-side routing: pages whose component trees reference IO primitives (highlight, current-user, etc.) now render client-side via Promise-aware asyncRenderToDom. IO calls proxy through /sx/io/<name> endpoint, which falls back to page helpers. - Add has-io flag to page registry entries (helpers.py) - Remove IO purity filter — include IO-dependent components in bundles - Extend try-client-route with 4 paths: pure, data, IO, data+IO - Convert tryAsyncEvalContent to callback style, add platform mapping - IO proxy falls back to page helpers (highlight works via proxy) - Demo page: /isomorphism/async-io with inline highlight calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-07T02:10:28Z";
|
||||
var SX_VERSION = "2026-03-07T02:32:34Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -565,92 +565,6 @@
|
||||
return makeThunk(componentBody(comp), local);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
// =========================================================================
|
||||
@@ -2116,24 +2030,31 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
var pageName = get(match, "name");
|
||||
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
|
||||
var target = resolveRouteTarget(targetSel);
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (isSxTruthy(get(match, "has-data")) ? (function() {
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() {
|
||||
var hasIo = get(match, "has-io");
|
||||
return (isSxTruthy(get(match, "has-data")) ? (function() {
|
||||
var cacheKey = pageDataCacheKey(pageName, params);
|
||||
var cached = pageDataCacheGet(cacheKey);
|
||||
return (isSxTruthy(cached) ? (function() {
|
||||
var env = merge(closure, params, cached);
|
||||
return (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+cache+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true));
|
||||
})());
|
||||
})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data);
|
||||
return (function() {
|
||||
var env = merge(closure, params, data);
|
||||
return (isSxTruthy(hasIo) ? tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data+async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }) : (function() {
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
|
||||
})());
|
||||
})(); }), true));
|
||||
})() : (function() {
|
||||
})() : (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, merge(closure, params), function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
|
||||
var env = merge(closure, params);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
|
||||
})())));
|
||||
})()));
|
||||
})()));
|
||||
})());
|
||||
})());
|
||||
})(); };
|
||||
@@ -2555,120 +2476,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
})(); };
|
||||
|
||||
// boot-init
|
||||
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), 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(!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(!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 !isSxTruthy((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(!isSxTruthy(contains(allNeeded, name)))) {
|
||||
allNeeded.push(name);
|
||||
}
|
||||
(function() {
|
||||
var val = envGet(env, name);
|
||||
return (function() {
|
||||
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env));
|
||||
return forEach(function(dep) { return (isSxTruthy(!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(!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(!isSxTruthy(contains(classes, cls)))) {
|
||||
classes.push(cls);
|
||||
} } }
|
||||
return classes;
|
||||
})(); };
|
||||
|
||||
// scan-io-refs-walk
|
||||
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// scan-io-refs
|
||||
var scanIoRefs = function(node, ioNames) { return (function() {
|
||||
var refs = [];
|
||||
scanIoRefsWalk(node, ioNames, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// transitive-io-refs-walk
|
||||
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-io-refs
|
||||
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
|
||||
var allRefs = [];
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
|
||||
return allRefs;
|
||||
})(); };
|
||||
|
||||
// compute-all-io-refs
|
||||
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// component-pure?
|
||||
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
|
||||
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), initIoPrimitives(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
|
||||
|
||||
// === Transpiled from router (client-side route matching) ===
|
||||
@@ -3510,6 +3318,32 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
}
|
||||
|
||||
// Async eval with callback — used for pages with IO deps.
|
||||
// Calls callback(rendered) when done, callback(null) on failure.
|
||||
function tryAsyncEvalContent(source, env, callback) {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
try {
|
||||
var result = asyncSxRenderWithEnv(source, merged);
|
||||
if (isPromise(result)) {
|
||||
result.then(function(rendered) {
|
||||
callback(rendered);
|
||||
}).catch(function(e) {
|
||||
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
|
||||
callback(null);
|
||||
});
|
||||
} else {
|
||||
callback(result);
|
||||
}
|
||||
} catch (e) {
|
||||
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePageData(pageName, params, callback) {
|
||||
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
|
||||
// The spec only knows about resolve-page-data(name, params, callback) —
|
||||
@@ -3939,6 +3773,605 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
|
||||
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
|
||||
|
||||
// =========================================================================
|
||||
// Async IO: Promise-aware rendering for client-side IO primitives
|
||||
// =========================================================================
|
||||
//
|
||||
// IO primitives (query, current-user, etc.) return Promises on the client.
|
||||
// asyncRenderToDom walks the component tree; when it encounters an IO
|
||||
// primitive, it awaits the Promise and continues rendering.
|
||||
//
|
||||
// The sync evaluator/renderer is untouched. This is a separate async path
|
||||
// used only when a page's component tree contains IO references.
|
||||
|
||||
var IO_PRIMITIVES = {};
|
||||
|
||||
function registerIoPrimitive(name, fn) {
|
||||
IO_PRIMITIVES[name] = fn;
|
||||
}
|
||||
|
||||
function isPromise(x) {
|
||||
return x != null && typeof x === "object" && typeof x.then === "function";
|
||||
}
|
||||
|
||||
// Async trampoline: resolves thunks, awaits Promises
|
||||
function asyncTrampoline(val) {
|
||||
if (isPromise(val)) return val.then(asyncTrampoline);
|
||||
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
|
||||
return val;
|
||||
}
|
||||
|
||||
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
|
||||
function asyncEval(expr, env) {
|
||||
// Intercept IO primitive calls at the AST level
|
||||
if (Array.isArray(expr) && expr.length > 0) {
|
||||
var head = expr[0];
|
||||
if (head && head._sym) {
|
||||
var name = head.name;
|
||||
if (IO_PRIMITIVES[name]) {
|
||||
// Evaluate args, then call the IO primitive
|
||||
return asyncEvalIoCall(name, expr.slice(1), env);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-IO: use sync eval, but result might be a thunk
|
||||
var result = evalExpr(expr, env);
|
||||
return asyncTrampoline(result);
|
||||
}
|
||||
|
||||
function asyncEvalIoCall(name, rawArgs, env) {
|
||||
// Parse keyword args and positional args, evaluating each (may be async)
|
||||
var kwargs = {};
|
||||
var args = [];
|
||||
var promises = [];
|
||||
var i = 0;
|
||||
while (i < rawArgs.length) {
|
||||
var arg = rawArgs[i];
|
||||
if (arg && arg._kw && (i + 1) < rawArgs.length) {
|
||||
var kName = arg.name;
|
||||
var kVal = asyncEval(rawArgs[i + 1], env);
|
||||
if (isPromise(kVal)) {
|
||||
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
|
||||
} else {
|
||||
kwargs[kName] = kVal;
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
var aVal = asyncEval(arg, env);
|
||||
if (isPromise(aVal)) {
|
||||
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
|
||||
args.push(null); // placeholder
|
||||
} else {
|
||||
args.push(aVal);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
var ioFn = IO_PRIMITIVES[name];
|
||||
if (promises.length > 0) {
|
||||
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
|
||||
}
|
||||
return ioFn(args, kwargs);
|
||||
}
|
||||
|
||||
// Async render-to-dom: returns Promise<Node> or Node
|
||||
function asyncRenderToDom(expr, env, ns) {
|
||||
// Literals
|
||||
if (expr === NIL || expr === null || expr === undefined) return null;
|
||||
if (expr === true || expr === false) return null;
|
||||
if (typeof expr === "string") return document.createTextNode(expr);
|
||||
if (typeof expr === "number") return document.createTextNode(String(expr));
|
||||
|
||||
// Symbol -> async eval then render
|
||||
if (expr && expr._sym) {
|
||||
var val = asyncEval(expr, env);
|
||||
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(val, env, ns);
|
||||
}
|
||||
|
||||
// Keyword
|
||||
if (expr && expr._kw) return document.createTextNode(expr.name);
|
||||
|
||||
// DocumentFragment / DOM nodes pass through
|
||||
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
|
||||
|
||||
// Dict -> skip
|
||||
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
|
||||
|
||||
// List
|
||||
if (!Array.isArray(expr) || expr.length === 0) return null;
|
||||
|
||||
var head = expr[0];
|
||||
if (!head) return null;
|
||||
|
||||
// Symbol head
|
||||
if (head._sym) {
|
||||
var hname = head.name;
|
||||
|
||||
// IO primitive
|
||||
if (IO_PRIMITIVES[hname]) {
|
||||
var ioResult = asyncEval(expr, env);
|
||||
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(ioResult, env, ns);
|
||||
}
|
||||
|
||||
// Fragment
|
||||
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
|
||||
|
||||
// raw!
|
||||
if (hname === "raw!") {
|
||||
return asyncEvalRaw(expr.slice(1), env);
|
||||
}
|
||||
|
||||
// Special forms that need async handling
|
||||
if (hname === "if") return asyncRenderIf(expr, env, ns);
|
||||
if (hname === "when") return asyncRenderWhen(expr, env, ns);
|
||||
if (hname === "cond") return asyncRenderCond(expr, env, ns);
|
||||
if (hname === "case") return asyncRenderCase(expr, env, ns);
|
||||
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
|
||||
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
|
||||
if (hname === "map") return asyncRenderMap(expr, env, ns);
|
||||
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
|
||||
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
|
||||
|
||||
// define/defcomp/defmacro — eval for side effects
|
||||
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
|
||||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
|
||||
trampoline(evalExpr(expr, env));
|
||||
return null;
|
||||
}
|
||||
|
||||
// quote
|
||||
if (hname === "quote") return null;
|
||||
|
||||
// lambda/fn
|
||||
if (hname === "lambda" || hname === "fn") {
|
||||
trampoline(evalExpr(expr, env));
|
||||
return null;
|
||||
}
|
||||
|
||||
// and/or — eval and render result
|
||||
if (hname === "and" || hname === "or" || hname === "->") {
|
||||
var aoResult = asyncEval(expr, env);
|
||||
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(aoResult, env, ns);
|
||||
}
|
||||
|
||||
// set!
|
||||
if (hname === "set!") {
|
||||
asyncEval(expr, env);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component
|
||||
if (hname.charAt(0) === "~") {
|
||||
var comp = env[hname];
|
||||
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
|
||||
if (comp && comp._macro) {
|
||||
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
|
||||
return asyncRenderToDom(expanded, env, ns);
|
||||
}
|
||||
}
|
||||
|
||||
// Macro
|
||||
if (env[hname] && env[hname]._macro) {
|
||||
var mac = env[hname];
|
||||
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
|
||||
return asyncRenderToDom(expanded, env, ns);
|
||||
}
|
||||
|
||||
// HTML tag
|
||||
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
|
||||
return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// html: prefix
|
||||
if (hname.indexOf("html:") === 0) {
|
||||
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// Custom element
|
||||
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
|
||||
return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// SVG context
|
||||
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
|
||||
// Fallback: eval and render
|
||||
var fResult = asyncEval(expr, env);
|
||||
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(fResult, env, ns);
|
||||
}
|
||||
|
||||
// Non-symbol head: eval call
|
||||
var cResult = asyncEval(expr, env);
|
||||
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(cResult, env, ns);
|
||||
}
|
||||
|
||||
function asyncRenderChildren(exprs, env, ns) {
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < exprs.length; i++) {
|
||||
var result = asyncRenderToDom(exprs[i], env, ns);
|
||||
if (isPromise(result)) {
|
||||
// Insert placeholder, replace when resolved
|
||||
var placeholder = document.createComment("async");
|
||||
frag.appendChild(placeholder);
|
||||
(function(ph) {
|
||||
pending.push(result.then(function(node) {
|
||||
if (node) ph.parentNode.replaceChild(node, ph);
|
||||
else ph.parentNode.removeChild(ph);
|
||||
}));
|
||||
})(placeholder);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
return Promise.all(pending).then(function() { return frag; });
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
function asyncRenderElement(tag, args, env, ns) {
|
||||
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
|
||||
var el = domCreateElement(tag, newNs);
|
||||
var pending = [];
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
var attrName = arg.name;
|
||||
var attrVal = asyncEval(args[i + 1], env);
|
||||
i++;
|
||||
if (isPromise(attrVal)) {
|
||||
(function(an, av) {
|
||||
pending.push(av.then(function(v) {
|
||||
if (!isNil(v) && v !== false) {
|
||||
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
|
||||
else if (v === true) el.setAttribute(an, "");
|
||||
else el.setAttribute(an, String(v));
|
||||
}
|
||||
}));
|
||||
})(attrName, attrVal);
|
||||
} else {
|
||||
if (!isNil(attrVal) && attrVal !== false) {
|
||||
if (attrName === "class" && attrVal && attrVal._styleValue) {
|
||||
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
|
||||
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
|
||||
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
|
||||
} else if (contains(BOOLEAN_ATTRS, attrName)) {
|
||||
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
|
||||
} else if (attrVal === true) {
|
||||
el.setAttribute(attrName, "");
|
||||
} else {
|
||||
el.setAttribute(attrName, String(attrVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!isVoid) {
|
||||
var child = asyncRenderToDom(arg, env, newNs);
|
||||
if (isPromise(child)) {
|
||||
var placeholder = document.createComment("async");
|
||||
el.appendChild(placeholder);
|
||||
(function(ph) {
|
||||
pending.push(child.then(function(node) {
|
||||
if (node) ph.parentNode.replaceChild(node, ph);
|
||||
else ph.parentNode.removeChild(ph);
|
||||
}));
|
||||
})(placeholder);
|
||||
} else if (child) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
|
||||
return el;
|
||||
}
|
||||
|
||||
function asyncRenderComponent(comp, args, env, ns) {
|
||||
var kwargs = {};
|
||||
var children = [];
|
||||
var pending = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
var kName = arg.name;
|
||||
var kVal = asyncEval(args[i + 1], env);
|
||||
if (isPromise(kVal)) {
|
||||
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
|
||||
} else {
|
||||
kwargs[kName] = kVal;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
children.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
function doRender() {
|
||||
var local = Object.create(componentClosure(comp));
|
||||
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
|
||||
var params = componentParams(comp);
|
||||
for (var j = 0; j < params.length; j++) {
|
||||
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
|
||||
}
|
||||
if (componentHasChildren(comp)) {
|
||||
var childResult = asyncRenderChildren(children, env, ns);
|
||||
if (isPromise(childResult)) {
|
||||
return childResult.then(function(childFrag) {
|
||||
local["children"] = childFrag;
|
||||
return asyncRenderToDom(componentBody(comp), local, ns);
|
||||
});
|
||||
}
|
||||
local["children"] = childResult;
|
||||
}
|
||||
return asyncRenderToDom(componentBody(comp), local, ns);
|
||||
}
|
||||
|
||||
if (pending.length > 0) return Promise.all(pending).then(doRender);
|
||||
return doRender();
|
||||
}
|
||||
|
||||
function asyncRenderIf(expr, env, ns) {
|
||||
var cond = asyncEval(expr[1], env);
|
||||
if (isPromise(cond)) {
|
||||
return cond.then(function(v) {
|
||||
return isSxTruthy(v)
|
||||
? asyncRenderToDom(expr[2], env, ns)
|
||||
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
|
||||
});
|
||||
}
|
||||
return isSxTruthy(cond)
|
||||
? asyncRenderToDom(expr[2], env, ns)
|
||||
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
|
||||
}
|
||||
|
||||
function asyncRenderWhen(expr, env, ns) {
|
||||
var cond = asyncEval(expr[1], env);
|
||||
if (isPromise(cond)) {
|
||||
return cond.then(function(v) {
|
||||
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
|
||||
});
|
||||
}
|
||||
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
|
||||
}
|
||||
|
||||
function asyncRenderCond(expr, env, ns) {
|
||||
var clauses = expr.slice(1);
|
||||
function step(idx) {
|
||||
if (idx >= clauses.length) return null;
|
||||
var clause = clauses[idx];
|
||||
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
|
||||
var test = clause[0];
|
||||
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
|
||||
(test && test._kw && test.name === "else")) {
|
||||
return asyncRenderToDom(clause[1], env, ns);
|
||||
}
|
||||
var v = asyncEval(test, env);
|
||||
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
|
||||
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
|
||||
}
|
||||
return step(0);
|
||||
}
|
||||
|
||||
function asyncRenderCase(expr, env, ns) {
|
||||
var matchVal = asyncEval(expr[1], env);
|
||||
function doCase(mv) {
|
||||
var clauses = expr.slice(2);
|
||||
for (var i = 0; i < clauses.length - 1; i += 2) {
|
||||
var test = clauses[i];
|
||||
if ((test && test._kw && test.name === "else") ||
|
||||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
|
||||
return asyncRenderToDom(clauses[i + 1], env, ns);
|
||||
}
|
||||
var tv = trampoline(evalExpr(test, env));
|
||||
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
|
||||
return asyncRenderToDom(clauses[i + 1], env, ns);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isPromise(matchVal)) return matchVal.then(doCase);
|
||||
return doCase(matchVal);
|
||||
}
|
||||
|
||||
function asyncRenderLet(expr, env, ns) {
|
||||
var bindings = expr[1];
|
||||
var local = Object.create(env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
|
||||
function bindStep(idx) {
|
||||
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
// Nested pairs: ((a 1) (b 2))
|
||||
if (bindings.length > 0 && Array.isArray(bindings[0])) {
|
||||
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
var b = bindings[idx];
|
||||
var vname = b[0]._sym ? b[0].name : String(b[0]);
|
||||
var val = asyncEval(b[1], local);
|
||||
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
|
||||
local[vname] = val;
|
||||
return bindStep(idx + 1);
|
||||
}
|
||||
// Flat pairs: (a 1 b 2)
|
||||
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
|
||||
var vv = asyncEval(bindings[idx + 1], local);
|
||||
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
|
||||
local[vn] = vv;
|
||||
return bindStep(idx + 2);
|
||||
}
|
||||
return bindStep(0);
|
||||
}
|
||||
|
||||
function asyncRenderMap(expr, env, ns) {
|
||||
var fn = asyncEval(expr[1], env);
|
||||
var coll = asyncEval(expr[2], env);
|
||||
function doMap(f, c) {
|
||||
if (!Array.isArray(c)) return null;
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var item = c[i];
|
||||
var result;
|
||||
if (f && f._lambda) {
|
||||
var lenv = Object.create(f._closure || env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
|
||||
lenv[f._params[0]] = item;
|
||||
result = asyncRenderToDom(f._body, lenv, null);
|
||||
} else if (typeof f === "function") {
|
||||
var r = f(item);
|
||||
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
|
||||
} else {
|
||||
result = asyncRenderToDom(item, env, null);
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
var ph = document.createComment("async");
|
||||
frag.appendChild(ph);
|
||||
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(function() { return frag; });
|
||||
return frag;
|
||||
}
|
||||
if (isPromise(fn) || isPromise(coll)) {
|
||||
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
|
||||
.then(function(r) { return doMap(r[0], r[1]); });
|
||||
}
|
||||
return doMap(fn, coll);
|
||||
}
|
||||
|
||||
function asyncRenderMapIndexed(expr, env, ns) {
|
||||
var fn = asyncEval(expr[1], env);
|
||||
var coll = asyncEval(expr[2], env);
|
||||
function doMap(f, c) {
|
||||
if (!Array.isArray(c)) return null;
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var item = c[i];
|
||||
var result;
|
||||
if (f && f._lambda) {
|
||||
var lenv = Object.create(f._closure || env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
|
||||
lenv[f._params[0]] = i;
|
||||
lenv[f._params[1]] = item;
|
||||
result = asyncRenderToDom(f._body, lenv, null);
|
||||
} else if (typeof f === "function") {
|
||||
var r = f(i, item);
|
||||
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
|
||||
} else {
|
||||
result = asyncRenderToDom(item, env, null);
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
var ph = document.createComment("async");
|
||||
frag.appendChild(ph);
|
||||
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(function() { return frag; });
|
||||
return frag;
|
||||
}
|
||||
if (isPromise(fn) || isPromise(coll)) {
|
||||
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
|
||||
.then(function(r) { return doMap(r[0], r[1]); });
|
||||
}
|
||||
return doMap(fn, coll);
|
||||
}
|
||||
|
||||
function asyncEvalRaw(args, env) {
|
||||
var parts = [];
|
||||
var pending = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var val = asyncEval(args[i], env);
|
||||
if (isPromise(val)) {
|
||||
(function(idx) {
|
||||
pending.push(val.then(function(v) { parts[idx] = v; }));
|
||||
})(parts.length);
|
||||
parts.push(null);
|
||||
} else {
|
||||
parts.push(val);
|
||||
}
|
||||
}
|
||||
function assemble() {
|
||||
var html = "";
|
||||
for (var j = 0; j < parts.length; j++) {
|
||||
var p = parts[j];
|
||||
if (p && p._rawHtml) html += p.html;
|
||||
else if (typeof p === "string") html += p;
|
||||
else if (p != null && !isNil(p)) html += String(p);
|
||||
}
|
||||
var el = document.createElement("span");
|
||||
el.innerHTML = html;
|
||||
var frag = document.createDocumentFragment();
|
||||
while (el.firstChild) frag.appendChild(el.firstChild);
|
||||
return frag;
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(assemble);
|
||||
return assemble();
|
||||
}
|
||||
|
||||
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
|
||||
function asyncSxRenderWithEnv(source, extraEnv) {
|
||||
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
|
||||
var exprs = parse(source);
|
||||
if (!_hasDom) return Promise.resolve(null);
|
||||
return asyncRenderChildren(exprs, env, null);
|
||||
}
|
||||
|
||||
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
|
||||
function registerProxiedIo(name) {
|
||||
registerIoPrimitive(name, function(args, kwargs) {
|
||||
var url = "/sx/io/" + encodeURIComponent(name);
|
||||
var qs = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
|
||||
}
|
||||
for (var k in kwargs) {
|
||||
if (kwargs.hasOwnProperty(k)) {
|
||||
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
|
||||
}
|
||||
}
|
||||
if (qs.length) url += "?" + qs.join("&");
|
||||
return fetch(url, { headers: { "SX-Request": "true" } })
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
logWarn("sx:io " + name + " failed " + resp.status);
|
||||
return NIL;
|
||||
}
|
||||
return resp.text();
|
||||
})
|
||||
.then(function(text) {
|
||||
if (!text || text === "nil") return NIL;
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
return exprs.length === 1 ? exprs[0] : exprs;
|
||||
} catch (e) {
|
||||
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
|
||||
return NIL;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Register default proxied IO primitives
|
||||
function initIoPrimitives() {
|
||||
var defaults = [
|
||||
"highlight", "current-user", "request-arg", "request-path",
|
||||
"app-url", "asset-url", "config"
|
||||
];
|
||||
for (var i = 0; i < defaults.length; i++) {
|
||||
registerProxiedIo(defaults[i]);
|
||||
}
|
||||
logInfo("sx:io registered " + defaults.length + " proxied primitives");
|
||||
}
|
||||
|
||||
|
||||
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
|
||||
var parse = sxParse;
|
||||
|
||||
@@ -4007,20 +4440,13 @@ 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,
|
||||
scanIoRefs: scanIoRefs,
|
||||
transitiveIoRefs: transitiveIoRefs,
|
||||
computeAllIoRefs: computeAllIoRefs,
|
||||
componentPure_p: componentPure_p,
|
||||
splitPathSegments: splitPathSegments,
|
||||
parseRoutePattern: parseRoutePattern,
|
||||
matchRoute: matchRoute,
|
||||
findMatchingRoute: findMatchingRoute,
|
||||
registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,
|
||||
asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,
|
||||
asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
@@ -691,6 +691,15 @@ def _build_pages_sx(service: str) -> str:
|
||||
deps = components_needed(content_src, _COMPONENT_ENV)
|
||||
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
|
||||
|
||||
# Check if any dep component has IO refs (needs async rendering)
|
||||
from .types import Component as _Comp
|
||||
has_io = "false"
|
||||
for dep_name in deps:
|
||||
comp = _COMPONENT_ENV.get(dep_name)
|
||||
if isinstance(comp, _Comp) and comp.io_refs:
|
||||
has_io = "true"
|
||||
break
|
||||
|
||||
# Build closure as SX dict
|
||||
closure_parts: list[str] = []
|
||||
for k, v in page_def.closure.items():
|
||||
@@ -703,6 +712,7 @@ def _build_pages_sx(service: str) -> str:
|
||||
+ " :path " + _sx_literal(page_def.path)
|
||||
+ " :auth " + _sx_literal(auth)
|
||||
+ " :has-data " + has_data
|
||||
+ " :has-io " + has_io
|
||||
+ " :content " + _sx_literal(content_src)
|
||||
+ " :deps " + deps_sx
|
||||
+ " :closure " + closure_sx + "}"
|
||||
|
||||
@@ -349,23 +349,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
|
||||
|
||||
needed = components_needed(page_sx, _COMPONENT_ENV)
|
||||
|
||||
# Include deps for :data pages whose component trees are fully pure
|
||||
# (no IO refs). Pages with IO deps must render server-side.
|
||||
# Include deps for all :data pages so the client can render them.
|
||||
# Pages with IO deps use the async render path (Phase 5) — the IO
|
||||
# primitives are proxied via /sx/io/<name>.
|
||||
if service:
|
||||
from .pages import get_all_pages
|
||||
for page_def in get_all_pages(service).values():
|
||||
if page_def.data_expr is not None and page_def.content_expr is not None:
|
||||
content_src = serialize(page_def.content_expr)
|
||||
data_deps = components_needed(content_src, _COMPONENT_ENV)
|
||||
# Check if any dep component has IO refs
|
||||
has_io = False
|
||||
for dep_name in data_deps:
|
||||
comp = _COMPONENT_ENV.get(dep_name)
|
||||
if isinstance(comp, Component) and comp.io_refs:
|
||||
has_io = True
|
||||
break
|
||||
if not has_io:
|
||||
needed |= data_deps
|
||||
needed |= components_needed(content_src, _COMPONENT_ENV)
|
||||
|
||||
if not needed:
|
||||
return "", ""
|
||||
@@ -416,13 +408,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
|
||||
for page_def in get_all_pages(service).values():
|
||||
if page_def.data_expr is not None and page_def.content_expr is not None:
|
||||
content_src = serialize(page_def.content_expr)
|
||||
data_deps = components_needed(content_src, _COMPONENT_ENV)
|
||||
has_io = any(
|
||||
isinstance(_COMPONENT_ENV.get(d), Component) and _COMPONENT_ENV.get(d).io_refs
|
||||
for d in data_deps
|
||||
)
|
||||
if not has_io:
|
||||
needed |= data_deps
|
||||
needed |= components_needed(content_src, _COMPONENT_ENV)
|
||||
classes: set[str] = set()
|
||||
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
|
||||
@@ -331,6 +331,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||
if has_data_pages:
|
||||
auto_mount_page_data(app, service_name)
|
||||
|
||||
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
|
||||
mount_io_endpoint(app, service_name)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -535,3 +538,73 @@ def auto_mount_page_data(app: Any, service_name: str) -> None:
|
||||
methods=["GET"],
|
||||
)
|
||||
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
|
||||
|
||||
|
||||
def mount_io_endpoint(app: Any, service_name: str) -> None:
|
||||
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
|
||||
|
||||
The client can call any allowed IO primitive or page helper via GET/POST.
|
||||
Result is returned as SX wire format (text/sx).
|
||||
|
||||
Falls back to page helpers when the name isn't a global IO primitive,
|
||||
so service-specific functions like ``highlight`` work via the proxy.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
from quart import make_response, request, abort as quart_abort
|
||||
from .primitives_io import IO_PRIMITIVES, execute_io
|
||||
from .jinja_bridge import _get_request_context
|
||||
from .parser import serialize
|
||||
|
||||
# Allowlist of IO primitives + page helpers the client may call
|
||||
_ALLOWED_IO = {
|
||||
"highlight", "current-user", "request-arg", "request-path",
|
||||
"htmx-request?", "app-url", "asset-url", "config",
|
||||
}
|
||||
|
||||
async def io_proxy(name: str) -> Any:
|
||||
if name not in _ALLOWED_IO:
|
||||
quart_abort(403)
|
||||
|
||||
# Parse args from query string or JSON body
|
||||
args: list = []
|
||||
kwargs: dict = {}
|
||||
if request.method == "GET":
|
||||
for k, v in request.args.items():
|
||||
if k.startswith("_arg"):
|
||||
args.append(v)
|
||||
else:
|
||||
kwargs[k] = v
|
||||
else:
|
||||
data = await request.get_json(silent=True) or {}
|
||||
args = data.get("args", [])
|
||||
kwargs = data.get("kwargs", {})
|
||||
|
||||
# Try global IO primitives first
|
||||
if name in IO_PRIMITIVES:
|
||||
ctx = _get_request_context()
|
||||
result = await execute_io(name, args, kwargs, ctx)
|
||||
else:
|
||||
# Fall back to page helpers (service-specific functions)
|
||||
helpers = get_page_helpers(service_name)
|
||||
helper_fn = helpers.get(name)
|
||||
if helper_fn is None:
|
||||
quart_abort(404)
|
||||
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
|
||||
if _asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
result_sx = serialize(result) if result is not None else "nil"
|
||||
resp = await make_response(result_sx, 200)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
io_proxy.__name__ = "sx_io_proxy"
|
||||
io_proxy.__qualname__ = "sx_io_proxy"
|
||||
|
||||
app.add_url_rule(
|
||||
"/sx/io/<name>",
|
||||
endpoint="sx_io_proxy",
|
||||
view_func=io_proxy,
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
logger.info("Mounted IO proxy endpoint for %s at /sx/io/<name>", service_name)
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
(log-info (str "sx-browser " SX_VERSION))
|
||||
(init-css-tracking)
|
||||
(init-style-dict)
|
||||
(init-io-primitives)
|
||||
(process-page-scripts)
|
||||
(process-sx-scripts nil)
|
||||
(sx-hydrate-elements nil)
|
||||
|
||||
@@ -384,6 +384,7 @@ class JSEmitter:
|
||||
"bind-client-route-click": "bindClientRouteClick",
|
||||
"try-client-route": "tryClientRoute",
|
||||
"try-eval-content": "tryEvalContent",
|
||||
"try-async-eval-content": "tryAsyncEvalContent",
|
||||
"url-pathname": "urlPathname",
|
||||
"bind-inline-handler": "bindInlineHandler",
|
||||
"bind-preload": "bindPreload",
|
||||
@@ -476,6 +477,7 @@ class JSEmitter:
|
||||
"process-sx-scripts": "processSxScripts",
|
||||
"process-component-script": "processComponentScript",
|
||||
"init-style-dict": "initStyleDict",
|
||||
"init-io-primitives": "initIoPrimitives",
|
||||
"SX_VERSION": "SX_VERSION",
|
||||
"boot-init": "bootInit",
|
||||
"resolve-mount-target": "resolveMountTarget",
|
||||
@@ -1144,6 +1146,607 @@ CONTINUATIONS_JS = '''
|
||||
'''
|
||||
|
||||
|
||||
ASYNC_IO_JS = '''
|
||||
// =========================================================================
|
||||
// Async IO: Promise-aware rendering for client-side IO primitives
|
||||
// =========================================================================
|
||||
//
|
||||
// IO primitives (query, current-user, etc.) return Promises on the client.
|
||||
// asyncRenderToDom walks the component tree; when it encounters an IO
|
||||
// primitive, it awaits the Promise and continues rendering.
|
||||
//
|
||||
// The sync evaluator/renderer is untouched. This is a separate async path
|
||||
// used only when a page's component tree contains IO references.
|
||||
|
||||
var IO_PRIMITIVES = {};
|
||||
|
||||
function registerIoPrimitive(name, fn) {
|
||||
IO_PRIMITIVES[name] = fn;
|
||||
}
|
||||
|
||||
function isPromise(x) {
|
||||
return x != null && typeof x === "object" && typeof x.then === "function";
|
||||
}
|
||||
|
||||
// Async trampoline: resolves thunks, awaits Promises
|
||||
function asyncTrampoline(val) {
|
||||
if (isPromise(val)) return val.then(asyncTrampoline);
|
||||
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
|
||||
return val;
|
||||
}
|
||||
|
||||
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
|
||||
function asyncEval(expr, env) {
|
||||
// Intercept IO primitive calls at the AST level
|
||||
if (Array.isArray(expr) && expr.length > 0) {
|
||||
var head = expr[0];
|
||||
if (head && head._sym) {
|
||||
var name = head.name;
|
||||
if (IO_PRIMITIVES[name]) {
|
||||
// Evaluate args, then call the IO primitive
|
||||
return asyncEvalIoCall(name, expr.slice(1), env);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-IO: use sync eval, but result might be a thunk
|
||||
var result = evalExpr(expr, env);
|
||||
return asyncTrampoline(result);
|
||||
}
|
||||
|
||||
function asyncEvalIoCall(name, rawArgs, env) {
|
||||
// Parse keyword args and positional args, evaluating each (may be async)
|
||||
var kwargs = {};
|
||||
var args = [];
|
||||
var promises = [];
|
||||
var i = 0;
|
||||
while (i < rawArgs.length) {
|
||||
var arg = rawArgs[i];
|
||||
if (arg && arg._kw && (i + 1) < rawArgs.length) {
|
||||
var kName = arg.name;
|
||||
var kVal = asyncEval(rawArgs[i + 1], env);
|
||||
if (isPromise(kVal)) {
|
||||
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
|
||||
} else {
|
||||
kwargs[kName] = kVal;
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
var aVal = asyncEval(arg, env);
|
||||
if (isPromise(aVal)) {
|
||||
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
|
||||
args.push(null); // placeholder
|
||||
} else {
|
||||
args.push(aVal);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
var ioFn = IO_PRIMITIVES[name];
|
||||
if (promises.length > 0) {
|
||||
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
|
||||
}
|
||||
return ioFn(args, kwargs);
|
||||
}
|
||||
|
||||
// Async render-to-dom: returns Promise<Node> or Node
|
||||
function asyncRenderToDom(expr, env, ns) {
|
||||
// Literals
|
||||
if (expr === NIL || expr === null || expr === undefined) return null;
|
||||
if (expr === true || expr === false) return null;
|
||||
if (typeof expr === "string") return document.createTextNode(expr);
|
||||
if (typeof expr === "number") return document.createTextNode(String(expr));
|
||||
|
||||
// Symbol -> async eval then render
|
||||
if (expr && expr._sym) {
|
||||
var val = asyncEval(expr, env);
|
||||
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(val, env, ns);
|
||||
}
|
||||
|
||||
// Keyword
|
||||
if (expr && expr._kw) return document.createTextNode(expr.name);
|
||||
|
||||
// DocumentFragment / DOM nodes pass through
|
||||
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
|
||||
|
||||
// Dict -> skip
|
||||
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
|
||||
|
||||
// List
|
||||
if (!Array.isArray(expr) || expr.length === 0) return null;
|
||||
|
||||
var head = expr[0];
|
||||
if (!head) return null;
|
||||
|
||||
// Symbol head
|
||||
if (head._sym) {
|
||||
var hname = head.name;
|
||||
|
||||
// IO primitive
|
||||
if (IO_PRIMITIVES[hname]) {
|
||||
var ioResult = asyncEval(expr, env);
|
||||
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(ioResult, env, ns);
|
||||
}
|
||||
|
||||
// Fragment
|
||||
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
|
||||
|
||||
// raw!
|
||||
if (hname === "raw!") {
|
||||
return asyncEvalRaw(expr.slice(1), env);
|
||||
}
|
||||
|
||||
// Special forms that need async handling
|
||||
if (hname === "if") return asyncRenderIf(expr, env, ns);
|
||||
if (hname === "when") return asyncRenderWhen(expr, env, ns);
|
||||
if (hname === "cond") return asyncRenderCond(expr, env, ns);
|
||||
if (hname === "case") return asyncRenderCase(expr, env, ns);
|
||||
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
|
||||
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
|
||||
if (hname === "map") return asyncRenderMap(expr, env, ns);
|
||||
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
|
||||
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
|
||||
|
||||
// define/defcomp/defmacro — eval for side effects
|
||||
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
|
||||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
|
||||
trampoline(evalExpr(expr, env));
|
||||
return null;
|
||||
}
|
||||
|
||||
// quote
|
||||
if (hname === "quote") return null;
|
||||
|
||||
// lambda/fn
|
||||
if (hname === "lambda" || hname === "fn") {
|
||||
trampoline(evalExpr(expr, env));
|
||||
return null;
|
||||
}
|
||||
|
||||
// and/or — eval and render result
|
||||
if (hname === "and" || hname === "or" || hname === "->") {
|
||||
var aoResult = asyncEval(expr, env);
|
||||
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(aoResult, env, ns);
|
||||
}
|
||||
|
||||
// set!
|
||||
if (hname === "set!") {
|
||||
asyncEval(expr, env);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component
|
||||
if (hname.charAt(0) === "~") {
|
||||
var comp = env[hname];
|
||||
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
|
||||
if (comp && comp._macro) {
|
||||
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
|
||||
return asyncRenderToDom(expanded, env, ns);
|
||||
}
|
||||
}
|
||||
|
||||
// Macro
|
||||
if (env[hname] && env[hname]._macro) {
|
||||
var mac = env[hname];
|
||||
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
|
||||
return asyncRenderToDom(expanded, env, ns);
|
||||
}
|
||||
|
||||
// HTML tag
|
||||
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
|
||||
return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// html: prefix
|
||||
if (hname.indexOf("html:") === 0) {
|
||||
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// Custom element
|
||||
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
|
||||
return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
}
|
||||
|
||||
// SVG context
|
||||
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
|
||||
|
||||
// Fallback: eval and render
|
||||
var fResult = asyncEval(expr, env);
|
||||
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(fResult, env, ns);
|
||||
}
|
||||
|
||||
// Non-symbol head: eval call
|
||||
var cResult = asyncEval(expr, env);
|
||||
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
|
||||
return asyncRenderToDom(cResult, env, ns);
|
||||
}
|
||||
|
||||
function asyncRenderChildren(exprs, env, ns) {
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < exprs.length; i++) {
|
||||
var result = asyncRenderToDom(exprs[i], env, ns);
|
||||
if (isPromise(result)) {
|
||||
// Insert placeholder, replace when resolved
|
||||
var placeholder = document.createComment("async");
|
||||
frag.appendChild(placeholder);
|
||||
(function(ph) {
|
||||
pending.push(result.then(function(node) {
|
||||
if (node) ph.parentNode.replaceChild(node, ph);
|
||||
else ph.parentNode.removeChild(ph);
|
||||
}));
|
||||
})(placeholder);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
return Promise.all(pending).then(function() { return frag; });
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
function asyncRenderElement(tag, args, env, ns) {
|
||||
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
|
||||
var el = domCreateElement(tag, newNs);
|
||||
var pending = [];
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
var attrName = arg.name;
|
||||
var attrVal = asyncEval(args[i + 1], env);
|
||||
i++;
|
||||
if (isPromise(attrVal)) {
|
||||
(function(an, av) {
|
||||
pending.push(av.then(function(v) {
|
||||
if (!isNil(v) && v !== false) {
|
||||
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
|
||||
else if (v === true) el.setAttribute(an, "");
|
||||
else el.setAttribute(an, String(v));
|
||||
}
|
||||
}));
|
||||
})(attrName, attrVal);
|
||||
} else {
|
||||
if (!isNil(attrVal) && attrVal !== false) {
|
||||
if (attrName === "class" && attrVal && attrVal._styleValue) {
|
||||
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
|
||||
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
|
||||
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
|
||||
} else if (contains(BOOLEAN_ATTRS, attrName)) {
|
||||
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
|
||||
} else if (attrVal === true) {
|
||||
el.setAttribute(attrName, "");
|
||||
} else {
|
||||
el.setAttribute(attrName, String(attrVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!isVoid) {
|
||||
var child = asyncRenderToDom(arg, env, newNs);
|
||||
if (isPromise(child)) {
|
||||
var placeholder = document.createComment("async");
|
||||
el.appendChild(placeholder);
|
||||
(function(ph) {
|
||||
pending.push(child.then(function(node) {
|
||||
if (node) ph.parentNode.replaceChild(node, ph);
|
||||
else ph.parentNode.removeChild(ph);
|
||||
}));
|
||||
})(placeholder);
|
||||
} else if (child) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
|
||||
return el;
|
||||
}
|
||||
|
||||
function asyncRenderComponent(comp, args, env, ns) {
|
||||
var kwargs = {};
|
||||
var children = [];
|
||||
var pending = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
if (arg && arg._kw && (i + 1) < args.length) {
|
||||
var kName = arg.name;
|
||||
var kVal = asyncEval(args[i + 1], env);
|
||||
if (isPromise(kVal)) {
|
||||
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
|
||||
} else {
|
||||
kwargs[kName] = kVal;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
children.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
function doRender() {
|
||||
var local = Object.create(componentClosure(comp));
|
||||
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
|
||||
var params = componentParams(comp);
|
||||
for (var j = 0; j < params.length; j++) {
|
||||
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
|
||||
}
|
||||
if (componentHasChildren(comp)) {
|
||||
var childResult = asyncRenderChildren(children, env, ns);
|
||||
if (isPromise(childResult)) {
|
||||
return childResult.then(function(childFrag) {
|
||||
local["children"] = childFrag;
|
||||
return asyncRenderToDom(componentBody(comp), local, ns);
|
||||
});
|
||||
}
|
||||
local["children"] = childResult;
|
||||
}
|
||||
return asyncRenderToDom(componentBody(comp), local, ns);
|
||||
}
|
||||
|
||||
if (pending.length > 0) return Promise.all(pending).then(doRender);
|
||||
return doRender();
|
||||
}
|
||||
|
||||
function asyncRenderIf(expr, env, ns) {
|
||||
var cond = asyncEval(expr[1], env);
|
||||
if (isPromise(cond)) {
|
||||
return cond.then(function(v) {
|
||||
return isSxTruthy(v)
|
||||
? asyncRenderToDom(expr[2], env, ns)
|
||||
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
|
||||
});
|
||||
}
|
||||
return isSxTruthy(cond)
|
||||
? asyncRenderToDom(expr[2], env, ns)
|
||||
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
|
||||
}
|
||||
|
||||
function asyncRenderWhen(expr, env, ns) {
|
||||
var cond = asyncEval(expr[1], env);
|
||||
if (isPromise(cond)) {
|
||||
return cond.then(function(v) {
|
||||
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
|
||||
});
|
||||
}
|
||||
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
|
||||
}
|
||||
|
||||
function asyncRenderCond(expr, env, ns) {
|
||||
var clauses = expr.slice(1);
|
||||
function step(idx) {
|
||||
if (idx >= clauses.length) return null;
|
||||
var clause = clauses[idx];
|
||||
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
|
||||
var test = clause[0];
|
||||
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
|
||||
(test && test._kw && test.name === "else")) {
|
||||
return asyncRenderToDom(clause[1], env, ns);
|
||||
}
|
||||
var v = asyncEval(test, env);
|
||||
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
|
||||
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
|
||||
}
|
||||
return step(0);
|
||||
}
|
||||
|
||||
function asyncRenderCase(expr, env, ns) {
|
||||
var matchVal = asyncEval(expr[1], env);
|
||||
function doCase(mv) {
|
||||
var clauses = expr.slice(2);
|
||||
for (var i = 0; i < clauses.length - 1; i += 2) {
|
||||
var test = clauses[i];
|
||||
if ((test && test._kw && test.name === "else") ||
|
||||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
|
||||
return asyncRenderToDom(clauses[i + 1], env, ns);
|
||||
}
|
||||
var tv = trampoline(evalExpr(test, env));
|
||||
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
|
||||
return asyncRenderToDom(clauses[i + 1], env, ns);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isPromise(matchVal)) return matchVal.then(doCase);
|
||||
return doCase(matchVal);
|
||||
}
|
||||
|
||||
function asyncRenderLet(expr, env, ns) {
|
||||
var bindings = expr[1];
|
||||
var local = Object.create(env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
|
||||
function bindStep(idx) {
|
||||
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
// Nested pairs: ((a 1) (b 2))
|
||||
if (bindings.length > 0 && Array.isArray(bindings[0])) {
|
||||
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
var b = bindings[idx];
|
||||
var vname = b[0]._sym ? b[0].name : String(b[0]);
|
||||
var val = asyncEval(b[1], local);
|
||||
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
|
||||
local[vname] = val;
|
||||
return bindStep(idx + 1);
|
||||
}
|
||||
// Flat pairs: (a 1 b 2)
|
||||
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
|
||||
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
|
||||
var vv = asyncEval(bindings[idx + 1], local);
|
||||
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
|
||||
local[vn] = vv;
|
||||
return bindStep(idx + 2);
|
||||
}
|
||||
return bindStep(0);
|
||||
}
|
||||
|
||||
function asyncRenderMap(expr, env, ns) {
|
||||
var fn = asyncEval(expr[1], env);
|
||||
var coll = asyncEval(expr[2], env);
|
||||
function doMap(f, c) {
|
||||
if (!Array.isArray(c)) return null;
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var item = c[i];
|
||||
var result;
|
||||
if (f && f._lambda) {
|
||||
var lenv = Object.create(f._closure || env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
|
||||
lenv[f._params[0]] = item;
|
||||
result = asyncRenderToDom(f._body, lenv, null);
|
||||
} else if (typeof f === "function") {
|
||||
var r = f(item);
|
||||
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
|
||||
} else {
|
||||
result = asyncRenderToDom(item, env, null);
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
var ph = document.createComment("async");
|
||||
frag.appendChild(ph);
|
||||
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(function() { return frag; });
|
||||
return frag;
|
||||
}
|
||||
if (isPromise(fn) || isPromise(coll)) {
|
||||
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
|
||||
.then(function(r) { return doMap(r[0], r[1]); });
|
||||
}
|
||||
return doMap(fn, coll);
|
||||
}
|
||||
|
||||
function asyncRenderMapIndexed(expr, env, ns) {
|
||||
var fn = asyncEval(expr[1], env);
|
||||
var coll = asyncEval(expr[2], env);
|
||||
function doMap(f, c) {
|
||||
if (!Array.isArray(c)) return null;
|
||||
var frag = document.createDocumentFragment();
|
||||
var pending = [];
|
||||
for (var i = 0; i < c.length; i++) {
|
||||
var item = c[i];
|
||||
var result;
|
||||
if (f && f._lambda) {
|
||||
var lenv = Object.create(f._closure || env);
|
||||
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
|
||||
lenv[f._params[0]] = i;
|
||||
lenv[f._params[1]] = item;
|
||||
result = asyncRenderToDom(f._body, lenv, null);
|
||||
} else if (typeof f === "function") {
|
||||
var r = f(i, item);
|
||||
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
|
||||
} else {
|
||||
result = asyncRenderToDom(item, env, null);
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
var ph = document.createComment("async");
|
||||
frag.appendChild(ph);
|
||||
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
|
||||
} else if (result) {
|
||||
frag.appendChild(result);
|
||||
}
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(function() { return frag; });
|
||||
return frag;
|
||||
}
|
||||
if (isPromise(fn) || isPromise(coll)) {
|
||||
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
|
||||
.then(function(r) { return doMap(r[0], r[1]); });
|
||||
}
|
||||
return doMap(fn, coll);
|
||||
}
|
||||
|
||||
function asyncEvalRaw(args, env) {
|
||||
var parts = [];
|
||||
var pending = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var val = asyncEval(args[i], env);
|
||||
if (isPromise(val)) {
|
||||
(function(idx) {
|
||||
pending.push(val.then(function(v) { parts[idx] = v; }));
|
||||
})(parts.length);
|
||||
parts.push(null);
|
||||
} else {
|
||||
parts.push(val);
|
||||
}
|
||||
}
|
||||
function assemble() {
|
||||
var html = "";
|
||||
for (var j = 0; j < parts.length; j++) {
|
||||
var p = parts[j];
|
||||
if (p && p._rawHtml) html += p.html;
|
||||
else if (typeof p === "string") html += p;
|
||||
else if (p != null && !isNil(p)) html += String(p);
|
||||
}
|
||||
var el = document.createElement("span");
|
||||
el.innerHTML = html;
|
||||
var frag = document.createDocumentFragment();
|
||||
while (el.firstChild) frag.appendChild(el.firstChild);
|
||||
return frag;
|
||||
}
|
||||
if (pending.length) return Promise.all(pending).then(assemble);
|
||||
return assemble();
|
||||
}
|
||||
|
||||
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
|
||||
function asyncSxRenderWithEnv(source, extraEnv) {
|
||||
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
|
||||
var exprs = parse(source);
|
||||
if (!_hasDom) return Promise.resolve(null);
|
||||
return asyncRenderChildren(exprs, env, null);
|
||||
}
|
||||
|
||||
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
|
||||
function registerProxiedIo(name) {
|
||||
registerIoPrimitive(name, function(args, kwargs) {
|
||||
var url = "/sx/io/" + encodeURIComponent(name);
|
||||
var qs = [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
|
||||
}
|
||||
for (var k in kwargs) {
|
||||
if (kwargs.hasOwnProperty(k)) {
|
||||
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
|
||||
}
|
||||
}
|
||||
if (qs.length) url += "?" + qs.join("&");
|
||||
return fetch(url, { headers: { "SX-Request": "true" } })
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
logWarn("sx:io " + name + " failed " + resp.status);
|
||||
return NIL;
|
||||
}
|
||||
return resp.text();
|
||||
})
|
||||
.then(function(text) {
|
||||
if (!text || text === "nil") return NIL;
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
return exprs.length === 1 ? exprs[0] : exprs;
|
||||
} catch (e) {
|
||||
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
|
||||
return NIL;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Register default proxied IO primitives
|
||||
function initIoPrimitives() {
|
||||
var defaults = [
|
||||
"highlight", "current-user", "request-arg", "request-path",
|
||||
"app-url", "asset-url", "config"
|
||||
];
|
||||
for (var i = 0; i < defaults.length; i++) {
|
||||
registerProxiedIo(defaults[i]);
|
||||
}
|
||||
logInfo("sx:io registered " + defaults.length + " proxied primitives");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def compile_ref_to_js(
|
||||
adapters: list[str] | None = None,
|
||||
modules: list[str] | None = None,
|
||||
@@ -1290,6 +1893,8 @@ def compile_ref_to_js(
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom))
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_JS)
|
||||
if has_dom:
|
||||
parts.append(ASYNC_IO_JS)
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
|
||||
parts.append(EPILOGUE)
|
||||
from datetime import datetime, timezone
|
||||
@@ -2783,6 +3388,32 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
}
|
||||
}
|
||||
|
||||
// Async eval with callback — used for pages with IO deps.
|
||||
// Calls callback(rendered) when done, callback(null) on failure.
|
||||
function tryAsyncEvalContent(source, env, callback) {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
try {
|
||||
var result = asyncSxRenderWithEnv(source, merged);
|
||||
if (isPromise(result)) {
|
||||
result.then(function(rendered) {
|
||||
callback(rendered);
|
||||
}).catch(function(e) {
|
||||
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
|
||||
callback(null);
|
||||
});
|
||||
} else {
|
||||
callback(result);
|
||||
}
|
||||
} catch (e) {
|
||||
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePageData(pageName, params, callback) {
|
||||
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
|
||||
// The spec only knows about resolve-page-data(name, params, callback) —
|
||||
@@ -3360,6 +3991,10 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' matchRoute: matchRoute,')
|
||||
api_lines.append(' findMatchingRoute: findMatchingRoute,')
|
||||
|
||||
if has_dom:
|
||||
api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,')
|
||||
api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,')
|
||||
api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,')
|
||||
api_lines.append(f' _version: "{version}"')
|
||||
api_lines.append(' };')
|
||||
api_lines.append('')
|
||||
|
||||
@@ -648,40 +648,71 @@
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(if (not (deps-satisfied? match))
|
||||
(do (log-info (str "sx:route deps miss for " page-name)) false)
|
||||
(if (get match "has-data")
|
||||
;; Data page: check cache, else resolve asynchronously
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(cached (page-data-cache-get cache-key)))
|
||||
(if cached
|
||||
;; Cache hit: render immediately
|
||||
(let ((env (merge closure params cached))
|
||||
(let ((has-io (get match "has-io")))
|
||||
(if (get match "has-data")
|
||||
;; Data page: check cache, else resolve asynchronously
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(cached (page-data-cache-get cache-key)))
|
||||
(if cached
|
||||
;; Cache hit
|
||||
(let ((env (merge closure params cached)))
|
||||
(if has-io
|
||||
;; Async render (data+IO)
|
||||
(do
|
||||
(log-info (str "sx:route client+cache+async " pathname))
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
true)
|
||||
;; Sync render (data only)
|
||||
(let ((rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
|
||||
(do
|
||||
(log-info (str "sx:route client+cache " pathname))
|
||||
(swap-rendered-content target rendered pathname)
|
||||
true)))))
|
||||
;; Cache miss: fetch, cache, render
|
||||
(do
|
||||
(log-info (str "sx:route client+data " pathname))
|
||||
(resolve-page-data page-name params
|
||||
(fn (data)
|
||||
(page-data-cache-set cache-key data)
|
||||
(let ((env (merge closure params data)))
|
||||
(if has-io
|
||||
;; Async render (data+IO)
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data+async eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
;; Sync render (data only)
|
||||
(let ((rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname)))))))
|
||||
true)))
|
||||
;; Non-data page
|
||||
(if has-io
|
||||
;; Async render (IO only, no data)
|
||||
(do
|
||||
(log-info (str "sx:route client+async " pathname))
|
||||
(try-async-eval-content content-src (merge closure params)
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
true)
|
||||
;; Pure page: render immediately
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(do
|
||||
(log-info (str "sx:route client+cache " pathname))
|
||||
(swap-rendered-content target rendered pathname)
|
||||
true)))
|
||||
;; Cache miss: fetch, cache, render
|
||||
(do
|
||||
(log-info (str "sx:route client+data " pathname))
|
||||
(resolve-page-data page-name params
|
||||
(fn (data)
|
||||
(page-data-cache-set cache-key data)
|
||||
(let ((env (merge closure params data))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname)))))
|
||||
true)))
|
||||
;; Pure page: render immediately
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(do
|
||||
(swap-rendered-content target rendered pathname)
|
||||
true)))))))))))))
|
||||
true)))))))))))))))
|
||||
|
||||
|
||||
(define bind-client-route-link
|
||||
@@ -991,6 +1022,8 @@
|
||||
;;
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (try-async-eval-content source env callback) → void; async render,
|
||||
;; calls (callback rendered-or-nil). Used for pages with IO deps.
|
||||
;; (url-pathname href) → extract pathname from URL string
|
||||
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
||||
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
||||
|
||||
62
sx/sx/async-io-demo.sx
Normal file
62
sx/sx/async-io-demo.sx
Normal file
@@ -0,0 +1,62 @@
|
||||
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
|
||||
;;
|
||||
;; This component calls `highlight` inline — an IO primitive that runs
|
||||
;; server-side Python (pygments). When rendered on the server, it
|
||||
;; executes synchronously. When rendered client-side, the async
|
||||
;; renderer proxies the call via /sx/io/highlight and awaits the result.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:route client+async" — async render with IO proxy
|
||||
;; "sx:io registered N proxied primitives" — IO proxy initialization
|
||||
|
||||
(defcomp ~async-io-demo-content ()
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight")
|
||||
" inline — an IO primitive. On the server it runs Python (pygments). "
|
||||
"On the client it proxies via " (code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight")
|
||||
" and the async renderer awaits the result."))
|
||||
|
||||
;; Live syntax-highlighted code blocks — each is an IO call
|
||||
(div :class "space-y-6"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting")
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition")
|
||||
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
|
||||
(raw! (highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp"))))
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code")
|
||||
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
|
||||
(raw! (highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python"))))
|
||||
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
|
||||
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "JavaScript async renderer")
|
||||
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
|
||||
(raw! (highlight "// The async renderer intercepts IO primitive calls\nfunction asyncEval(expr, env) {\n if (IO_PRIMITIVES[head.name]) {\n return asyncEvalIoCall(name, args, env);\n }\n return asyncTrampoline(evalExpr(expr, env));\n}" "javascript")))))
|
||||
|
||||
;; Architecture explanation
|
||||
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-blue-900" "How it works")
|
||||
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
|
||||
(li "Server renders the page — " (code "highlight") " runs Python pygments directly")
|
||||
(li "Client receives page with component definitions including " (code "~async-io-demo-content"))
|
||||
(li "On client navigation, " (code "has-io") " flag routes to async renderer")
|
||||
(li "Async renderer encounters " (code "(highlight ...)") " — checks " (code "IO_PRIMITIVES"))
|
||||
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
|
||||
(li "Server executes, returns SX wire format (quoted HTML string)")
|
||||
(li "Async renderer inserts result via " (code "(raw! ...)") " — renders identically")))
|
||||
|
||||
;; Verification instructions
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to verify async IO rendering")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console (F12)")
|
||||
(li "Navigate to another page (e.g. Data Test)")
|
||||
(li "Click back to this page")
|
||||
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io"))
|
||||
(li "The code blocks should render identically — same syntax highlighting")
|
||||
(li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight"))))))
|
||||
@@ -107,7 +107,8 @@
|
||||
(dict :label "Roadmap" :href "/isomorphism/")
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")))
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
||||
|
||||
@@ -444,6 +444,17 @@
|
||||
:server-time server-time :items items
|
||||
:phase phase :transport transport))
|
||||
|
||||
(defpage async-io-demo
|
||||
:path "/isomorphism/async-io"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO")
|
||||
:selected "Async IO")
|
||||
:content (~async-io-demo-content))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
|
||||
Reference in New Issue
Block a user