SX docs: configurable shell, SX-native event handlers, nav fixes
- Configurable page shell (~sx-page-shell kwargs + SX_SHELL app config) so each app controls its own assets — sx docs loads only sx-browser.js - SX-evaluated sx-on:* handlers (eval-expr instead of new Function) with DOM primitives registered in PRIMITIVES table - data-init boot mode for pure SX initialization scripts - Jiggle animation on links while fetching - Nav: 3-column grid for centered alignment, is-leaf sizing, fix map-indexed param order (index, item), guard mod-by-zero - Async route eval failure now falls back to server fetch instead of silently rendering nothing - Remove duplicate h1 title from ~doc-page - Re-bootstrap sx-ref.js + sx-browser.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -211,6 +211,13 @@
|
||||
(or (nil? text) (empty? (trim text)))
|
||||
nil
|
||||
|
||||
;; Init scripts — evaluate SX for side effects (event listeners etc.)
|
||||
(dom-has-attr? s "data-init")
|
||||
(let ((exprs (sx-parse text)))
|
||||
(for-each
|
||||
(fn (expr) (eval-expr expr (env-extend (dict))))
|
||||
exprs))
|
||||
|
||||
;; Mount directive
|
||||
(dom-has-attr? s "data-mount")
|
||||
(let ((mount-sel (dom-get-attr s "data-mount"))
|
||||
|
||||
@@ -420,6 +420,8 @@ class JSEmitter:
|
||||
"promise-delayed": "promiseDelayed",
|
||||
"abort-previous": "abortPrevious",
|
||||
"track-controller": "trackController",
|
||||
"abort-previous-target": "abortPreviousTarget",
|
||||
"track-controller-target": "trackControllerTarget",
|
||||
"new-abort-controller": "newAbortController",
|
||||
"controller-signal": "controllerSignal",
|
||||
"abort-error?": "isAbortError",
|
||||
@@ -470,7 +472,6 @@ class JSEmitter:
|
||||
"try-async-eval-content": "tryAsyncEvalContent",
|
||||
"register-io-deps": "registerIoDeps",
|
||||
"url-pathname": "urlPathname",
|
||||
"bind-inline-handler": "bindInlineHandler",
|
||||
"bind-preload": "bindPreload",
|
||||
"mark-processed!": "markProcessed",
|
||||
"is-processed?": "isProcessed",
|
||||
@@ -584,6 +585,7 @@ class JSEmitter:
|
||||
"scan-io-refs-walk": "scanIoRefsWalk",
|
||||
"transitive-io-refs": "transitiveIoRefs",
|
||||
"compute-all-io-refs": "computeAllIoRefs",
|
||||
"component-io-refs-cached": "componentIoRefsCached",
|
||||
"component-pure?": "componentPure_p",
|
||||
"render-target": "renderTarget",
|
||||
"page-render-plan": "pageRenderPlan",
|
||||
@@ -3091,6 +3093,19 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
if (_controllers) _controllers.set(el, ctrl);
|
||||
}
|
||||
|
||||
var _targetControllers = typeof WeakMap !== "undefined" ? new WeakMap() : null;
|
||||
|
||||
function abortPreviousTarget(el) {
|
||||
if (_targetControllers) {
|
||||
var prev = _targetControllers.get(el);
|
||||
if (prev) prev.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function trackControllerTarget(el, ctrl) {
|
||||
if (_targetControllers) _targetControllers.set(el, ctrl);
|
||||
}
|
||||
|
||||
function newAbortController() {
|
||||
return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} };
|
||||
}
|
||||
@@ -3769,12 +3784,6 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inline handlers ---
|
||||
|
||||
function bindInlineHandler(el, eventName, body) {
|
||||
el.addEventListener(eventName, new Function("event", body));
|
||||
}
|
||||
|
||||
// --- Preload binding ---
|
||||
|
||||
function bindPreload(el, events, debounceMs, fn) {
|
||||
@@ -4088,7 +4097,24 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False):
|
||||
PRIMITIVES["schedule-idle"] = scheduleIdle;
|
||||
PRIMITIVES["invoke"] = invoke;
|
||||
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
|
||||
PRIMITIVES["filter"] = filter;''')
|
||||
PRIMITIVES["filter"] = filter;
|
||||
// DOM primitives for sx-on:* handlers and data-init scripts
|
||||
if (typeof domBody === "function") PRIMITIVES["dom-body"] = domBody;
|
||||
if (typeof domQuery === "function") PRIMITIVES["dom-query"] = domQuery;
|
||||
if (typeof domQueryAll === "function") PRIMITIVES["dom-query-all"] = domQueryAll;
|
||||
if (typeof domQueryById === "function") PRIMITIVES["dom-query-by-id"] = domQueryById;
|
||||
if (typeof domSetAttr === "function") PRIMITIVES["dom-set-attr"] = domSetAttr;
|
||||
if (typeof domGetAttr === "function") PRIMITIVES["dom-get-attr"] = domGetAttr;
|
||||
if (typeof domRemoveAttr === "function") PRIMITIVES["dom-remove-attr"] = domRemoveAttr;
|
||||
if (typeof domHasAttr === "function") PRIMITIVES["dom-has-attr?"] = domHasAttr;
|
||||
if (typeof domAddClass === "function") PRIMITIVES["dom-add-class"] = domAddClass;
|
||||
if (typeof domRemoveClass === "function") PRIMITIVES["dom-remove-class"] = domRemoveClass;
|
||||
if (typeof domHasClass === "function") PRIMITIVES["dom-has-class?"] = domHasClass;
|
||||
if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest;
|
||||
if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches;
|
||||
if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_;
|
||||
if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue;
|
||||
if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml;''')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -316,6 +316,7 @@ class PyEmitter:
|
||||
"scan-io-refs-walk": "scan_io_refs_walk",
|
||||
"transitive-io-refs": "transitive_io_refs",
|
||||
"compute-all-io-refs": "compute_all_io_refs",
|
||||
"component-io-refs-cached": "component_io_refs_cached",
|
||||
"component-pure?": "component_pure_p",
|
||||
"render-target": "render_target",
|
||||
"page-render-plan": "page_render_plan",
|
||||
|
||||
@@ -308,9 +308,27 @@
|
||||
(env-components env))))
|
||||
|
||||
|
||||
(define component-io-refs-cached
|
||||
(fn (name env io-names)
|
||||
(let ((key (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((val (env-get env key)))
|
||||
(if (and (= (type-of val) "component")
|
||||
(not (nil? (component-io-refs val)))
|
||||
(not (empty? (component-io-refs val))))
|
||||
(component-io-refs val)
|
||||
;; Fallback: not yet cached (shouldn't happen after compute-all-io-refs)
|
||||
(transitive-io-refs name env io-names))))))
|
||||
|
||||
(define component-pure?
|
||||
(fn (name env io-names)
|
||||
(empty? (transitive-io-refs name env io-names))))
|
||||
(let ((key (if (starts-with? name "~") name (str "~" name))))
|
||||
(let ((val (env-get env key)))
|
||||
(if (and (= (type-of val) "component")
|
||||
(not (nil? (component-io-refs val))))
|
||||
;; Use cached io-refs (empty list = pure)
|
||||
(empty? (component-io-refs val))
|
||||
;; Fallback
|
||||
(empty? (transitive-io-refs name env io-names)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -369,12 +387,12 @@
|
||||
(if (= target "server")
|
||||
(do
|
||||
(append! server-list name)
|
||||
;; Collect IO deps from server components
|
||||
;; Collect IO deps from server components (use cache)
|
||||
(for-each
|
||||
(fn (io-ref)
|
||||
(when (not (contains? io-deps io-ref))
|
||||
(append! io-deps io-ref)))
|
||||
(transitive-io-refs name env io-names)))
|
||||
(component-io-refs-cached name env io-names)))
|
||||
(append! client-list name))))
|
||||
needed)
|
||||
|
||||
|
||||
@@ -347,6 +347,8 @@
|
||||
"promise-delayed" "promiseDelayed"
|
||||
"abort-previous" "abortPrevious"
|
||||
"track-controller" "trackController"
|
||||
"abort-previous-target" "abortPreviousTarget"
|
||||
"track-controller-target" "trackControllerTarget"
|
||||
"new-abort-controller" "newAbortController"
|
||||
"controller-signal" "controllerSignal"
|
||||
"abort-error?" "isAbortError"
|
||||
@@ -397,7 +399,6 @@
|
||||
"try-async-eval-content" "tryAsyncEvalContent"
|
||||
"register-io-deps" "registerIoDeps"
|
||||
"url-pathname" "urlPathname"
|
||||
"bind-inline-handler" "bindInlineHandler"
|
||||
"bind-preload" "bindPreload"
|
||||
"mark-processed!" "markProcessed"
|
||||
"is-processed?" "isProcessed"
|
||||
@@ -507,6 +508,7 @@
|
||||
"scan-io-refs-walk" "scanIoRefsWalk"
|
||||
"transitive-io-refs" "transitiveIoRefs"
|
||||
"compute-all-io-refs" "computeAllIoRefs"
|
||||
"component-io-refs-cached" "componentIoRefsCached"
|
||||
"component-pure?" "componentPure_p"
|
||||
"render-target" "renderTarget"
|
||||
"page-render-plan" "pageRenderPlan"
|
||||
|
||||
@@ -109,12 +109,22 @@
|
||||
(fn (el verb method url extraParams)
|
||||
;; Execute the actual fetch. Manages abort, headers, body, loading state.
|
||||
(let ((sync (dom-get-attr el "sx-sync")))
|
||||
;; Abort previous if sync mode
|
||||
;; Abort previous if sync mode (per-element)
|
||||
(when (= sync "replace")
|
||||
(abort-previous el))
|
||||
|
||||
;; Abort any in-flight request targeting the same swap target.
|
||||
;; This ensures rapid navigation (click A then B) cancels A's fetch.
|
||||
(let ((target-el (resolve-target el)))
|
||||
(when target-el
|
||||
(abort-previous-target target-el)))
|
||||
|
||||
(let ((ctrl (new-abort-controller)))
|
||||
(track-controller el ctrl)
|
||||
;; Also track against the swap target for cross-element cancellation
|
||||
(let ((target-el (resolve-target el)))
|
||||
(when target-el
|
||||
(track-controller-target target-el ctrl)))
|
||||
|
||||
;; Build request
|
||||
(let ((body-info (build-request-body el method url))
|
||||
@@ -909,7 +919,9 @@
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async eval failed for " pathname))
|
||||
(do (log-warn (str "sx:route cache+async eval failed for " pathname " — server fallback"))
|
||||
(fetch-and-restore target pathname
|
||||
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
true)
|
||||
;; Sync render (data only)
|
||||
@@ -932,12 +944,16 @@
|
||||
(try-async-eval-content content-src env
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data+async eval failed for " pathname))
|
||||
(do (log-warn (str "sx:route data+async eval failed for " pathname " — server fallback"))
|
||||
(fetch-and-restore target pathname
|
||||
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
||||
(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))
|
||||
(do (log-warn (str "sx:route data eval failed for " pathname " — server fallback"))
|
||||
(fetch-and-restore target pathname
|
||||
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
||||
(swap-rendered-content target rendered pathname)))))))
|
||||
true)))
|
||||
;; Non-data page
|
||||
@@ -948,7 +964,9 @@
|
||||
(try-async-eval-content content-src (merge closure params)
|
||||
(fn (rendered)
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route async eval failed for " pathname))
|
||||
(do (log-warn (str "sx:route async eval failed for " pathname " — server fallback"))
|
||||
(fetch-and-restore target pathname
|
||||
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
||||
(swap-rendered-content target rendered pathname))))
|
||||
true)
|
||||
;; Pure page: render immediately
|
||||
@@ -1033,7 +1051,9 @@
|
||||
|
||||
(define bind-inline-handlers
|
||||
(fn (root)
|
||||
;; Find elements with sx-on:* attributes and bind handlers
|
||||
;; Find elements with sx-on:* attributes and bind SX event handlers.
|
||||
;; Handler bodies are SX expressions evaluated with `event` and `this`
|
||||
;; bound in scope. No raw JS — handlers are pure SX.
|
||||
(for-each
|
||||
(fn (el)
|
||||
(for-each
|
||||
@@ -1044,9 +1064,19 @@
|
||||
(let ((event-name (slice name 6)))
|
||||
(when (not (is-processed? el (str "on:" event-name)))
|
||||
(mark-processed! el (str "on:" event-name))
|
||||
(bind-inline-handler el event-name body))))))
|
||||
;; Parse body as SX, bind handler that evaluates it
|
||||
(let ((exprs (sx-parse body)))
|
||||
(dom-listen el event-name
|
||||
(fn (e)
|
||||
(let ((handler-env (env-extend (dict))))
|
||||
(env-set! handler-env "event" e)
|
||||
(env-set! handler-env "this" el)
|
||||
(env-set! handler-env "detail" (event-detail e))
|
||||
(for-each
|
||||
(fn (expr) (eval-expr expr handler-env))
|
||||
exprs))))))))))
|
||||
(dom-attr-list el)))
|
||||
(dom-query-all (or root (dom-body)) "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:load]"))))
|
||||
(dom-query-all (or root (dom-body)) "[sx-on\\:]"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -1209,6 +1239,8 @@
|
||||
;; === Abort controllers ===
|
||||
;; (abort-previous el) → abort + remove controller for element
|
||||
;; (track-controller el ctrl) → store controller for element
|
||||
;; (abort-previous-target el) → abort + remove controller for target element
|
||||
;; (track-controller-target el c) → store controller keyed by target element
|
||||
;; (new-abort-controller) → new AbortController()
|
||||
;; (controller-signal ctrl) → ctrl.signal
|
||||
;; (abort-error? err) → boolean (err.name === "AbortError")
|
||||
@@ -1274,7 +1306,7 @@
|
||||
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
||||
;;
|
||||
;; === Inline handlers ===
|
||||
;; (bind-inline-handler el event-name body) → void (new Function)
|
||||
;; (sx-on:* handlers are now evaluated as SX, not delegated to platform)
|
||||
;;
|
||||
;; === Preload ===
|
||||
;; (bind-preload el events debounce-ms fn) → void
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
"scan-io-refs-walk" "scan_io_refs_walk"
|
||||
"transitive-io-refs" "transitive_io_refs"
|
||||
"compute-all-io-refs" "compute_all_io_refs"
|
||||
"component-io-refs-cached" "component_io_refs_cached"
|
||||
"component-pure?" "component_pure_p"
|
||||
"render-target" "render_target"
|
||||
"page-render-plan" "page_render_plan"
|
||||
|
||||
5382
shared/sx/ref/sx-ref.js
Normal file
5382
shared/sx/ref/sx-ref.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
|
||||
# WARNING: eval.sx dispatches forms not in special-forms.sx: form?
|
||||
"""
|
||||
sx_ref.py -- Generated from reference SX evaluator specification.
|
||||
|
||||
@@ -1309,14 +1311,17 @@ transitive_io_refs = lambda name, env, io_names: (lambda all_refs: (lambda seen:
|
||||
# compute-all-io-refs
|
||||
compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (component_set_io_refs(val, transitive_io_refs(name, env, io_names)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
|
||||
|
||||
# component-io-refs-cached
|
||||
component_io_refs_cached = lambda name, env, io_names: (lambda key: (lambda val: (component_io_refs(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else ((not sx_truthy(is_nil(component_io_refs(val)))) if not sx_truthy((not sx_truthy(is_nil(component_io_refs(val))))) else (not sx_truthy(empty_p(component_io_refs(val))))))) else transitive_io_refs(name, env, io_names)))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name)))
|
||||
|
||||
# component-pure?
|
||||
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
|
||||
component_pure_p = lambda name, env, io_names: (lambda key: (lambda val: (empty_p(component_io_refs(val)) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(is_nil(component_io_refs(val)))))) else empty_p(transitive_io_refs(name, env, io_names))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name)))
|
||||
|
||||
# render-target
|
||||
render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server' if sx_truthy((not sx_truthy((type_of(val) == 'component')))) else (lambda affinity: ('server' if sx_truthy((affinity == 'server')) else ('client' if sx_truthy((affinity == 'client')) else ('server' if sx_truthy((not sx_truthy(component_pure_p(name, env, io_names)))) else 'client'))))(component_affinity(val))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name)))
|
||||
|
||||
# page-render-plan
|
||||
page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), transitive_io_refs(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env))
|
||||
page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), component_io_refs_cached(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env))
|
||||
|
||||
# env-components
|
||||
env_components = lambda env: filter(lambda k: (lambda v: (is_component(v) if sx_truthy(is_component(v)) else is_macro(v)))(env_get(env, k)), keys(env))
|
||||
@@ -1487,4 +1492,4 @@ def render(expr, env=None):
|
||||
|
||||
def make_env(**kwargs):
|
||||
"""Create an environment dict with initial bindings."""
|
||||
return dict(kwargs)
|
||||
return dict(kwargs)
|
||||
|
||||
Reference in New Issue
Block a user