diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 4488529..e1061f7 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-07T17:30:45Z"; + var SX_VERSION = "2026-03-07T17:50:50Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -565,6 +565,92 @@ 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 // ========================================================================= @@ -2498,6 +2584,119 @@ callExpr.push(dictGet(kwargs, k)); } } 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)); }; + + // === Transpiled from router (client-side route matching) === // split-path-segments @@ -4495,6 +4694,12 @@ callExpr.push(dictGet(kwargs, k)); } } renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); }, renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null, parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null, + parseTime: typeof parseTime === "function" ? parseTime : null, + defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null, + parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null, + parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null, + nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null, + filterParams: typeof filterParams === "function" ? filterParams : null, morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, @@ -4509,6 +4714,17 @@ callExpr.push(dictGet(kwargs, k)); } } getEnv: function() { return componentEnv; }, resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null, init: typeof bootInit === "function" ? bootInit : null, + scanRefs: scanRefs, + scanComponentsFromSource: scanComponentsFromSource, + 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, diff --git a/shared/static/scripts/sx-test-runner.js b/shared/static/scripts/sx-test-runner.js index 2705255..7548c47 100644 --- a/shared/static/scripts/sx-test-runner.js +++ b/shared/static/scripts/sx-test-runner.js @@ -131,6 +131,35 @@ } } + function loadDepsFromBootstrap(env) { + if (Sx.scanRefs) { + env["scan-refs"] = Sx.scanRefs; + env["scan-components-from-source"] = Sx.scanComponentsFromSource; + env["transitive-deps"] = Sx.transitiveDeps; + env["compute-all-deps"] = Sx.computeAllDeps; + env["components-needed"] = Sx.componentsNeeded; + env["page-component-bundle"] = Sx.pageComponentBundle; + env["page-css-classes"] = Sx.pageCssClasses; + env["scan-io-refs"] = Sx.scanIoRefs; + env["transitive-io-refs"] = Sx.transitiveIoRefs; + env["compute-all-io-refs"] = Sx.computeAllIoRefs; + env["component-pure?"] = Sx.componentPure_p; + env["test-env"] = function() { return env; }; + } + } + + function loadEngineFromBootstrap(env) { + if (Sx.parseTime) { + env["parse-time"] = Sx.parseTime; + env["parse-trigger-spec"] = Sx.parseTriggerSpec; + env["default-trigger"] = Sx.defaultTrigger; + env["parse-swap-spec"] = Sx.parseSwapSpec; + env["parse-retry-spec"] = Sx.parseRetrySpec; + env["next-retry-ms"] = function(cur, cap) { return Math.min(cur * 2, cap); }; + env["filter-params"] = Sx.filterParams; + } + } + // --- Legacy runner (monolithic test.sx) --- window.sxRunTests = function(srcId, outId, btnId) { var src = document.getElementById(srcId).textContent; @@ -169,6 +198,8 @@ "parser": { needs: ["sx-parse"] }, "router": { needs: [] }, "render": { needs: ["render-html"] }, + "deps": { needs: [] }, + "engine": { needs: [] }, }; window.sxRunModularTests = function(specName, outId, btnId) { @@ -190,8 +221,10 @@ var sn = specs[si]; if (!SPECS[sn]) continue; - // Load router from bootstrap if needed + // Load module functions from bootstrap if (sn === "router") loadRouterFromBootstrap(ctx.env); + if (sn === "deps") loadDepsFromBootstrap(ctx.env); + if (sn === "engine") loadEngineFromBootstrap(ctx.env); // Find spec source — either per-spec textarea or embedded in overview var specEl = document.getElementById("test-spec-" + sn); diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 05a557f..46785a9 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -4007,6 +4007,12 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has api_lines.append(' renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,') if has_engine: api_lines.append(' parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,') + api_lines.append(' parseTime: typeof parseTime === "function" ? parseTime : null,') + api_lines.append(' defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null,') + api_lines.append(' parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null,') + api_lines.append(' parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null,') + api_lines.append(' nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null,') + api_lines.append(' filterParams: typeof filterParams === "function" ? filterParams : null,') api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,') api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,') api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,') @@ -4027,6 +4033,7 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,') if has_deps: api_lines.append(' scanRefs: scanRefs,') + api_lines.append(' scanComponentsFromSource: scanComponentsFromSource,') api_lines.append(' transitiveDeps: transitiveDeps,') api_lines.append(' computeAllDeps: computeAllDeps,') api_lines.append(' componentsNeeded: componentsNeeded,') diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 2fee342..24df287 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -854,6 +854,7 @@ ADAPTER_FILES = { SPEC_MODULES = { "deps": ("deps.sx", "deps (component dependency analysis)"), "router": ("router.sx", "router (client-side route matching)"), + "engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"), } @@ -958,7 +959,7 @@ def compile_ref_to_py( Valid names: continuations. None = no extensions. spec_modules: List of spec module names to include. - Valid names: deps. + Valid names: deps, engine. None = no spec modules. """ # Determine which primitive modules to include @@ -1832,10 +1833,15 @@ PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL def _sx_parse_int(v, default=0): - try: - return _b_int(v) - except (ValueError, TypeError): + if v is None or v is NIL: return default + s = str(v).strip() + # Match JS parseInt: extract leading integer portion + import re as _re + m = _re.match(r'^[+-]?\\d+', s) + if m: + return _b_int(m.group()) + return default ''', "stdlib.text": ''' @@ -1976,6 +1982,10 @@ concat = PRIMITIVES["concat"] split = PRIMITIVES["split"] length = PRIMITIVES["len"] merge = PRIMITIVES["merge"] +trim = PRIMITIVES["trim"] +replace = PRIMITIVES["replace"] +parse_int = PRIMITIVES["parse-int"] +upper = PRIMITIVES["upper"] ''' @@ -2189,7 +2199,7 @@ def main(): parser.add_argument( "--spec-modules", default=None, - help="Comma-separated spec modules (deps). Default: none.", + help="Comma-separated spec modules (deps,engine). Default: none.", ) args = parser.parse_args() adapters = args.adapters.split(",") if args.adapters else None diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 40f9dba..99cddbc 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1,3 +1,4 @@ +# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift """ sx_ref.py -- Generated from reference SX evaluator specification. @@ -777,10 +778,15 @@ PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL def _sx_parse_int(v, default=0): - try: - return _b_int(v) - except (ValueError, TypeError): + if v is None or v is NIL: return default + s = str(v).strip() + # Match JS parseInt: extract leading integer portion + import re as _re + m = _re.match(r'^[+-]?\d+', s) + if m: + return _b_int(m.group()) + return default # stdlib.text @@ -879,6 +885,10 @@ concat = PRIMITIVES["concat"] split = PRIMITIVES["split"] length = PRIMITIVES["len"] merge = PRIMITIVES["merge"] +trim = PRIMITIVES["trim"] +replace = PRIMITIVES["replace"] +parse_int = PRIMITIVES["parse-int"] +upper = PRIMITIVES["upper"] # ========================================================================= @@ -1252,38 +1262,133 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: ( component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names)) -# === Transpiled from router (client-side route matching) === +# === Transpiled from engine (fetch/swap/trigger pure logic) === -# split-path-segments -split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(trimmed2, '/')))((slice(trimmed, 0, (length(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path)) +# ENGINE_VERBS +ENGINE_VERBS = ['get', 'post', 'put', 'delete', 'patch'] -# make-route-segment -make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (length(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({})) +# DEFAULT_SWAP +DEFAULT_SWAP = 'outerHTML' -# parse-route-pattern -parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern)) +# parse-time +parse_time = lambda s: (0 if sx_truthy(is_nil(s)) else (parse_int(s, 0) if sx_truthy(ends_with_p(s, 'ms')) else ((parse_int(replace(s, 's', ''), 0) * 1000) if sx_truthy(ends_with_p(s, 's')) else parse_int(s, 0)))) -# match-route-segments -def match_route_segments(path_segs, parsed_segs): +# parse-trigger-spec +parse_trigger_spec = lambda spec: (NIL if sx_truthy(is_nil(spec)) else (lambda raw_parts: filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda part: (lambda tokens: (NIL if sx_truthy(empty_p(tokens)) else ({'event': 'every', 'modifiers': {'interval': parse_time(nth(tokens, 1))}} if sx_truthy(((first(tokens) == 'every') if not sx_truthy((first(tokens) == 'every')) else (len(tokens) >= 2))) else (lambda mods: _sx_begin(for_each(lambda tok: (_sx_dict_set(mods, 'once', True) if sx_truthy((tok == 'once')) else (_sx_dict_set(mods, 'changed', True) if sx_truthy((tok == 'changed')) else (_sx_dict_set(mods, 'delay', parse_time(slice(tok, 6))) if sx_truthy(starts_with_p(tok, 'delay:')) else (_sx_dict_set(mods, 'from', slice(tok, 5)) if sx_truthy(starts_with_p(tok, 'from:')) else NIL)))), rest(tokens)), {'event': first(tokens), 'modifiers': mods}))({}))))(split(trim(part), ' ')), raw_parts)))(split(spec, ','))) + +# default-trigger +default_trigger = lambda tag_name: ([{'event': 'submit', 'modifiers': {}}] if sx_truthy((tag_name == 'FORM')) else ([{'event': 'change', 'modifiers': {}}] if sx_truthy(((tag_name == 'INPUT') if sx_truthy((tag_name == 'INPUT')) else ((tag_name == 'SELECT') if sx_truthy((tag_name == 'SELECT')) else (tag_name == 'TEXTAREA')))) else [{'event': 'click', 'modifiers': {}}])) + +# get-verb-info +get_verb_info = lambda el: some(lambda verb: (lambda url: ({'method': upper(verb), 'url': url} if sx_truthy(url) else NIL))(dom_get_attr(el, sx_str('sx-', verb))), ENGINE_VERBS) + +# build-request-headers +build_request_headers = lambda el, loaded_components, css_hash: (lambda headers: _sx_begin((lambda target_sel: (_sx_dict_set(headers, 'SX-Target', target_sel) if sx_truthy(target_sel) else NIL))(dom_get_attr(el, 'sx-target')), (_sx_dict_set(headers, 'SX-Components', join(',', loaded_components)) if sx_truthy((not sx_truthy(empty_p(loaded_components)))) else NIL), (_sx_dict_set(headers, 'SX-Css', css_hash) if sx_truthy(css_hash) else NIL), (lambda extra_h: ((lambda parsed: (for_each(lambda key: _sx_dict_set(headers, key, sx_str(get(parsed, key))), keys(parsed)) if sx_truthy(parsed) else NIL))(parse_header_value(extra_h)) if sx_truthy(extra_h) else NIL))(dom_get_attr(el, 'sx-headers')), headers))({'SX-Request': 'true', 'SX-Current-URL': browser_location_href()}) + +# process-response-headers +process_response_headers = lambda get_header: {'redirect': get_header('SX-Redirect'), 'refresh': get_header('SX-Refresh'), 'trigger': get_header('SX-Trigger'), 'retarget': get_header('SX-Retarget'), 'reswap': get_header('SX-Reswap'), 'location': get_header('SX-Location'), 'replace-url': get_header('SX-Replace-Url'), 'css-hash': get_header('SX-Css-Hash'), 'trigger-swap': get_header('SX-Trigger-After-Swap'), 'trigger-settle': get_header('SX-Trigger-After-Settle'), 'content-type': get_header('Content-Type')} + +# parse-swap-spec +def parse_swap_spec(raw_swap, global_transitions_p): _cells = {} - return (NIL if sx_truthy((not sx_truthy((length(path_segs) == length(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs), (params if sx_truthy(_cells['matched']) else NIL))))({})) + parts = split((raw_swap if sx_truthy(raw_swap) else DEFAULT_SWAP), ' ') + style = first(parts) + _cells['use_transition'] = global_transitions_p + for p in rest(parts): + if sx_truthy((p == 'transition:true')): + _cells['use_transition'] = True + elif sx_truthy((p == 'transition:false')): + _cells['use_transition'] = False + return {'style': style, 'transition': _cells['use_transition']} -# match-route -match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path)) +# parse-retry-spec +parse_retry_spec = lambda retry_attr: (NIL if sx_truthy(is_nil(retry_attr)) else (lambda parts: {'strategy': first(parts), 'start-ms': parse_int(nth(parts, 1), 1000), 'cap-ms': parse_int(nth(parts, 2), 30000)})(split(retry_attr, ':'))) -# find-matching-route -def find_matching_route(path, routes): +# next-retry-ms +next_retry_ms = lambda current_ms, cap_ms: min((current_ms * 2), cap_ms) + +# filter-params +filter_params = lambda params_spec, all_params: (all_params if sx_truthy(is_nil(params_spec)) else ([] if sx_truthy((params_spec == 'none')) else (all_params if sx_truthy((params_spec == '*')) else ((lambda excluded: filter(lambda p: (not sx_truthy(contains_p(excluded, first(p)))), all_params))(map(trim, split(slice(params_spec, 4), ','))) if sx_truthy(starts_with_p(params_spec, 'not ')) else (lambda allowed: filter(lambda p: contains_p(allowed, first(p)), all_params))(map(trim, split(params_spec, ','))))))) + +# resolve-target +resolve_target = lambda el: (lambda sel: (el if sx_truthy((is_nil(sel) if sx_truthy(is_nil(sel)) else (sel == 'this'))) else (dom_parent(el) if sx_truthy((sel == 'closest')) else dom_query(sel))))(dom_get_attr(el, 'sx-target')) + +# apply-optimistic +apply_optimistic = lambda el: (lambda directive: (NIL if sx_truthy(is_nil(directive)) else (lambda target: (lambda state: _sx_begin((_sx_begin(_sx_dict_set(state, 'opacity', dom_get_style(target, 'opacity')), dom_set_style(target, 'opacity', '0'), dom_set_style(target, 'pointer-events', 'none')) if sx_truthy((directive == 'remove')) else (_sx_begin(_sx_dict_set(state, 'disabled', dom_get_prop(target, 'disabled')), dom_set_prop(target, 'disabled', True)) if sx_truthy((directive == 'disable')) else ((lambda cls: _sx_begin(_sx_dict_set(state, 'add-class', cls), dom_add_class(target, cls)))(slice(directive, 10)) if sx_truthy(starts_with_p(directive, 'add-class:')) else NIL))), state))({'target': target, 'directive': directive}))((resolve_target(el) if sx_truthy(resolve_target(el)) else el))))(dom_get_attr(el, 'sx-optimistic')) + +# revert-optimistic +revert_optimistic = lambda state: ((lambda target: (lambda directive: (_sx_begin(dom_set_style(target, 'opacity', (get(state, 'opacity') if sx_truthy(get(state, 'opacity')) else '')), dom_set_style(target, 'pointer-events', '')) if sx_truthy((directive == 'remove')) else (dom_set_prop(target, 'disabled', (get(state, 'disabled') if sx_truthy(get(state, 'disabled')) else False)) if sx_truthy((directive == 'disable')) else (dom_remove_class(target, get(state, 'add-class')) if sx_truthy(get(state, 'add-class')) else NIL))))(get(state, 'directive')))(get(state, 'target')) if sx_truthy(state) else NIL) + +# find-oob-swaps +find_oob_swaps = lambda container: (lambda results: _sx_begin(for_each(lambda attr: (lambda oob_els: for_each(lambda oob: (lambda swap_type: (lambda target_id: _sx_begin(dom_remove_attr(oob, attr), (_sx_append(results, {'element': oob, 'swap-type': swap_type, 'target-id': target_id}) if sx_truthy(target_id) else NIL)))(dom_id(oob)))((dom_get_attr(oob, attr) if sx_truthy(dom_get_attr(oob, attr)) else 'outerHTML')), oob_els))(dom_query_all(container, sx_str('[', attr, ']'))), ['sx-swap-oob', 'hx-swap-oob']), results))([]) + +# morph-node +morph_node = lambda old_node, new_node: (NIL if sx_truthy((dom_has_attr_p(old_node, 'sx-preserve') if sx_truthy(dom_has_attr_p(old_node, 'sx-preserve')) else dom_has_attr_p(old_node, 'sx-ignore'))) else (dom_replace_child(dom_parent(old_node), dom_clone(new_node), old_node) if sx_truthy(((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node)))) if sx_truthy((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node))))) else (not sx_truthy((dom_node_name(old_node) == dom_node_name(new_node)))))) else ((dom_set_text_content(old_node, dom_text_content(new_node)) if sx_truthy((not sx_truthy((dom_text_content(old_node) == dom_text_content(new_node))))) else NIL) if sx_truthy(((dom_node_type(old_node) == 3) if sx_truthy((dom_node_type(old_node) == 3)) else (dom_node_type(old_node) == 8))) else (_sx_begin(sync_attrs(old_node, new_node), (morph_children(old_node, new_node) if sx_truthy((not sx_truthy((dom_is_active_element_p(old_node) if not sx_truthy(dom_is_active_element_p(old_node)) else dom_is_input_element_p(old_node))))) else NIL)) if sx_truthy((dom_node_type(old_node) == 1)) else NIL)))) + +# sync-attrs +sync_attrs = _sx_fn(lambda old_el, new_el: ( + for_each(lambda attr: (lambda name: (lambda val: (dom_set_attr(old_el, name, val) if sx_truthy((not sx_truthy((dom_get_attr(old_el, name) == val)))) else NIL))(nth(attr, 1)))(first(attr)), dom_attr_list(new_el)), + for_each(lambda attr: (dom_remove_attr(old_el, first(attr)) if sx_truthy((not sx_truthy(dom_has_attr_p(new_el, first(attr))))) else NIL), dom_attr_list(old_el)) +)[-1]) + +# morph-children +def morph_children(old_parent, new_parent): _cells = {} - path_segs = split_path_segments(path) - _cells['result'] = NIL - for route in routes: - if sx_truthy(is_nil(_cells['result'])): - params = match_route_segments(path_segs, get(route, 'parsed')) - if sx_truthy((not sx_truthy(is_nil(params)))): - matched = merge(route, {}) - matched['params'] = params - _cells['result'] = matched - return _cells['result'] + old_kids = dom_child_list(old_parent) + new_kids = dom_child_list(new_parent) + old_by_id = reduce(lambda acc, kid: (lambda id: (_sx_begin(_sx_dict_set(acc, id, kid), acc) if sx_truthy(id) else acc))(dom_id(kid)), {}, old_kids) + _cells['oi'] = 0 + for new_child in new_kids: + match_id = dom_id(new_child) + match_by_id = (dict_get(old_by_id, match_id) if sx_truthy(match_id) else NIL) + if sx_truthy((match_by_id if not sx_truthy(match_by_id) else (not sx_truthy(is_nil(match_by_id))))): + if sx_truthy(((_cells['oi'] < len(old_kids)) if not sx_truthy((_cells['oi'] < len(old_kids))) else (not sx_truthy((match_by_id == nth(old_kids, _cells['oi'])))))): + dom_insert_before(old_parent, match_by_id, (nth(old_kids, _cells['oi']) if sx_truthy((_cells['oi'] < len(old_kids))) else NIL)) + morph_node(match_by_id, new_child) + _cells['oi'] = (_cells['oi'] + 1) + elif sx_truthy((_cells['oi'] < len(old_kids))): + old_child = nth(old_kids, _cells['oi']) + if sx_truthy((dom_id(old_child) if not sx_truthy(dom_id(old_child)) else (not sx_truthy(match_id)))): + dom_insert_before(old_parent, dom_clone(new_child), old_child) + else: + morph_node(old_child, new_child) + _cells['oi'] = (_cells['oi'] + 1) + else: + dom_append(old_parent, dom_clone(new_child)) + return for_each(lambda i: ((lambda leftover: (dom_remove_child(old_parent, leftover) if sx_truthy((dom_is_child_of_p(leftover, old_parent) if not sx_truthy(dom_is_child_of_p(leftover, old_parent)) else ((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve')))) else (not sx_truthy(dom_has_attr_p(leftover, 'sx-ignore')))))) else NIL))(nth(old_kids, i)) if sx_truthy((i >= _cells['oi'])) else NIL), range(_cells['oi'], len(old_kids))) + +# swap-dom-nodes +swap_dom_nodes = lambda target, new_nodes, strategy: _sx_case(strategy, [('innerHTML', lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL)))), ('outerHTML', lambda: (lambda parent: _sx_begin(((lambda fc: (_sx_begin(morph_node(target, fc), (lambda sib: insert_remaining_siblings(parent, target, sib))(dom_next_sibling(fc))) if sx_truthy(fc) else dom_remove_child(parent, target)))(dom_first_child(new_nodes)) if sx_truthy(dom_is_fragment_p(new_nodes)) else morph_node(target, new_nodes)), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_after(target, new_nodes)), ('beforeend', lambda: dom_append(target, new_nodes)), ('afterbegin', lambda: dom_prepend(target, new_nodes)), ('beforebegin', lambda: dom_insert_before(dom_parent(target), new_nodes, target)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL))))]) + +# insert-remaining-siblings +insert_remaining_siblings = lambda parent, ref_node, sib: ((lambda next: _sx_begin(dom_insert_after(ref_node, sib), insert_remaining_siblings(parent, sib, next)))(dom_next_sibling(sib)) if sx_truthy(sib) else NIL) + +# swap-html-string +swap_html_string = lambda target, html, strategy: _sx_case(strategy, [('innerHTML', lambda: dom_set_inner_html(target, html)), ('outerHTML', lambda: (lambda parent: _sx_begin(dom_insert_adjacent_html(target, 'afterend', html), dom_remove_child(parent, target), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_adjacent_html(target, 'afterend', html)), ('beforeend', lambda: dom_insert_adjacent_html(target, 'beforeend', html)), ('afterbegin', lambda: dom_insert_adjacent_html(target, 'afterbegin', html)), ('beforebegin', lambda: dom_insert_adjacent_html(target, 'beforebegin', html)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: dom_set_inner_html(target, html))]) + +# handle-history +handle_history = lambda el, url, resp_headers: (lambda push_url: (lambda replace_url: (lambda hdr_replace: (browser_replace_state(hdr_replace) if sx_truthy(hdr_replace) else (browser_push_state((url if sx_truthy((push_url == 'true')) else push_url)) if sx_truthy((push_url if not sx_truthy(push_url) else (not sx_truthy((push_url == 'false'))))) else (browser_replace_state((url if sx_truthy((replace_url == 'true')) else replace_url)) if sx_truthy((replace_url if not sx_truthy(replace_url) else (not sx_truthy((replace_url == 'false'))))) else NIL))))(get(resp_headers, 'replace-url')))(dom_get_attr(el, 'sx-replace-url')))(dom_get_attr(el, 'sx-push-url')) + +# PRELOAD_TTL +PRELOAD_TTL = 30000 + +# preload-cache-get +preload_cache_get = lambda cache, url: (lambda entry: (NIL if sx_truthy(is_nil(entry)) else (_sx_begin(dict_delete(cache, url), NIL) if sx_truthy(((now_ms() - get(entry, 'timestamp')) > PRELOAD_TTL)) else _sx_begin(dict_delete(cache, url), entry))))(dict_get(cache, url)) + +# preload-cache-set +preload_cache_set = lambda cache, url, text, content_type: _sx_dict_set(cache, url, {'text': text, 'content-type': content_type, 'timestamp': now_ms()}) + +# classify-trigger +classify_trigger = lambda trigger: (lambda event: ('poll' if sx_truthy((event == 'every')) else ('intersect' if sx_truthy((event == 'intersect')) else ('load' if sx_truthy((event == 'load')) else ('revealed' if sx_truthy((event == 'revealed')) else 'event')))))(get(trigger, 'event')) + +# should-boost-link? +should_boost_link_p = lambda link: (lambda href: (href if not sx_truthy(href) else ((not sx_truthy(starts_with_p(href, '#'))) if not sx_truthy((not sx_truthy(starts_with_p(href, '#')))) else ((not sx_truthy(starts_with_p(href, 'javascript:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'javascript:')))) else ((not sx_truthy(starts_with_p(href, 'mailto:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'mailto:')))) else (browser_same_origin_p(href) if not sx_truthy(browser_same_origin_p(href)) else ((not sx_truthy(dom_has_attr_p(link, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(link, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(link, 'sx-disable')))))))))))(dom_get_attr(link, 'href')) + +# should-boost-form? +should_boost_form_p = lambda form: ((not sx_truthy(dom_has_attr_p(form, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(form, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(form, 'sx-disable'))))) + +# parse-sse-swap +parse_sse_swap = lambda el: (dom_get_attr(el, 'sx-sse-swap') if sx_truthy(dom_get_attr(el, 'sx-sse-swap')) else 'message') # ========================================================================= @@ -1318,64 +1423,6 @@ 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 # ========================================================================= diff --git a/shared/sx/ref/test-deps.sx b/shared/sx/ref/test-deps.sx new file mode 100644 index 0000000..b67b508 --- /dev/null +++ b/shared/sx/ref/test-deps.sx @@ -0,0 +1,225 @@ +;; ========================================================================== +;; test-deps.sx — Tests for component dependency analysis (deps.sx) +;; +;; Requires: test-framework.sx loaded first. +;; Platform functions: scan-refs, transitive-deps, components-needed, +;; component-pure?, scan-io-refs, transitive-io-refs, +;; scan-components-from-source, test-env +;; (loaded from bootstrapped output by test runners) +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Test component definitions — these exist in the test env for dep analysis +;; -------------------------------------------------------------------------- + +(defcomp ~dep-leaf () + (span "leaf")) + +(defcomp ~dep-branch () + (div (~dep-leaf))) + +(defcomp ~dep-trunk () + (div (~dep-branch) (~dep-leaf))) + +(defcomp ~dep-conditional (&key show?) + (if show? + (~dep-leaf) + (~dep-branch))) + +(defcomp ~dep-nested-cond (&key mode) + (cond + (= mode "a") (~dep-leaf) + (= mode "b") (~dep-branch) + :else (~dep-trunk))) + +(defcomp ~dep-island () + (div "no deps")) + + +;; -------------------------------------------------------------------------- +;; 1. scan-refs — finds component references in AST nodes +;; -------------------------------------------------------------------------- + +(defsuite "scan-refs" + + (deftest "empty for string literal" + (assert-equal (list) (scan-refs "hello"))) + + (deftest "empty for number" + (assert-equal (list) (scan-refs 42))) + + (deftest "finds component symbol" + (let ((refs (scan-refs (quote (~dep-leaf))))) + (assert-contains "~dep-leaf" refs))) + + (deftest "finds in nested list" + (let ((refs (scan-refs (quote (div (span (~dep-leaf))))))) + (assert-contains "~dep-leaf" refs))) + + (deftest "finds multiple refs" + (let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-branch)))))) + (assert-contains "~dep-leaf" refs) + (assert-contains "~dep-branch" refs))) + + (deftest "deduplicates" + (let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-leaf)))))) + (assert-equal 1 (len refs)))) + + (deftest "walks if branches" + (let ((refs (scan-refs (quote (if true (~dep-leaf) (~dep-branch)))))) + (assert-contains "~dep-leaf" refs) + (assert-contains "~dep-branch" refs))) + + (deftest "walks cond branches" + (let ((refs (scan-refs (quote (cond (= x 1) (~dep-leaf) :else (~dep-trunk)))))) + (assert-contains "~dep-leaf" refs) + (assert-contains "~dep-trunk" refs))) + + (deftest "ignores non-component symbols" + (let ((refs (scan-refs (quote (div class "foo"))))) + (assert-equal 0 (len refs))))) + + +;; -------------------------------------------------------------------------- +;; 2. scan-components-from-source — regex-based source string scanning +;; -------------------------------------------------------------------------- + +(defsuite "scan-components-from-source" + + (deftest "finds single component" + (let ((refs (scan-components-from-source "(~dep-leaf)"))) + (assert-contains "~dep-leaf" refs))) + + (deftest "finds multiple components" + (let ((refs (scan-components-from-source "(div (~dep-leaf) (~dep-branch))"))) + (assert-contains "~dep-leaf" refs) + (assert-contains "~dep-branch" refs))) + + (deftest "no false positives on plain text" + (let ((refs (scan-components-from-source "(div \"hello world\")"))) + (assert-equal 0 (len refs)))) + + (deftest "handles hyphenated names" + (let ((refs (scan-components-from-source "(~my-component :key val)"))) + (assert-contains "~my-component" refs)))) + + +;; -------------------------------------------------------------------------- +;; 3. transitive-deps — transitive dependency closure +;; -------------------------------------------------------------------------- + +(defsuite "transitive-deps" + + (deftest "leaf has no deps" + (let ((deps (transitive-deps "~dep-leaf" (test-env)))) + (assert-equal 0 (len deps)))) + + (deftest "direct dependency" + (let ((deps (transitive-deps "~dep-branch" (test-env)))) + (assert-contains "~dep-leaf" deps))) + + (deftest "transitive closure" + (let ((deps (transitive-deps "~dep-trunk" (test-env)))) + (assert-contains "~dep-branch" deps) + (assert-contains "~dep-leaf" deps))) + + (deftest "excludes self" + (let ((deps (transitive-deps "~dep-trunk" (test-env)))) + (assert-false (contains? deps "~dep-trunk")))) + + (deftest "walks conditional branches" + (let ((deps (transitive-deps "~dep-conditional" (test-env)))) + (assert-contains "~dep-leaf" deps) + (assert-contains "~dep-branch" deps))) + + (deftest "walks all cond branches" + (let ((deps (transitive-deps "~dep-nested-cond" (test-env)))) + (assert-contains "~dep-leaf" deps) + (assert-contains "~dep-branch" deps) + (assert-contains "~dep-trunk" deps))) + + (deftest "island has no deps" + (let ((deps (transitive-deps "~dep-island" (test-env)))) + (assert-equal 0 (len deps)))) + + (deftest "accepts name without tilde" + (let ((deps (transitive-deps "dep-branch" (test-env)))) + (assert-contains "~dep-leaf" deps)))) + + +;; -------------------------------------------------------------------------- +;; 4. components-needed — page bundle computation +;; -------------------------------------------------------------------------- + +(defsuite "components-needed" + + (deftest "finds direct and transitive" + (let ((needed (components-needed "(~dep-trunk)" (test-env)))) + (assert-contains "~dep-trunk" needed) + (assert-contains "~dep-branch" needed) + (assert-contains "~dep-leaf" needed))) + + (deftest "deduplicates" + (let ((needed (components-needed "(div (~dep-leaf) (~dep-leaf))" (test-env)))) + ;; ~dep-leaf should appear only once + (assert-true (contains? needed "~dep-leaf")))) + + (deftest "handles leaf page" + (let ((needed (components-needed "(~dep-island)" (test-env)))) + (assert-contains "~dep-island" needed) + (assert-equal 1 (len needed)))) + + (deftest "handles multiple top-level components" + (let ((needed (components-needed "(div (~dep-leaf) (~dep-island))" (test-env)))) + (assert-contains "~dep-leaf" needed) + (assert-contains "~dep-island" needed)))) + + +;; -------------------------------------------------------------------------- +;; 5. IO detection — scan-io-refs, component-pure? +;; -------------------------------------------------------------------------- + +;; Define components that reference "io" functions for testing +(defcomp ~dep-pure () + (div (~dep-leaf) "static")) + +(defcomp ~dep-io () + (div (fetch-data "/api"))) + +(defcomp ~dep-io-indirect () + (div (~dep-io))) + +(defsuite "scan-io-refs" + + (deftest "no IO in pure AST" + (let ((refs (scan-io-refs (quote (div "hello" (span "world"))) (list "fetch-data")))) + (assert-equal 0 (len refs)))) + + (deftest "finds IO reference" + (let ((refs (scan-io-refs (quote (div (fetch-data "/api"))) (list "fetch-data")))) + (assert-contains "fetch-data" refs))) + + (deftest "multiple IO refs" + (let ((refs (scan-io-refs (quote (div (fetch-data "/a") (query-db "x"))) (list "fetch-data" "query-db")))) + (assert-contains "fetch-data" refs) + (assert-contains "query-db" refs))) + + (deftest "ignores non-IO symbols" + (let ((refs (scan-io-refs (quote (div (map str items))) (list "fetch-data")))) + (assert-equal 0 (len refs))))) + + +(defsuite "component-pure?" + + (deftest "pure component is pure" + (assert-true (component-pure? "~dep-pure" (test-env) (list "fetch-data")))) + + (deftest "IO component is not pure" + (assert-false (component-pure? "~dep-io" (test-env) (list "fetch-data")))) + + (deftest "indirect IO is not pure" + (assert-false (component-pure? "~dep-io-indirect" (test-env) (list "fetch-data")))) + + (deftest "leaf component is pure" + (assert-true (component-pure? "~dep-leaf" (test-env) (list "fetch-data"))))) diff --git a/shared/sx/ref/test-engine.sx b/shared/sx/ref/test-engine.sx new file mode 100644 index 0000000..c3fa2d2 --- /dev/null +++ b/shared/sx/ref/test-engine.sx @@ -0,0 +1,212 @@ +;; ========================================================================== +;; test-engine.sx — Tests for SxEngine pure logic (engine.sx) +;; +;; Requires: test-framework.sx loaded first. +;; Platform functions: parse-time, parse-trigger-spec, default-trigger, +;; parse-swap-spec, parse-retry-spec, next-retry-ms, filter-params +;; (loaded from bootstrapped output by test runners) +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 1. parse-time — time string parsing +;; -------------------------------------------------------------------------- + +(defsuite "parse-time" + + (deftest "seconds to ms" + (assert-equal 2000 (parse-time "2s"))) + + (deftest "milliseconds" + (assert-equal 500 (parse-time "500ms"))) + + (deftest "nil returns 0" + (assert-equal 0 (parse-time nil))) + + (deftest "plain number string" + (assert-equal 100 (parse-time "100"))) + + (deftest "one second" + (assert-equal 1000 (parse-time "1s"))) + + (deftest "large seconds" + (assert-equal 30000 (parse-time "30s")))) + + +;; -------------------------------------------------------------------------- +;; 2. parse-trigger-spec — trigger attribute parsing +;; -------------------------------------------------------------------------- + +(defsuite "parse-trigger-spec" + + (deftest "nil returns nil" + (assert-nil (parse-trigger-spec nil))) + + (deftest "single event" + (let ((triggers (parse-trigger-spec "click"))) + (assert-equal 1 (len triggers)) + (assert-equal "click" (get (first triggers) "event")))) + + (deftest "event with once modifier" + (let ((triggers (parse-trigger-spec "click once"))) + (assert-equal 1 (len triggers)) + (assert-equal "click" (get (first triggers) "event")) + (assert-true (get (get (first triggers) "modifiers") "once")))) + + (deftest "event with delay modifier" + (let ((triggers (parse-trigger-spec "click delay:500ms"))) + (assert-equal 1 (len triggers)) + (assert-equal 500 (get (get (first triggers) "modifiers") "delay")))) + + (deftest "multiple triggers comma-separated" + (let ((triggers (parse-trigger-spec "click,change"))) + (assert-equal 2 (len triggers)) + (assert-equal "click" (get (first triggers) "event")) + (assert-equal "change" (get (nth triggers 1) "event")))) + + (deftest "polling trigger" + (let ((triggers (parse-trigger-spec "every 3s"))) + (assert-equal 1 (len triggers)) + (assert-equal "every" (get (first triggers) "event")) + (assert-equal 3000 (get (get (first triggers) "modifiers") "interval")))) + + (deftest "event with from modifier" + (let ((triggers (parse-trigger-spec "click from:body"))) + (assert-equal "body" (get (get (first triggers) "modifiers") "from")))) + + (deftest "event with changed modifier" + (let ((triggers (parse-trigger-spec "keyup changed"))) + (assert-equal "keyup" (get (first triggers) "event")) + (assert-true (get (get (first triggers) "modifiers") "changed"))))) + + +;; -------------------------------------------------------------------------- +;; 3. default-trigger — default trigger by element tag +;; -------------------------------------------------------------------------- + +(defsuite "default-trigger" + + (deftest "form submits" + (let ((triggers (default-trigger "FORM"))) + (assert-equal "submit" (get (first triggers) "event")))) + + (deftest "input changes" + (let ((triggers (default-trigger "INPUT"))) + (assert-equal "change" (get (first triggers) "event")))) + + (deftest "select changes" + (let ((triggers (default-trigger "SELECT"))) + (assert-equal "change" (get (first triggers) "event")))) + + (deftest "textarea changes" + (let ((triggers (default-trigger "TEXTAREA"))) + (assert-equal "change" (get (first triggers) "event")))) + + (deftest "div clicks" + (let ((triggers (default-trigger "DIV"))) + (assert-equal "click" (get (first triggers) "event")))) + + (deftest "button clicks" + (let ((triggers (default-trigger "BUTTON"))) + (assert-equal "click" (get (first triggers) "event"))))) + + +;; -------------------------------------------------------------------------- +;; 4. parse-swap-spec — swap specification parsing +;; -------------------------------------------------------------------------- + +(defsuite "parse-swap-spec" + + (deftest "default swap" + (let ((spec (parse-swap-spec nil false))) + (assert-equal "outerHTML" (get spec "style")) + (assert-false (get spec "transition")))) + + (deftest "innerHTML" + (let ((spec (parse-swap-spec "innerHTML" false))) + (assert-equal "innerHTML" (get spec "style")))) + + (deftest "with transition true" + (let ((spec (parse-swap-spec "innerHTML transition:true" false))) + (assert-equal "innerHTML" (get spec "style")) + (assert-true (get spec "transition")))) + + (deftest "transition false overrides global" + (let ((spec (parse-swap-spec "outerHTML transition:false" true))) + (assert-equal "outerHTML" (get spec "style")) + (assert-false (get spec "transition")))) + + (deftest "global transition when not overridden" + (let ((spec (parse-swap-spec "innerHTML" true))) + (assert-equal "innerHTML" (get spec "style")) + (assert-true (get spec "transition"))))) + + +;; -------------------------------------------------------------------------- +;; 5. parse-retry-spec — retry specification parsing +;; -------------------------------------------------------------------------- + +(defsuite "parse-retry-spec" + + (deftest "nil returns nil" + (assert-nil (parse-retry-spec nil))) + + (deftest "exponential backoff" + (let ((spec (parse-retry-spec "exponential:1000:30000"))) + (assert-equal "exponential" (get spec "strategy")) + (assert-equal 1000 (get spec "start-ms")) + (assert-equal 30000 (get spec "cap-ms")))) + + (deftest "linear strategy" + (let ((spec (parse-retry-spec "linear:2000:60000"))) + (assert-equal "linear" (get spec "strategy")) + (assert-equal 2000 (get spec "start-ms")) + (assert-equal 60000 (get spec "cap-ms"))))) + + +;; -------------------------------------------------------------------------- +;; 6. next-retry-ms — exponential backoff calculation +;; -------------------------------------------------------------------------- + +(defsuite "next-retry-ms" + + (deftest "doubles current" + (assert-equal 2000 (next-retry-ms 1000 30000))) + + (deftest "caps at maximum" + (assert-equal 30000 (next-retry-ms 20000 30000))) + + (deftest "exact cap" + (assert-equal 30000 (next-retry-ms 15000 30000))) + + (deftest "small initial" + (assert-equal 200 (next-retry-ms 100 30000)))) + + +;; -------------------------------------------------------------------------- +;; 7. filter-params — form parameter filtering +;; -------------------------------------------------------------------------- + +(defsuite "filter-params" + + (deftest "nil passes all through" + (let ((params (list (list "a" "1") (list "b" "2")))) + (assert-equal 2 (len (filter-params nil params))))) + + (deftest "none returns empty" + (let ((params (list (list "a" "1") (list "b" "2")))) + (assert-equal 0 (len (filter-params "none" params))))) + + (deftest "star passes all" + (let ((params (list (list "a" "1") (list "b" "2")))) + (assert-equal 2 (len (filter-params "*" params))))) + + (deftest "whitelist" + (let ((params (list (list "name" "Jo") (list "age" "30") (list "secret" "x")))) + (let ((filtered (filter-params "name,age" params))) + (assert-equal 2 (len filtered))))) + + (deftest "blacklist with not" + (let ((params (list (list "name" "Jo") (list "csrf" "tok") (list "age" "30")))) + (let ((filtered (filter-params "not csrf" params))) + (assert-equal 2 (len filtered)))))) diff --git a/shared/sx/tests/run.js b/shared/sx/tests/run.js index 288f166..965c7b4 100644 --- a/shared/sx/tests/run.js +++ b/shared/sx/tests/run.js @@ -159,6 +159,8 @@ var SPECS = { "parser": { file: "test-parser.sx", needs: ["sx-parse"] }, "router": { file: "test-router.sx", needs: [] }, "render": { file: "test-render.sx", needs: ["render-html"] }, + "deps": { file: "test-deps.sx", needs: [] }, + "engine": { file: "test-engine.sx", needs: [] }, }; function evalFile(filename) { @@ -215,9 +217,6 @@ if (args[0] === "--legacy") { // Load prerequisite spec modules if (specName === "router") { - // Use bootstrapped router functions from sx-browser.js. - // The bare evaluator can't run router.sx faithfully because set! - // inside lambda closures doesn't propagate (dict copies, not cells). if (Sx.splitPathSegments) { env["split-path-segments"] = Sx.splitPathSegments; env["parse-route-pattern"] = Sx.parseRoutePattern; @@ -230,6 +229,35 @@ if (args[0] === "--legacy") { } } + if (specName === "deps") { + if (Sx.scanRefs) { + env["scan-refs"] = Sx.scanRefs; + env["scan-components-from-source"] = Sx.scanComponentsFromSource; + env["transitive-deps"] = Sx.transitiveDeps; + env["compute-all-deps"] = Sx.computeAllDeps; + env["components-needed"] = Sx.componentsNeeded; + env["page-component-bundle"] = Sx.pageComponentBundle; + env["page-css-classes"] = Sx.pageCssClasses; + env["scan-io-refs"] = Sx.scanIoRefs; + env["transitive-io-refs"] = Sx.transitiveIoRefs; + env["compute-all-io-refs"] = Sx.computeAllIoRefs; + env["component-pure?"] = Sx.componentPure_p; + env["test-env"] = function() { return env; }; + } + } + + if (specName === "engine") { + if (Sx.parseTime) { + env["parse-time"] = Sx.parseTime; + env["parse-trigger-spec"] = Sx.parseTriggerSpec; + env["default-trigger"] = Sx.defaultTrigger; + env["parse-swap-spec"] = Sx.parseSwapSpec; + env["parse-retry-spec"] = Sx.parseRetrySpec; + env["next-retry-ms"] = function(cur, cap) { return Math.min(cur * 2, cap); }; + env["filter-params"] = Sx.filterParams; + } + } + console.log("# --- " + specName + " ---"); evalFile(spec.file); } diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index eba2a03..f45d811 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -141,6 +141,8 @@ SPECS = { "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, "router": {"file": "test-router.sx", "needs": []}, "render": {"file": "test-render.sx", "needs": ["render-html"]}, + "deps": {"file": "test-deps.sx", "needs": []}, + "engine": {"file": "test-engine.sx", "needs": []}, } REF_DIR = os.path.join(_HERE, "..", "ref") @@ -269,6 +271,62 @@ def _load_router_from_bootstrap(env): eval_file("router.sx", env) +def _load_deps_from_bootstrap(env): + """Load deps functions from the bootstrapped sx_ref.py.""" + try: + from shared.sx.ref.sx_ref import ( + scan_refs, + scan_components_from_source, + transitive_deps, + compute_all_deps, + components_needed, + page_component_bundle, + page_css_classes, + scan_io_refs, + transitive_io_refs, + compute_all_io_refs, + component_pure_p, + ) + env["scan-refs"] = scan_refs + env["scan-components-from-source"] = scan_components_from_source + env["transitive-deps"] = transitive_deps + env["compute-all-deps"] = compute_all_deps + env["components-needed"] = components_needed + env["page-component-bundle"] = page_component_bundle + env["page-css-classes"] = page_css_classes + env["scan-io-refs"] = scan_io_refs + env["transitive-io-refs"] = transitive_io_refs + env["compute-all-io-refs"] = compute_all_io_refs + env["component-pure?"] = component_pure_p + env["test-env"] = lambda: env + except ImportError: + eval_file("deps.sx", env) + env["test-env"] = lambda: env + + +def _load_engine_from_bootstrap(env): + """Load engine pure functions from the bootstrapped sx_ref.py.""" + try: + from shared.sx.ref.sx_ref import ( + parse_time, + parse_trigger_spec, + default_trigger, + parse_swap_spec, + parse_retry_spec, + next_retry_ms, + filter_params, + ) + env["parse-time"] = parse_time + env["parse-trigger-spec"] = parse_trigger_spec + env["default-trigger"] = default_trigger + env["parse-swap-spec"] = parse_swap_spec + env["parse-retry-spec"] = parse_retry_spec + env["next-retry-ms"] = next_retry_ms + env["filter-params"] = filter_params + except ImportError: + eval_file("engine.sx", env) + + def main(): global passed, failed, test_num @@ -306,6 +364,10 @@ def main(): # Load prerequisite spec modules if spec_name == "router": _load_router_from_bootstrap(env) + if spec_name == "deps": + _load_deps_from_bootstrap(env) + if spec_name == "engine": + _load_engine_from_bootstrap(env) print(f"# --- {spec_name} ---") eval_file(spec["file"], env) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index c5f8091..a1758ef 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -113,6 +113,8 @@ (dict :label "Parser" :href "/testing/parser") (dict :label "Router" :href "/testing/router") (dict :label "Renderer" :href "/testing/render") + (dict :label "Dependencies" :href "/testing/deps") + (dict :label "Engine" :href "/testing/engine") (dict :label "Runners" :href "/testing/runners"))) (define isomorphism-nav-items (list diff --git a/sx/sx/testing.sx b/sx/sx/testing.sx index 6bb96e6..6bb316a 100644 --- a/sx/sx/testing.sx +++ b/sx/sx/testing.sx @@ -6,7 +6,7 @@ ;; Overview page ;; --------------------------------------------------------------------------- -(defcomp ~testing-overview-content (&key server-results framework-source eval-source parser-source router-source render-source) +(defcomp ~testing-overview-content (&key server-results framework-source eval-source parser-source router-source render-source deps-source engine-source) (~doc-page :title "Testing" (div :class "space-y-8" @@ -35,6 +35,8 @@ +-- test-parser.sx 39 tests: tokenizer, parser, serializer +-- test-router.sx 18 tests: route matching + param extraction +-- test-render.sx 23 tests: HTML rendering + components + +-- test-deps.sx 33 tests: dependency analysis + IO detection + +-- test-engine.sx 37 tests: trigger/swap/retry parsing Runners: run.js Node.js — injects platform fns, runs specs @@ -51,7 +53,9 @@ Platform functions (5 total): Per-spec platform functions: parser: sx-parse, sx-serialize, make-symbol, make-keyword, ... router: (none — pure spec, uses bootstrapped functions) - render: render-html (wraps parse + render-to-html)"))) + render: render-html (wraps parse + render-to-html) + deps: test-env (returns current evaluation environment) + engine: (none — pure spec, uses bootstrapped functions)"))) ;; Server results (when server-results @@ -86,6 +90,8 @@ Per-spec platform functions: (textarea :id "test-spec-parser" :style "display:none" parser-source) (textarea :id "test-spec-router" :style "display:none" router-source) (textarea :id "test-spec-render" :style "display:none" render-source) + (textarea :id "test-spec-deps" :style "display:none" deps-source) + (textarea :id "test-spec-engine" :style "display:none" engine-source) (script :src (asset-url "/scripts/sx-test-runner.js"))) ;; Test spec index @@ -107,7 +113,15 @@ Per-spec platform functions: (a :href "/testing/render" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" (h3 :class "font-semibold text-stone-800" "Renderer") (p :class "text-sm text-stone-500" "23 tests — elements, attributes, void elements, fragments, escaping, control flow, components") - (p :class "text-xs text-violet-600 mt-1" "test-render.sx")))) + (p :class "text-xs text-violet-600 mt-1" "test-render.sx")) + (a :href "/testing/deps" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Dependencies") + (p :class "text-sm text-stone-500" "33 tests — scan-refs, transitive-deps, components-needed, IO detection, purity classification") + (p :class "text-xs text-violet-600 mt-1" "test-deps.sx")) + (a :href "/testing/engine" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors" + (h3 :class "font-semibold text-stone-800" "Engine") + (p :class "text-sm text-stone-500" "37 tests — parse-time, trigger specs, swap specs, retry logic, param filtering") + (p :class "text-xs text-violet-600 mt-1" "test-engine.sx")))) ;; What it proves (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 6fee3a7..dd9ec74 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -553,7 +553,9 @@ :eval-source eval-source :parser-source parser-source :router-source router-source - :render-source render-source)) + :render-source render-source + :deps-source deps-source + :engine-source engine-source)) (defpage testing-page :path "/testing/" @@ -570,6 +572,8 @@ "parser" (run-modular-tests "parser") "router" (run-modular-tests "router") "render" (run-modular-tests "render") + "deps" (run-modular-tests "deps") + "engine" (run-modular-tests "engine") :else (dict)) :content (case slug "eval" (~testing-spec-content @@ -600,6 +604,20 @@ :spec-source spec-source :framework-source framework-source :server-results server-results) + "deps" (~testing-spec-content + :spec-name "deps" + :spec-title "Dependency Analysis Tests" + :spec-desc "33 tests covering component dependency analysis — scan-refs, scan-components-from-source, transitive-deps, components-needed, scan-io-refs, and component-pure? classification." + :spec-source spec-source + :framework-source framework-source + :server-results server-results) + "engine" (~testing-spec-content + :spec-name "engine" + :spec-title "Engine Tests" + :spec-desc "37 tests covering engine pure functions — parse-time, parse-trigger-spec, default-trigger, parse-swap-spec, parse-retry-spec, next-retry-ms, and filter-params." + :spec-source spec-source + :framework-source framework-source + :server-results server-results) "runners" (~testing-runners-content) :else (~testing-overview-content :server-results server-results))) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 8a25df9..d578fbd 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -707,6 +707,8 @@ def _run_modular_tests(spec_name: str) -> dict: "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, "router": {"file": "test-router.sx", "needs": []}, "render": {"file": "test-render.sx", "needs": ["render-html"]}, + "deps": {"file": "test-deps.sx", "needs": []}, + "engine": {"file": "test-engine.sx", "needs": []}, } specs_to_run = list(SPECS.keys()) if spec_name == "all" else [spec_name] @@ -721,7 +723,7 @@ def _run_modular_tests(spec_name: str) -> dict: if not spec: continue - # Load router from bootstrap if needed + # Load module functions from bootstrap if sn == "router": try: from shared.sx.ref.sx_ref import ( @@ -740,6 +742,46 @@ def _run_modular_tests(spec_name: str) -> dict: env["make-route-segment"] = make_route_segment except ImportError: eval_file("router.sx") + elif sn == "deps": + try: + from shared.sx.ref.sx_ref import ( + scan_refs, scan_components_from_source, + transitive_deps, compute_all_deps, + components_needed, page_component_bundle, + page_css_classes, scan_io_refs, + transitive_io_refs, compute_all_io_refs, + component_pure_p, + ) + env["scan-refs"] = scan_refs + env["scan-components-from-source"] = scan_components_from_source + env["transitive-deps"] = transitive_deps + env["compute-all-deps"] = compute_all_deps + env["components-needed"] = components_needed + env["page-component-bundle"] = page_component_bundle + env["page-css-classes"] = page_css_classes + env["scan-io-refs"] = scan_io_refs + env["transitive-io-refs"] = transitive_io_refs + env["compute-all-io-refs"] = compute_all_io_refs + env["component-pure?"] = component_pure_p + env["test-env"] = lambda: env + except ImportError: + eval_file("deps.sx") + env["test-env"] = lambda: env + elif sn == "engine": + try: + from shared.sx.ref.sx_ref import ( + parse_time, parse_trigger_spec, default_trigger, + parse_swap_spec, parse_retry_spec, filter_params, + ) + env["parse-time"] = parse_time + env["parse-trigger-spec"] = parse_trigger_spec + env["default-trigger"] = default_trigger + env["parse-swap-spec"] = parse_swap_spec + env["parse-retry-spec"] = parse_retry_spec + env["next-retry-ms"] = lambda cur, cap: min(cur * 2, cap) + env["filter-params"] = filter_params + except ImportError: + eval_file("engine.sx") eval_file(spec["file"]) @@ -763,6 +805,8 @@ def _run_modular_tests(spec_name: str) -> dict: result["parser-source"] = _read_spec_file("test-parser.sx") result["router-source"] = _read_spec_file("test-router.sx") result["render-source"] = _read_spec_file("test-render.sx") + result["deps-source"] = _read_spec_file("test-deps.sx") + result["engine-source"] = _read_spec_file("test-engine.sx") else: spec = SPECS.get(spec_name) if spec: