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:
@@ -302,9 +302,10 @@ def create_base_app(
|
|||||||
return
|
return
|
||||||
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
||||||
|
|
||||||
@app.before_request
|
if not no_db:
|
||||||
async def _load_user():
|
@app.before_request
|
||||||
await load_current_user()
|
async def _load_user():
|
||||||
|
await load_current_user()
|
||||||
|
|
||||||
# Register any app-specific before-request hooks (e.g. cart loader)
|
# Register any app-specific before-request hooks (e.g. cart loader)
|
||||||
if before_request_fns:
|
if before_request_fns:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,3 +49,13 @@
|
|||||||
.sx-loading-btn .sx-spinner {
|
.sx-loading-btn .sx-spinner {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle jiggle on links while fetching */
|
||||||
|
@keyframes sxJiggle {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-0.5px); }
|
||||||
|
75% { transform: translateX(0.5px); }
|
||||||
|
}
|
||||||
|
a.sx-request {
|
||||||
|
animation: sxJiggle 0.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,7 +293,12 @@ async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
|
|||||||
async def full_page_sx(ctx: dict, *, header_rows: str,
|
async def full_page_sx(ctx: dict, *, header_rows: str,
|
||||||
filter: str = "", aside: str = "",
|
filter: str = "", aside: str = "",
|
||||||
content: str = "", menu: str = "",
|
content: str = "", menu: str = "",
|
||||||
meta_html: str = "", meta: str = "") -> str:
|
meta_html: str = "", meta: str = "",
|
||||||
|
head_scripts: list[str] | None = None,
|
||||||
|
inline_css: str | None = None,
|
||||||
|
inline_head_js: str | None = None,
|
||||||
|
init_sx: str | None = None,
|
||||||
|
body_scripts: list[str] | None = None) -> str:
|
||||||
"""Build a full page using sx_page() with ~app-body.
|
"""Build a full page using sx_page() with ~app-body.
|
||||||
|
|
||||||
meta_html: raw HTML injected into the <head> shell (legacy).
|
meta_html: raw HTML injected into the <head> shell (legacy).
|
||||||
@@ -313,7 +318,10 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
|
|||||||
# Wrap body + meta in a fragment so sx.js renders both;
|
# Wrap body + meta in a fragment so sx.js renders both;
|
||||||
# auto-hoist moves meta/title/link elements to <head>.
|
# auto-hoist moves meta/title/link elements to <head>.
|
||||||
body_sx = _sx_fragment(meta, body_sx)
|
body_sx = _sx_fragment(meta, body_sx)
|
||||||
return await sx_page(ctx, body_sx, meta_html=meta_html)
|
return await sx_page(ctx, body_sx, meta_html=meta_html,
|
||||||
|
head_scripts=head_scripts, inline_css=inline_css,
|
||||||
|
inline_head_js=inline_head_js, init_sx=init_sx,
|
||||||
|
body_scripts=body_scripts)
|
||||||
|
|
||||||
|
|
||||||
def _build_component_ast(__name: str, **kwargs: Any) -> list:
|
def _build_component_ast(__name: str, **kwargs: Any) -> list:
|
||||||
@@ -518,8 +526,12 @@ def components_for_request(source: str = "",
|
|||||||
if val.has_children:
|
if val.has_children:
|
||||||
param_strs.extend(["&rest", "children"])
|
param_strs.extend(["&rest", "children"])
|
||||||
params_sx = "(" + " ".join(param_strs) + ")"
|
params_sx = "(" + " ".join(param_strs) + ")"
|
||||||
body_sx = serialize(val.body, pretty=True)
|
body_sx = serialize(val.body, indent=1, pretty=True)
|
||||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
head = f"(defcomp ~{val.name} {params_sx}"
|
||||||
|
if "\n" in body_sx:
|
||||||
|
parts.append(f"{head}\n {body_sx})")
|
||||||
|
else:
|
||||||
|
parts.append(f"{head} {body_sx})")
|
||||||
elif isinstance(val, Macro):
|
elif isinstance(val, Macro):
|
||||||
if val.name in loaded:
|
if val.name in loaded:
|
||||||
continue
|
continue
|
||||||
@@ -527,8 +539,12 @@ def components_for_request(source: str = "",
|
|||||||
if val.rest_param:
|
if val.rest_param:
|
||||||
param_strs.extend(["&rest", val.rest_param])
|
param_strs.extend(["&rest", val.rest_param])
|
||||||
params_sx = "(" + " ".join(param_strs) + ")"
|
params_sx = "(" + " ".join(param_strs) + ")"
|
||||||
body_sx = serialize(val.body, pretty=True)
|
body_sx = serialize(val.body, indent=1, pretty=True)
|
||||||
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
|
head = f"(defmacro {val.name} {params_sx}"
|
||||||
|
if "\n" in body_sx:
|
||||||
|
parts.append(f"{head}\n {body_sx})")
|
||||||
|
else:
|
||||||
|
parts.append(f"{head} {body_sx})")
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -752,7 +768,12 @@ def _sx_literal(v: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def sx_page(ctx: dict, page_sx: str, *,
|
async def sx_page(ctx: dict, page_sx: str, *,
|
||||||
meta_html: str = "") -> str:
|
meta_html: str = "",
|
||||||
|
head_scripts: list[str] | None = None,
|
||||||
|
inline_css: str | None = None,
|
||||||
|
inline_head_js: str | None = None,
|
||||||
|
init_sx: str | None = None,
|
||||||
|
body_scripts: list[str] | None = None) -> str:
|
||||||
"""Return a minimal HTML shell that boots the page from sx source.
|
"""Return a minimal HTML shell that boots the page from sx source.
|
||||||
|
|
||||||
The browser loads component definitions and page sx, then sx.js
|
The browser loads component definitions and page sx, then sx.js
|
||||||
@@ -817,8 +838,21 @@ async def sx_page(ctx: dict, page_sx: str, *,
|
|||||||
if isinstance(page_sx, SxExpr):
|
if isinstance(page_sx, SxExpr):
|
||||||
page_sx = "".join([page_sx])
|
page_sx = "".join([page_sx])
|
||||||
|
|
||||||
return await render_to_html(
|
# Per-app shell config: check explicit args, then app config, then defaults
|
||||||
"sx-page-shell",
|
from quart import current_app as _app
|
||||||
|
_shell_cfg = _app.config.get("SX_SHELL", {})
|
||||||
|
if head_scripts is None:
|
||||||
|
head_scripts = _shell_cfg.get("head_scripts")
|
||||||
|
if inline_css is None:
|
||||||
|
inline_css = _shell_cfg.get("inline_css")
|
||||||
|
if inline_head_js is None:
|
||||||
|
inline_head_js = _shell_cfg.get("inline_head_js")
|
||||||
|
if init_sx is None:
|
||||||
|
init_sx = _shell_cfg.get("init_sx")
|
||||||
|
if body_scripts is None:
|
||||||
|
body_scripts = _shell_cfg.get("body_scripts")
|
||||||
|
|
||||||
|
shell_kwargs: dict[str, Any] = dict(
|
||||||
title=_html_escape(title),
|
title=_html_escape(title),
|
||||||
asset_url=asset_url,
|
asset_url=asset_url,
|
||||||
meta_html=meta_html,
|
meta_html=meta_html,
|
||||||
@@ -832,6 +866,17 @@ async def sx_page(ctx: dict, page_sx: str, *,
|
|||||||
sx_js_hash=_script_hash("sx-browser.js"),
|
sx_js_hash=_script_hash("sx-browser.js"),
|
||||||
body_js_hash=_script_hash("body.js"),
|
body_js_hash=_script_hash("body.js"),
|
||||||
)
|
)
|
||||||
|
if head_scripts is not None:
|
||||||
|
shell_kwargs["head_scripts"] = head_scripts
|
||||||
|
if inline_css is not None:
|
||||||
|
shell_kwargs["inline_css"] = inline_css
|
||||||
|
if inline_head_js is not None:
|
||||||
|
shell_kwargs["inline_head_js"] = inline_head_js
|
||||||
|
if init_sx is not None:
|
||||||
|
shell_kwargs["init_sx"] = init_sx
|
||||||
|
if body_scripts is not None:
|
||||||
|
shell_kwargs["body_scripts"] = body_scripts
|
||||||
|
return await render_to_html("sx-page-shell", **shell_kwargs)
|
||||||
|
|
||||||
|
|
||||||
_SX_STREAMING_RESOLVE = """\
|
_SX_STREAMING_RESOLVE = """\
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ def watch_sx_dir(directory: str) -> None:
|
|||||||
|
|
||||||
def reload_if_changed() -> None:
|
def reload_if_changed() -> None:
|
||||||
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
|
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
|
||||||
changed = False
|
import logging
|
||||||
|
import time
|
||||||
|
_logger = logging.getLogger("sx.reload")
|
||||||
|
|
||||||
|
changed_files = []
|
||||||
for directory in _watched_dirs:
|
for directory in _watched_dirs:
|
||||||
for fp in sorted(
|
for fp in sorted(
|
||||||
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
||||||
@@ -140,14 +144,27 @@ def reload_if_changed() -> None:
|
|||||||
mtime = os.path.getmtime(fp)
|
mtime = os.path.getmtime(fp)
|
||||||
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
|
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
|
||||||
_file_mtimes[fp] = mtime
|
_file_mtimes[fp] = mtime
|
||||||
changed = True
|
changed_files.append(fp)
|
||||||
if changed:
|
if changed_files:
|
||||||
|
for fp in changed_files:
|
||||||
|
_logger.info("Changed: %s", fp)
|
||||||
|
t0 = time.monotonic()
|
||||||
_COMPONENT_ENV.clear()
|
_COMPONENT_ENV.clear()
|
||||||
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
|
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
|
||||||
for cb in _reload_callbacks:
|
for cb in _reload_callbacks:
|
||||||
cb()
|
cb()
|
||||||
for directory in _watched_dirs:
|
for directory in _watched_dirs:
|
||||||
load_sx_dir(directory)
|
load_sx_dir(directory)
|
||||||
|
t1 = time.monotonic()
|
||||||
|
_logger.info("Reloaded %d file(s), components in %.1fms",
|
||||||
|
len(changed_files), (t1 - t0) * 1000)
|
||||||
|
|
||||||
|
# Recompute render plans for all services that have pages
|
||||||
|
from .pages import _PAGE_REGISTRY, compute_page_render_plans
|
||||||
|
for svc in _PAGE_REGISTRY:
|
||||||
|
t2 = time.monotonic()
|
||||||
|
compute_page_render_plans(svc)
|
||||||
|
_logger.info("Render plans for %s in %.1fms", svc, (time.monotonic() - t2) * 1000)
|
||||||
|
|
||||||
|
|
||||||
def load_service_components(service_dir: str, service_name: str | None = None) -> None:
|
def load_service_components(service_dir: str, service_name: str | None = None) -> None:
|
||||||
|
|||||||
@@ -846,17 +846,22 @@ def compute_page_render_plans(service_name: str) -> None:
|
|||||||
Must be called after components are loaded (compute_all_deps/io_refs done)
|
Must be called after components are loaded (compute_all_deps/io_refs done)
|
||||||
and pages are registered. Stores plans on PageDef.render_plan.
|
and pages are registered. Stores plans on PageDef.render_plan.
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
from .parser import serialize
|
from .parser import serialize
|
||||||
from .deps import page_render_plan, get_all_io_names
|
from .deps import page_render_plan, get_all_io_names
|
||||||
from .jinja_bridge import _COMPONENT_ENV
|
from .jinja_bridge import _COMPONENT_ENV
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
io_names = get_all_io_names()
|
io_names = get_all_io_names()
|
||||||
pages = get_all_pages(service_name)
|
pages = get_all_pages(service_name)
|
||||||
|
count = 0
|
||||||
for page_def in pages.values():
|
for page_def in pages.values():
|
||||||
if page_def.content_expr is not None:
|
if page_def.content_expr is not None:
|
||||||
content_src = serialize(page_def.content_expr)
|
content_src = serialize(page_def.content_expr)
|
||||||
page_def.render_plan = page_render_plan(content_src, _COMPONENT_ENV, io_names)
|
page_def.render_plan = page_render_plan(content_src, _COMPONENT_ENV, io_names)
|
||||||
logger.info("Computed render plans for %d pages in %s", len(pages), service_name)
|
count += 1
|
||||||
|
elapsed = (time.monotonic() - t0) * 1000
|
||||||
|
logger.info("Computed render plans for %d pages in %s (%.1fms)", count, service_name, elapsed)
|
||||||
|
|
||||||
|
|
||||||
def auto_mount_pages(app: Any, service_name: str) -> None:
|
def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||||
|
|||||||
@@ -211,6 +211,13 @@
|
|||||||
(or (nil? text) (empty? (trim text)))
|
(or (nil? text) (empty? (trim text)))
|
||||||
nil
|
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
|
;; Mount directive
|
||||||
(dom-has-attr? s "data-mount")
|
(dom-has-attr? s "data-mount")
|
||||||
(let ((mount-sel (dom-get-attr s "data-mount"))
|
(let ((mount-sel (dom-get-attr s "data-mount"))
|
||||||
|
|||||||
@@ -420,6 +420,8 @@ class JSEmitter:
|
|||||||
"promise-delayed": "promiseDelayed",
|
"promise-delayed": "promiseDelayed",
|
||||||
"abort-previous": "abortPrevious",
|
"abort-previous": "abortPrevious",
|
||||||
"track-controller": "trackController",
|
"track-controller": "trackController",
|
||||||
|
"abort-previous-target": "abortPreviousTarget",
|
||||||
|
"track-controller-target": "trackControllerTarget",
|
||||||
"new-abort-controller": "newAbortController",
|
"new-abort-controller": "newAbortController",
|
||||||
"controller-signal": "controllerSignal",
|
"controller-signal": "controllerSignal",
|
||||||
"abort-error?": "isAbortError",
|
"abort-error?": "isAbortError",
|
||||||
@@ -470,7 +472,6 @@ class JSEmitter:
|
|||||||
"try-async-eval-content": "tryAsyncEvalContent",
|
"try-async-eval-content": "tryAsyncEvalContent",
|
||||||
"register-io-deps": "registerIoDeps",
|
"register-io-deps": "registerIoDeps",
|
||||||
"url-pathname": "urlPathname",
|
"url-pathname": "urlPathname",
|
||||||
"bind-inline-handler": "bindInlineHandler",
|
|
||||||
"bind-preload": "bindPreload",
|
"bind-preload": "bindPreload",
|
||||||
"mark-processed!": "markProcessed",
|
"mark-processed!": "markProcessed",
|
||||||
"is-processed?": "isProcessed",
|
"is-processed?": "isProcessed",
|
||||||
@@ -584,6 +585,7 @@ class JSEmitter:
|
|||||||
"scan-io-refs-walk": "scanIoRefsWalk",
|
"scan-io-refs-walk": "scanIoRefsWalk",
|
||||||
"transitive-io-refs": "transitiveIoRefs",
|
"transitive-io-refs": "transitiveIoRefs",
|
||||||
"compute-all-io-refs": "computeAllIoRefs",
|
"compute-all-io-refs": "computeAllIoRefs",
|
||||||
|
"component-io-refs-cached": "componentIoRefsCached",
|
||||||
"component-pure?": "componentPure_p",
|
"component-pure?": "componentPure_p",
|
||||||
"render-target": "renderTarget",
|
"render-target": "renderTarget",
|
||||||
"page-render-plan": "pageRenderPlan",
|
"page-render-plan": "pageRenderPlan",
|
||||||
@@ -3091,6 +3093,19 @@ PLATFORM_ORCHESTRATION_JS = """
|
|||||||
if (_controllers) _controllers.set(el, ctrl);
|
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() {
|
function newAbortController() {
|
||||||
return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} };
|
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 ---
|
// --- Preload binding ---
|
||||||
|
|
||||||
function bindPreload(el, events, debounceMs, fn) {
|
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["schedule-idle"] = scheduleIdle;
|
||||||
PRIMITIVES["invoke"] = invoke;
|
PRIMITIVES["invoke"] = invoke;
|
||||||
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ class PyEmitter:
|
|||||||
"scan-io-refs-walk": "scan_io_refs_walk",
|
"scan-io-refs-walk": "scan_io_refs_walk",
|
||||||
"transitive-io-refs": "transitive_io_refs",
|
"transitive-io-refs": "transitive_io_refs",
|
||||||
"compute-all-io-refs": "compute_all_io_refs",
|
"compute-all-io-refs": "compute_all_io_refs",
|
||||||
|
"component-io-refs-cached": "component_io_refs_cached",
|
||||||
"component-pure?": "component_pure_p",
|
"component-pure?": "component_pure_p",
|
||||||
"render-target": "render_target",
|
"render-target": "render_target",
|
||||||
"page-render-plan": "page_render_plan",
|
"page-render-plan": "page_render_plan",
|
||||||
|
|||||||
@@ -308,9 +308,27 @@
|
|||||||
(env-components env))))
|
(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?
|
(define component-pure?
|
||||||
(fn (name env io-names)
|
(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")
|
(if (= target "server")
|
||||||
(do
|
(do
|
||||||
(append! server-list name)
|
(append! server-list name)
|
||||||
;; Collect IO deps from server components
|
;; Collect IO deps from server components (use cache)
|
||||||
(for-each
|
(for-each
|
||||||
(fn (io-ref)
|
(fn (io-ref)
|
||||||
(when (not (contains? io-deps io-ref))
|
(when (not (contains? io-deps io-ref))
|
||||||
(append! 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))))
|
(append! client-list name))))
|
||||||
needed)
|
needed)
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,8 @@
|
|||||||
"promise-delayed" "promiseDelayed"
|
"promise-delayed" "promiseDelayed"
|
||||||
"abort-previous" "abortPrevious"
|
"abort-previous" "abortPrevious"
|
||||||
"track-controller" "trackController"
|
"track-controller" "trackController"
|
||||||
|
"abort-previous-target" "abortPreviousTarget"
|
||||||
|
"track-controller-target" "trackControllerTarget"
|
||||||
"new-abort-controller" "newAbortController"
|
"new-abort-controller" "newAbortController"
|
||||||
"controller-signal" "controllerSignal"
|
"controller-signal" "controllerSignal"
|
||||||
"abort-error?" "isAbortError"
|
"abort-error?" "isAbortError"
|
||||||
@@ -397,7 +399,6 @@
|
|||||||
"try-async-eval-content" "tryAsyncEvalContent"
|
"try-async-eval-content" "tryAsyncEvalContent"
|
||||||
"register-io-deps" "registerIoDeps"
|
"register-io-deps" "registerIoDeps"
|
||||||
"url-pathname" "urlPathname"
|
"url-pathname" "urlPathname"
|
||||||
"bind-inline-handler" "bindInlineHandler"
|
|
||||||
"bind-preload" "bindPreload"
|
"bind-preload" "bindPreload"
|
||||||
"mark-processed!" "markProcessed"
|
"mark-processed!" "markProcessed"
|
||||||
"is-processed?" "isProcessed"
|
"is-processed?" "isProcessed"
|
||||||
@@ -507,6 +508,7 @@
|
|||||||
"scan-io-refs-walk" "scanIoRefsWalk"
|
"scan-io-refs-walk" "scanIoRefsWalk"
|
||||||
"transitive-io-refs" "transitiveIoRefs"
|
"transitive-io-refs" "transitiveIoRefs"
|
||||||
"compute-all-io-refs" "computeAllIoRefs"
|
"compute-all-io-refs" "computeAllIoRefs"
|
||||||
|
"component-io-refs-cached" "componentIoRefsCached"
|
||||||
"component-pure?" "componentPure_p"
|
"component-pure?" "componentPure_p"
|
||||||
"render-target" "renderTarget"
|
"render-target" "renderTarget"
|
||||||
"page-render-plan" "pageRenderPlan"
|
"page-render-plan" "pageRenderPlan"
|
||||||
|
|||||||
@@ -109,12 +109,22 @@
|
|||||||
(fn (el verb method url extraParams)
|
(fn (el verb method url extraParams)
|
||||||
;; Execute the actual fetch. Manages abort, headers, body, loading state.
|
;; Execute the actual fetch. Manages abort, headers, body, loading state.
|
||||||
(let ((sync (dom-get-attr el "sx-sync")))
|
(let ((sync (dom-get-attr el "sx-sync")))
|
||||||
;; Abort previous if sync mode
|
;; Abort previous if sync mode (per-element)
|
||||||
(when (= sync "replace")
|
(when (= sync "replace")
|
||||||
(abort-previous el))
|
(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)))
|
(let ((ctrl (new-abort-controller)))
|
||||||
(track-controller el ctrl)
|
(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
|
;; Build request
|
||||||
(let ((body-info (build-request-body el method url))
|
(let ((body-info (build-request-body el method url))
|
||||||
@@ -909,7 +919,9 @@
|
|||||||
(try-async-eval-content content-src env
|
(try-async-eval-content content-src env
|
||||||
(fn (rendered)
|
(fn (rendered)
|
||||||
(if (nil? 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))))
|
(swap-rendered-content target rendered pathname))))
|
||||||
true)
|
true)
|
||||||
;; Sync render (data only)
|
;; Sync render (data only)
|
||||||
@@ -932,12 +944,16 @@
|
|||||||
(try-async-eval-content content-src env
|
(try-async-eval-content content-src env
|
||||||
(fn (rendered)
|
(fn (rendered)
|
||||||
(if (nil? 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))))
|
(swap-rendered-content target rendered pathname))))
|
||||||
;; Sync render (data only)
|
;; Sync render (data only)
|
||||||
(let ((rendered (try-eval-content content-src env)))
|
(let ((rendered (try-eval-content content-src env)))
|
||||||
(if (nil? rendered)
|
(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)))))))
|
(swap-rendered-content target rendered pathname)))))))
|
||||||
true)))
|
true)))
|
||||||
;; Non-data page
|
;; Non-data page
|
||||||
@@ -948,7 +964,9 @@
|
|||||||
(try-async-eval-content content-src (merge closure params)
|
(try-async-eval-content content-src (merge closure params)
|
||||||
(fn (rendered)
|
(fn (rendered)
|
||||||
(if (nil? 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))))
|
(swap-rendered-content target rendered pathname))))
|
||||||
true)
|
true)
|
||||||
;; Pure page: render immediately
|
;; Pure page: render immediately
|
||||||
@@ -1033,7 +1051,9 @@
|
|||||||
|
|
||||||
(define bind-inline-handlers
|
(define bind-inline-handlers
|
||||||
(fn (root)
|
(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
|
(for-each
|
||||||
(fn (el)
|
(fn (el)
|
||||||
(for-each
|
(for-each
|
||||||
@@ -1044,9 +1064,19 @@
|
|||||||
(let ((event-name (slice name 6)))
|
(let ((event-name (slice name 6)))
|
||||||
(when (not (is-processed? el (str "on:" event-name)))
|
(when (not (is-processed? el (str "on:" event-name)))
|
||||||
(mark-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-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 controllers ===
|
||||||
;; (abort-previous el) → abort + remove controller for element
|
;; (abort-previous el) → abort + remove controller for element
|
||||||
;; (track-controller el ctrl) → store 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()
|
;; (new-abort-controller) → new AbortController()
|
||||||
;; (controller-signal ctrl) → ctrl.signal
|
;; (controller-signal ctrl) → ctrl.signal
|
||||||
;; (abort-error? err) → boolean (err.name === "AbortError")
|
;; (abort-error? err) → boolean (err.name === "AbortError")
|
||||||
@@ -1274,7 +1306,7 @@
|
|||||||
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
||||||
;;
|
;;
|
||||||
;; === Inline handlers ===
|
;; === 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 ===
|
;; === Preload ===
|
||||||
;; (bind-preload el events debounce-ms fn) → void
|
;; (bind-preload el events debounce-ms fn) → void
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
"scan-io-refs-walk" "scan_io_refs_walk"
|
"scan-io-refs-walk" "scan_io_refs_walk"
|
||||||
"transitive-io-refs" "transitive_io_refs"
|
"transitive-io-refs" "transitive_io_refs"
|
||||||
"compute-all-io-refs" "compute_all_io_refs"
|
"compute-all-io-refs" "compute_all_io_refs"
|
||||||
|
"component-io-refs-cached" "component_io_refs_cached"
|
||||||
"component-pure?" "component_pure_p"
|
"component-pure?" "component_pure_p"
|
||||||
"render-target" "render_target"
|
"render-target" "render_target"
|
||||||
"page-render-plan" "page_render_plan"
|
"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.
|
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
|
||||||
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))
|
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?
|
||||||
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
|
||||||
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)))
|
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
|
||||||
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
|
||||||
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))
|
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))
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
sx-css sx-css-classes
|
sx-css sx-css-classes
|
||||||
component-hash component-defs
|
component-hash component-defs
|
||||||
pages-sx page-sx
|
pages-sx page-sx
|
||||||
asset-url sx-js-hash body-js-hash)
|
asset-url sx-js-hash body-js-hash
|
||||||
|
head-scripts inline-css inline-head-js
|
||||||
|
init-sx body-scripts)
|
||||||
(<>
|
(<>
|
||||||
(raw! "<!doctype html>")
|
(raw! "<!doctype html>")
|
||||||
(html :lang "en"
|
(html :lang "en"
|
||||||
@@ -26,21 +28,32 @@
|
|||||||
(meta :name "theme-color" :content "#ffffff")
|
(meta :name "theme-color" :content "#ffffff")
|
||||||
(title title)
|
(title title)
|
||||||
(when meta-html (raw! meta-html))
|
(when meta-html (raw! meta-html))
|
||||||
(style (raw! "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }"))
|
|
||||||
(meta :name "csrf-token" :content csrf)
|
(meta :name "csrf-token" :content csrf)
|
||||||
(style :id "sx-css" (raw! (or sx-css "")))
|
(style :id "sx-css" (raw! (or sx-css "")))
|
||||||
(meta :name "sx-css-classes" :content (or sx-css-classes ""))
|
(meta :name "sx-css-classes" :content (or sx-css-classes ""))
|
||||||
;; CDN scripts
|
;; CDN / head scripts — configurable per app
|
||||||
(script :src "https://unpkg.com/prismjs/prism.js")
|
;; Pass a list (even empty) to override defaults; nil = use defaults
|
||||||
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
(if (not (nil? head-scripts))
|
||||||
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
(map (fn (src) (script :src src)) head-scripts)
|
||||||
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
;; Default: Prism + SweetAlert (legacy apps)
|
||||||
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
(<>
|
||||||
;; Inline JS
|
(script :src "https://unpkg.com/prismjs/prism.js")
|
||||||
(script (raw! "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}"))
|
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||||
(script (raw! "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})"))
|
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||||
;; Inline CSS
|
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||||
(style (raw! "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}
|
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")))
|
||||||
|
;; Inline JS — skipped when app provides its own inline-head-js (even empty)
|
||||||
|
(if (not (nil? inline-head-js))
|
||||||
|
(when (not (empty? inline-head-js)) (script (raw! inline-head-js)))
|
||||||
|
(<>
|
||||||
|
(script (raw! "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}"))
|
||||||
|
(script (raw! "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})"))))
|
||||||
|
;; Inline CSS — configurable per app
|
||||||
|
;; Pass a string (even empty) to override defaults; nil = use defaults
|
||||||
|
(if (not (nil? inline-css))
|
||||||
|
(style (raw! inline-css))
|
||||||
|
;; Default: all shared styles (legacy apps)
|
||||||
|
(style (raw! "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}
|
||||||
details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}
|
details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}
|
||||||
@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}
|
@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}
|
||||||
img{max-width:100%;height:auto}
|
img{max-width:100%;height:auto}
|
||||||
@@ -50,13 +63,21 @@ img{max-width:100%;height:auto}
|
|||||||
details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}
|
details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}
|
||||||
.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}
|
.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}
|
||||||
.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}
|
.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}
|
||||||
.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")))
|
.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))))
|
||||||
(body :class "bg-stone-50 text-stone-900"
|
(body :class "bg-stone-50 text-stone-900"
|
||||||
(script :type "text/sx" :data-components true :data-hash component-hash
|
(script :type "text/sx" :data-components true :data-hash component-hash
|
||||||
(raw! (or component-defs "")))
|
(raw! (or component-defs "")))
|
||||||
|
(when init-sx
|
||||||
|
(script :type "text/sx" :data-init true
|
||||||
|
(raw! init-sx)))
|
||||||
(script :type "text/sx-pages"
|
(script :type "text/sx-pages"
|
||||||
(raw! (or pages-sx "")))
|
(raw! (or pages-sx "")))
|
||||||
(script :type "text/sx" :data-mount "body"
|
(script :type "text/sx" :data-mount "body"
|
||||||
(raw! (or page-sx "")))
|
(raw! (or page-sx "")))
|
||||||
(script :src (str asset-url "/scripts/sx-browser.js?v=" sx-js-hash))
|
(script :src (str asset-url "/scripts/sx-browser.js?v=" sx-js-hash))
|
||||||
(script :src (str asset-url "/scripts/body.js?v=" body-js-hash))))))
|
;; Body scripts — configurable per app
|
||||||
|
;; Pass a list (even empty) to override defaults; nil = use defaults
|
||||||
|
(if (not (nil? body-scripts))
|
||||||
|
(map (fn (src) (script :src src)) body-scripts)
|
||||||
|
;; Default: body.js (legacy apps)
|
||||||
|
(script :src (str asset-url "/scripts/body.js?v=" body-js-hash)))))))
|
||||||
|
|||||||
28
sx/app.py
28
sx/app.py
@@ -67,6 +67,34 @@ def create_app() -> "Quart":
|
|||||||
**extra_kw,
|
**extra_kw,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Minimal shell — no Prism, no SweetAlert, no body.js
|
||||||
|
# sx docs uses custom highlight.py, not Prism; body.js is for legacy apps
|
||||||
|
app.config["SX_SHELL"] = {
|
||||||
|
"head_scripts": [], # no CDN scripts
|
||||||
|
"body_scripts": [], # no body.js
|
||||||
|
"inline_head_js": "", # no pre-boot JS (hover-capable, close-details unused)
|
||||||
|
"inline_css": (
|
||||||
|
".sx-indicator{display:none}"
|
||||||
|
".sx-request .sx-indicator{display:inline-flex}"
|
||||||
|
"@keyframes sxJiggle{0%,100%{transform:translateX(0)}"
|
||||||
|
"25%{transform:translateX(-.5px)}75%{transform:translateX(.5px)}}"
|
||||||
|
"a.sx-request{animation:sxJiggle .3s ease-in-out infinite}"
|
||||||
|
),
|
||||||
|
# Nav link aria-selected update on client-side routing — pure SX
|
||||||
|
"init_sx": (
|
||||||
|
'(dom-listen (dom-body) "sx:clientRoute"'
|
||||||
|
' (fn (e)'
|
||||||
|
' (let ((p (get (event-detail e) "pathname")))'
|
||||||
|
' (when p'
|
||||||
|
' (for-each'
|
||||||
|
' (fn (a) (dom-set-attr a "aria-selected" "false"))'
|
||||||
|
' (dom-query-all "nav a[aria-selected]"))'
|
||||||
|
' (for-each'
|
||||||
|
' (fn (a) (dom-set-attr a "aria-selected" "true"))'
|
||||||
|
' (dom-query-all (str "nav a[href=\\"" p "\\"]")))))))'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
from sxc.pages import setup_sx_pages
|
from sxc.pages import setup_sx_pages
|
||||||
setup_sx_pages()
|
setup_sx_pages()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
(defcomp ~sx-home-content ()
|
(defcomp ~sx-home-content ()
|
||||||
(div :id "main-content" :class "max-w-3xl mx-auto px-4 py-6"
|
(div :id "main-content" :class "max-w-3xl mx-auto px-4 py-6"
|
||||||
(highlight "(defcomp ~sx-header ()
|
(~doc-code :code (highlight "(defcomp ~sx-header ()
|
||||||
(a :href \"/\"
|
(a :href \"/\"
|
||||||
:sx-get \"/\" :sx-target \"#main-panel\"
|
:sx-get \"/\" :sx-target \"#main-panel\"
|
||||||
:sx-select \"#main-panel\"
|
:sx-select \"#main-panel\"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
(p :class \"text-lg text-stone-500 mb-1\"
|
(p :class \"text-lg text-stone-500 mb-1\"
|
||||||
\"Framework free reactive hypermedia\")
|
\"Framework free reactive hypermedia\")
|
||||||
(p :class \"text-xs text-stone-400\"
|
(p :class \"text-xs text-stone-400\"
|
||||||
\"© Giles Bradshaw 2026\")))" "lisp")))
|
\"© Giles Bradshaw 2026\")))" "lisp"))))
|
||||||
|
|
||||||
(defcomp ~docs-introduction-content ()
|
(defcomp ~docs-introduction-content ()
|
||||||
(~doc-page :title "Introduction"
|
(~doc-page :title "Introduction"
|
||||||
|
|||||||
@@ -21,35 +21,41 @@
|
|||||||
(p :class "text-xs text-stone-400"
|
(p :class "text-xs text-stone-400"
|
||||||
"© Giles Bradshaw 2026")))
|
"© Giles Bradshaw 2026")))
|
||||||
|
|
||||||
;; Current section with annotated prev/next siblings.
|
;; @css grid grid-cols-3
|
||||||
;; Desktop: prev ← Current → next (horizontal)
|
|
||||||
;; Mobile: stacked vertically
|
;; Current section with prev/next siblings.
|
||||||
(defcomp ~nav-sibling-row (&key node siblings)
|
;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
|
||||||
(let* ((idx (find-nav-index siblings node))
|
;; Current page is larger in the leaf (bottom) row.
|
||||||
(count (len siblings))
|
(defcomp ~nav-sibling-row (&key node siblings is-leaf)
|
||||||
(prev-idx (mod (+ (- idx 1) count) count))
|
(let* ((sibs (or siblings (list)))
|
||||||
(next-idx (mod (+ idx 1) count))
|
(count (len sibs)))
|
||||||
(prev-node (nth siblings prev-idx))
|
(when (> count 0)
|
||||||
(next-node (nth siblings next-idx)))
|
(let* ((idx (find-nav-index sibs node))
|
||||||
(div :class "max-w-3xl mx-auto px-4 py-2 flex items-center justify-center gap-4"
|
(prev-idx (mod (+ (- idx 1) count) count))
|
||||||
(a :href (get prev-node "href")
|
(next-idx (mod (+ idx 1) count))
|
||||||
:sx-get (get prev-node "href") :sx-target "#main-panel"
|
(prev-node (nth sibs prev-idx))
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
(next-node (nth sibs next-idx)))
|
||||||
:sx-push-url "true"
|
(div :class "max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center"
|
||||||
:class "text-sm text-stone-500 hover:text-violet-600"
|
(a :href (get prev-node "href")
|
||||||
(str "← " (get prev-node "label")))
|
:sx-get (get prev-node "href") :sx-target "#main-panel"
|
||||||
(a :href (get node "href")
|
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||||
:sx-get (get node "href") :sx-target "#main-panel"
|
:sx-push-url "true"
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
:class "text-sm text-stone-500 hover:text-violet-600 text-right"
|
||||||
:sx-push-url "true"
|
(str "← " (get prev-node "label")))
|
||||||
:class "text-lg font-semibold text-violet-700 px-4"
|
(a :href (get node "href")
|
||||||
(get node "label"))
|
:sx-get (get node "href") :sx-target "#main-panel"
|
||||||
(a :href (get next-node "href")
|
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||||
:sx-get (get next-node "href") :sx-target "#main-panel"
|
:sx-push-url "true"
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
:class (if is-leaf
|
||||||
:sx-push-url "true"
|
"text-2xl font-bold text-violet-700 text-center px-4"
|
||||||
:class "text-sm text-stone-500 hover:text-violet-600"
|
"text-lg font-semibold text-violet-700 text-center px-4")
|
||||||
(str (get next-node "label") " →")))))
|
(get node "label"))
|
||||||
|
(a :href (get next-node "href")
|
||||||
|
:sx-get (get next-node "href") :sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class "text-sm text-stone-500 hover:text-violet-600 text-left"
|
||||||
|
(str (get next-node "label") " →")))))))
|
||||||
|
|
||||||
;; Children links — shown as clearly clickable buttons.
|
;; Children links — shown as clearly clickable buttons.
|
||||||
(defcomp ~nav-children (&key items)
|
(defcomp ~nav-children (&key items)
|
||||||
@@ -70,16 +76,20 @@
|
|||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
(defcomp ~sx-doc (&key path &rest children) :affinity :server
|
(defcomp ~sx-doc (&key path &rest children) :affinity :server
|
||||||
(let ((nav-state (resolve-nav-path sx-nav-tree (or path "/"))))
|
(let* ((nav-state (resolve-nav-path sx-nav-tree (or path "/")))
|
||||||
|
(trail (or (get nav-state "trail") (list)))
|
||||||
|
(trail-len (len trail)))
|
||||||
(<>
|
(<>
|
||||||
(div :id "sx-nav" :class "mb-6"
|
(div :id "sx-nav" :class "mb-6"
|
||||||
(~sx-header)
|
(~sx-header)
|
||||||
;; Sibling arrows for EVERY level in the trail
|
;; Sibling arrows for EVERY level in the trail
|
||||||
(map (fn (crumb)
|
;; Last row (leaf) gets is-leaf for larger current page title
|
||||||
|
(map-indexed (fn (i crumb)
|
||||||
(~nav-sibling-row
|
(~nav-sibling-row
|
||||||
:node (get crumb "node")
|
:node (get crumb "node")
|
||||||
:siblings (get crumb "siblings")))
|
:siblings (get crumb "siblings")
|
||||||
(get nav-state "trail"))
|
:is-leaf (= i (- trail-len 1))))
|
||||||
|
trail)
|
||||||
;; Children as button links
|
;; Children as button links
|
||||||
(when (get nav-state "children")
|
(when (get nav-state "children")
|
||||||
(~nav-children :items (get nav-state "children"))))
|
(~nav-children :items (get nav-state "children"))))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
(defcomp ~doc-page (&key title &rest children)
|
(defcomp ~doc-page (&key title &rest children)
|
||||||
(div :class "max-w-4xl mx-auto px-6 py-8"
|
(div :class "max-w-4xl mx-auto px-6 py-8"
|
||||||
(h1 :class "text-4xl font-bold text-stone-900 mb-8 text-center" title)
|
|
||||||
(div :class "prose prose-stone max-w-none space-y-6" children)))
|
(div :class "prose prose-stone max-w-none space-y-6" children)))
|
||||||
|
|
||||||
(defcomp ~doc-section (&key title id &rest children)
|
(defcomp ~doc-section (&key title id &rest children)
|
||||||
|
|||||||
Reference in New Issue
Block a user