From 5f06e2e2cc05ab64f31d93f737f698747476c955 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 12 Mar 2026 23:05:01 +0000 Subject: [PATCH] Spec URL evaluation in router.sx, bootstrap to Python/JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add url-to-expr, auto-quote-unknowns, prepare-url-expr to router.sx — the canonical URL-to-expression pipeline. Dots→spaces, parse, then auto-quote unknown symbols as strings (slugs). The same spec serves both server (Python) and client (JS) route handling. - router.sx: three new pure functions for URL evaluation - bootstrap_py.py: auto-include router module with html adapter - platform_js.py: export urlToExpr/autoQuoteUnknowns/prepareUrlExpr - sx_router.py: replace hand-written auto_quote_slugs with bootstrapped prepare_url_expr — delete ~50 lines of hardcoded function name sets - Rebootstrap sx_ref.py (4331 lines) and sx-browser.js Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 29 ++- shared/sx/ref/bootstrap_py.py | 5 +- shared/sx/ref/platform_js.py | 3 + shared/sx/ref/router.sx | 106 ++++++++- shared/sx/ref/sx_ref.py | 355 ++++++++++++++++++++++++++++ sx/sxc/pages/sx_router.py | 198 +++++----------- 6 files changed, 546 insertions(+), 150 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 8d7c26b..a314584 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-12T22:27:08Z"; + var SX_VERSION = "2026-03-12T22:55:39Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -3922,6 +3922,30 @@ callExpr.push(dictGet(kwargs, k)); } } return (isSxTruthy((get(parsed, "type") == "special-form")) ? get(parsed, "inner") : NIL); })(); }; + // url-to-expr + var urlToExpr = function(urlPath) { return (isSxTruthy(sxOr((urlPath == "/"), isEmpty(urlPath))) ? [] : (function() { + var trimmed = (isSxTruthy(startsWith(urlPath, "/")) ? slice(urlPath, 1) : urlPath); + return (function() { + var sxSource = replace_(trimmed, ".", " "); + return (function() { + var exprs = sxParse(sxSource); + return (isSxTruthy(isEmpty(exprs)) ? [] : first(exprs)); +})(); +})(); +})()); }; + + // auto-quote-unknowns + var autoQuoteUnknowns = function(expr, env) { return (isSxTruthy(!isSxTruthy(isList(expr))) ? expr : (isSxTruthy(isEmpty(expr)) ? expr : cons(first(expr), map(function(child) { return (isSxTruthy(isList(child)) ? autoQuoteUnknowns(child, env) : (isSxTruthy((typeOf(child) == "symbol")) ? (function() { + var name = symbolName(child); + return (isSxTruthy(sxOr(envHas(env, name), startsWith(name, ":"), startsWith(name, "~"), startsWith(name, "!"))) ? child : name); +})() : child)); }, rest(expr))))); }; + + // prepare-url-expr + var prepareUrlExpr = function(urlPath, env) { return (function() { + var expr = urlToExpr(urlPath); + return (isSxTruthy(isEmpty(expr)) ? expr : autoQuoteUnknowns(expr, env)); +})(); }; + // === Transpiled from signals (reactive signal runtime) === @@ -6300,6 +6324,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { parseRoutePattern: parseRoutePattern, matchRoute: matchRoute, findMatchingRoute: findMatchingRoute, + urlToExpr: urlToExpr, + autoQuoteUnknowns: autoQuoteUnknowns, + prepareUrlExpr: prepareUrlExpr, registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null, registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null, asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null, diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index b84010b..bd77a4b 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -1254,7 +1254,8 @@ def compile_ref_to_py( if sm not in SPEC_MODULES: raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}") spec_mod_set.add(sm) - # html adapter needs deps (component analysis) and signals (island rendering) + # html adapter needs deps (component analysis), signals (island rendering), + # router (URL-to-expression evaluation), and page-helpers if "html" in adapter_set: if "deps" in SPEC_MODULES: spec_mod_set.add("deps") @@ -1262,6 +1263,8 @@ def compile_ref_to_py( spec_mod_set.add("signals") if "page-helpers" in SPEC_MODULES: spec_mod_set.add("page-helpers") + if "router" in SPEC_MODULES: + spec_mod_set.add("router") has_deps = "deps" in spec_mod_set # Core files always included, then selected adapters, then spec modules diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py index c0d8bca..04be0f0 100644 --- a/shared/sx/ref/platform_js.py +++ b/shared/sx/ref/platform_js.py @@ -3129,6 +3129,9 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has api_lines.append(' parseRoutePattern: parseRoutePattern,') api_lines.append(' matchRoute: matchRoute,') api_lines.append(' findMatchingRoute: findMatchingRoute,') + api_lines.append(' urlToExpr: urlToExpr,') + api_lines.append(' autoQuoteUnknowns: autoQuoteUnknowns,') + api_lines.append(' prepareUrlExpr: prepareUrlExpr,') if has_dom: api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,') diff --git a/shared/sx/ref/router.sx b/shared/sx/ref/router.sx index 322ca2c..862c18a 100644 --- a/shared/sx/ref/router.sx +++ b/shared/sx/ref/router.sx @@ -570,11 +570,111 @@ ;; -------------------------------------------------------------------------- -;; Platform interface — none required +;; 12. URL expression evaluation ;; -------------------------------------------------------------------------- -;; All functions use only pure primitives: +;; A URL is an expression. The system is the environment. +;; eval(url, env) — that's it. +;; +;; The only URL-specific pre-processing: +;; 1. Surface syntax → AST (dots to spaces, parse as SX) +;; 2. Auto-quote unknowns (symbols not in env become strings) +;; +;; After that, it's standard eval. The host wires these into its route +;; handlers (Python catch-all, JS client-side navigation). The same +;; functions serve both. + +(define url-to-expr :effects [] + (fn ((url-path :as string)) + ;; Convert a URL path to an SX expression (AST). + ;; + ;; "/sx/(language.(doc.introduction))" → (language (doc introduction)) + ;; "/(language.(doc.introduction))" → (language (doc introduction)) + ;; "/" → (list) ; empty — home + ;; + ;; Steps: + ;; 1. Strip URL prefix ("/sx/" or "/") — host passes the path after prefix + ;; 2. Dots → spaces (URL-safe whitespace encoding) + ;; 3. Parse as SX expression + ;; + ;; The caller is responsible for stripping any app-level prefix. + ;; This function receives the raw expression portion: "(language.(doc.intro))" + ;; or "/" for home. + (if (or (= url-path "/") (empty? url-path)) + (list) + (let ((trimmed (if (starts-with? url-path "/") + (slice url-path 1) + url-path))) + ;; Dots → spaces + (let ((sx-source (replace trimmed "." " "))) + ;; Parse — returns list of expressions, take the first + (let ((exprs (sx-parse sx-source))) + (if (empty? exprs) + (list) + (first exprs)))))))) + + +(define auto-quote-unknowns :effects [] + (fn ((expr :as list) (env :as dict)) + ;; Walk an AST and replace symbols not in env with their name as a string. + ;; This makes URL slugs work without quoting: + ;; (language (doc introduction)) ; introduction is not a function + ;; → (language (doc "introduction")) + ;; + ;; Rules: + ;; - List head (call position) stays as-is — it's a function name + ;; - Tail symbols: if in env, keep as symbol; otherwise, string + ;; - Keywords, strings, numbers, nested lists: pass through + ;; - Non-list expressions: pass through unchanged + (if (not (list? expr)) + expr + (if (empty? expr) + expr + ;; Head stays as symbol (function position), quote the rest + (cons (first expr) + (map (fn (child) + (cond + ;; Nested list — recurse + (list? child) + (auto-quote-unknowns child env) + ;; Symbol — check env + (= (type-of child) "symbol") + (let ((name (symbol-name child))) + (if (or (env-has? env name) + ;; Keep keywords, component refs, special forms + (starts-with? name ":") + (starts-with? name "~") + (starts-with? name "!")) + child + name)) ;; unknown → string + ;; Everything else passes through + :else child)) + (rest expr))))))) + + +(define prepare-url-expr :effects [] + (fn ((url-path :as string) (env :as dict)) + ;; Full pipeline: URL path → ready-to-eval AST. + ;; + ;; "(language.(doc.introduction))" + env + ;; → (language (doc "introduction")) + ;; + ;; The result can be fed directly to eval: + ;; (eval (prepare-url-expr path env) env) + (let ((expr (url-to-expr url-path))) + (if (empty? expr) + expr + (auto-quote-unknowns expr env))))) + + +;; -------------------------------------------------------------------------- +;; Platform interface +;; -------------------------------------------------------------------------- +;; Pure primitives used: ;; split, slice, starts-with?, ends-with?, len, empty?, replace, ;; map, filter, for-each, for-each-indexed, nth, get, dict-set!, merge, ;; list, nil?, not, =, case, join, str, index-of, and, or, cons, -;; first, rest, append, parse-int, contains?, min, cond +;; first, rest, append, parse-int, contains?, min, cond, +;; symbol?, symbol-name, list?, env-has?, type-of +;; +;; From parser.sx: sx-parse, sx-serialize ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index ef96090..bc55c94 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -2881,6 +2881,361 @@ def build_affinity_analysis(demo_components, page_plans): return {'components': demo_components, 'page-plans': page_plans} +# === Transpiled from router (client-side route matching) === + +# split-path-segments +def split_path_segments(path): + trimmed = (slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path) + trimmed2 = (slice(trimmed, 0, (len(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) + if sx_truthy(empty_p(trimmed2)): + return [] + else: + return split(trimmed2, '/') + +# make-route-segment +def make_route_segment(seg): + if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))): + param_name = slice(seg, 1, (len(seg) - 1)) + d = {} + d['type'] = 'param' + d['value'] = param_name + return d + else: + d = {} + d['type'] = 'literal' + d['value'] = seg + return d + +# parse-route-pattern +def parse_route_pattern(pattern): + segments = split_path_segments(pattern) + return map(make_route_segment, segments) + +# match-route-segments +def match_route_segments(path_segs, parsed_segs): + _cells = {} + if sx_truthy((not sx_truthy((len(path_segs) == len(parsed_segs))))): + return NIL + else: + params = {} + _cells['matched'] = True + 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) + if sx_truthy(_cells['matched']): + return params + else: + return NIL + +# match-route +def match_route(path, pattern): + path_segs = split_path_segments(path) + parsed_segs = parse_route_pattern(pattern) + return match_route_segments(path_segs, parsed_segs) + +# find-matching-route +def find_matching_route(path, routes): + _cells = {} + match_path = ((sx_url_to_path(path) if sx_truthy(sx_url_to_path(path)) else path) if sx_truthy(starts_with_p(path, '/(')) else path) + path_segs = split_path_segments(match_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'] + +# _fn-to-segment +def _fn_to_segment(name): + _match = name + if _match == 'doc': + return 'docs' + elif _match == 'spec': + return 'specs' + elif _match == 'bootstrapper': + return 'bootstrappers' + elif _match == 'test': + return 'testing' + elif _match == 'example': + return 'examples' + elif _match == 'protocol': + return 'protocols' + elif _match == 'essay': + return 'essays' + elif _match == 'plan': + return 'plans' + elif _match == 'reference-detail': + return 'reference' + else: + return name + +# sx-url-to-path +def sx_url_to_path(url): + if sx_truthy((not sx_truthy((starts_with_p(url, '/(') if not sx_truthy(starts_with_p(url, '/(')) else ends_with_p(url, ')'))))): + return NIL + else: + inner = slice(url, 2, (len(url) - 1)) + s = replace(replace(replace(inner, '.', '/'), '(', ''), ')', '') + segs = filter(lambda s: (not sx_truthy(empty_p(s))), split(s, '/')) + return sx_str('/', join('/', map(_fn_to_segment, segs))) + +# _count-leading-dots +def _count_leading_dots(s): + if sx_truthy(empty_p(s)): + return 0 + else: + if sx_truthy(starts_with_p(s, '.')): + return (1 + _count_leading_dots(slice(s, 1))) + else: + return 0 + +# _strip-trailing-close +def _strip_trailing_close(s): + if sx_truthy(ends_with_p(s, ')')): + return _strip_trailing_close(slice(s, 0, (len(s) - 1))) + else: + return s + +# _index-of-safe +def _index_of_safe(s, needle): + idx = index_of(s, needle) + if sx_truthy((is_nil(idx) if sx_truthy(is_nil(idx)) else (idx < 0))): + return NIL + else: + return idx + +# _last-index-of +def _last_index_of(s, needle): + idx = _index_of_safe(s, needle) + if sx_truthy(is_nil(idx)): + return NIL + else: + rest_idx = _last_index_of(slice(s, (idx + 1)), needle) + if sx_truthy(is_nil(rest_idx)): + return idx + else: + return ((idx + 1) + rest_idx) + +# _pop-sx-url-level +def _pop_sx_url_level(url): + stripped = _strip_trailing_close(url) + close_count = (len(url) - len(_strip_trailing_close(url))) + if sx_truthy((close_count <= 1)): + return '/' + else: + last_dp = _last_index_of(stripped, '.(') + if sx_truthy(is_nil(last_dp)): + return '/' + else: + return sx_str(slice(stripped, 0, last_dp), slice(url, (len(url) - (close_count - 1)))) + +# _pop-sx-url-levels +def _pop_sx_url_levels(url, n): + if sx_truthy((n <= 0)): + return url + else: + return _pop_sx_url_levels(_pop_sx_url_level(url), (n - 1)) + +# _split-pos-kw +def _split_pos_kw(tokens, i, pos, kw): + if sx_truthy((i >= len(tokens))): + return {'positional': join('.', pos), 'keywords': kw} + else: + tok = nth(tokens, i) + if sx_truthy(starts_with_p(tok, ':')): + val = (nth(tokens, (i + 1)) if sx_truthy(((i + 1) < len(tokens))) else '') + return _split_pos_kw(tokens, (i + 2), pos, append(kw, [[tok, val]])) + else: + return _split_pos_kw(tokens, (i + 1), append(pos, [tok]), kw) + +# _parse-relative-body +def _parse_relative_body(body): + if sx_truthy(empty_p(body)): + return {'positional': '', 'keywords': []} + else: + return _split_pos_kw(split(body, '.'), 0, [], []) + +# _extract-innermost +def _extract_innermost(url): + stripped = _strip_trailing_close(url) + suffix = slice(url, len(_strip_trailing_close(url))) + last_dp = _last_index_of(stripped, '.(') + if sx_truthy(is_nil(last_dp)): + return {'before': '/(', 'content': slice(stripped, 2), 'suffix': suffix} + else: + return {'before': slice(stripped, 0, (last_dp + 2)), 'content': slice(stripped, (last_dp + 2)), 'suffix': suffix} + +# _find-kw-in-tokens +def _find_kw_in_tokens(tokens, i, kw): + if sx_truthy((i >= len(tokens))): + return NIL + else: + if sx_truthy(((nth(tokens, i) == kw) if not sx_truthy((nth(tokens, i) == kw)) else ((i + 1) < len(tokens)))): + return nth(tokens, (i + 1)) + else: + return _find_kw_in_tokens(tokens, (i + 1), kw) + +# _find-keyword-value +def _find_keyword_value(content, kw): + return _find_kw_in_tokens(split(content, '.'), 0, kw) + +# _replace-kw-in-tokens +def _replace_kw_in_tokens(tokens, i, kw, value): + if sx_truthy((i >= len(tokens))): + return [] + else: + if sx_truthy(((nth(tokens, i) == kw) if not sx_truthy((nth(tokens, i) == kw)) else ((i + 1) < len(tokens)))): + return append([kw, value], _replace_kw_in_tokens(tokens, (i + 2), kw, value)) + else: + return cons(nth(tokens, i), _replace_kw_in_tokens(tokens, (i + 1), kw, value)) + +# _set-keyword-in-content +def _set_keyword_in_content(content, kw, value): + current = _find_keyword_value(content, kw) + if sx_truthy(is_nil(current)): + return sx_str(content, '.', kw, '.', value) + else: + return join('.', _replace_kw_in_tokens(split(content, '.'), 0, kw, value)) + +# _is-delta-value? +def _is_delta_value_p(s): + return ((not sx_truthy(empty_p(s))) if not sx_truthy((not sx_truthy(empty_p(s)))) else ((len(s) > 1) if not sx_truthy((len(s) > 1)) else (starts_with_p(s, '+') if sx_truthy(starts_with_p(s, '+')) else starts_with_p(s, '-')))) + +# _apply-delta +def _apply_delta(current_str, delta_str): + cur = parse_int(current_str, NIL) + delta = parse_int(delta_str, NIL) + if sx_truthy((is_nil(cur) if sx_truthy(is_nil(cur)) else is_nil(delta))): + return delta_str + else: + return sx_str((cur + delta)) + +# _apply-kw-pairs +def _apply_kw_pairs(content, kw_pairs): + if sx_truthy(empty_p(kw_pairs)): + return content + else: + pair = first(kw_pairs) + kw = first(pair) + raw_val = nth(pair, 1) + actual_val = ((lambda current: (raw_val if sx_truthy(is_nil(current)) else _apply_delta(current, raw_val)))(_find_keyword_value(content, kw)) if sx_truthy(_is_delta_value_p(raw_val)) else raw_val) + return _apply_kw_pairs(_set_keyword_in_content(content, kw, actual_val), rest(kw_pairs)) + +# _apply-keywords-to-url +def _apply_keywords_to_url(url, kw_pairs): + if sx_truthy(empty_p(kw_pairs)): + return url + else: + parts = _extract_innermost(url) + new_content = _apply_kw_pairs(get(parts, 'content'), kw_pairs) + return sx_str(get(parts, 'before'), new_content, get(parts, 'suffix')) + +# _normalize-relative +def _normalize_relative(url): + if sx_truthy(starts_with_p(url, '(')): + return url + else: + return sx_str('(', url, ')') + +# resolve-relative-url +def resolve_relative_url(current, relative): + canonical = _normalize_relative(relative) + rel_inner = slice(canonical, 1, (len(canonical) - 1)) + dots = _count_leading_dots(rel_inner) + body = slice(rel_inner, _count_leading_dots(rel_inner)) + if sx_truthy((dots == 0)): + return current + else: + parsed = _parse_relative_body(body) + pos_body = get(parsed, 'positional') + kw_pairs = get(parsed, 'keywords') + after_nav = ((current if sx_truthy(empty_p(pos_body)) else (lambda stripped: (lambda suffix: sx_str(stripped, '.', pos_body, suffix))(slice(current, len(_strip_trailing_close(current)))))(_strip_trailing_close(current))) if sx_truthy((dots == 1)) else (lambda base: (base if sx_truthy(empty_p(pos_body)) else (sx_str('/(', pos_body, ')') if sx_truthy((base == '/')) else (lambda stripped: (lambda suffix: sx_str(stripped, '.(', pos_body, ')', suffix))(slice(base, len(_strip_trailing_close(base)))))(_strip_trailing_close(base)))))(_pop_sx_url_levels(current, (dots - 1)))) + return _apply_keywords_to_url(after_nav, kw_pairs) + +# relative-sx-url? +def relative_sx_url_p(url): + return ((starts_with_p(url, '(') if not sx_truthy(starts_with_p(url, '(')) else (not sx_truthy(starts_with_p(url, '/(')))) if sx_truthy((starts_with_p(url, '(') if not sx_truthy(starts_with_p(url, '(')) else (not sx_truthy(starts_with_p(url, '/('))))) else starts_with_p(url, '.')) + +# _url-special-forms +def _url_special_forms(): + return ['!source', '!inspect', '!diff', '!search', '!raw', '!json'] + +# url-special-form? +def url_special_form_p(name): + return (starts_with_p(name, '!') if not sx_truthy(starts_with_p(name, '!')) else contains_p(_url_special_forms(), name)) + +# parse-sx-url +def parse_sx_url(url): + if sx_truthy((url == '/')): + return {'type': 'home', 'raw': url} + elif sx_truthy(relative_sx_url_p(url)): + return {'type': 'relative', 'raw': url} + elif sx_truthy((starts_with_p(url, '/(!') if not sx_truthy(starts_with_p(url, '/(!')) else ends_with_p(url, ')'))): + inner = slice(url, 2, (len(url) - 1)) + dot_pos = _index_of_safe(inner, '.') + paren_pos = _index_of_safe(inner, '(') + end_pos = (len(inner) if sx_truthy((is_nil(dot_pos) if not sx_truthy(is_nil(dot_pos)) else is_nil(paren_pos))) else (paren_pos if sx_truthy(is_nil(dot_pos)) else (dot_pos if sx_truthy(is_nil(paren_pos)) else min(dot_pos, paren_pos)))) + form_name = slice(inner, 0, end_pos) + rest_part = slice(inner, end_pos) + inner_expr = (slice(rest_part, 1) if sx_truthy(starts_with_p(rest_part, '.')) else rest_part) + return {'type': 'special-form', 'form': form_name, 'inner': inner_expr, 'raw': url} + elif sx_truthy((starts_with_p(url, '/(~') if not sx_truthy(starts_with_p(url, '/(~')) else ends_with_p(url, ')'))): + name = slice(url, 2, (len(url) - 1)) + return {'type': 'direct-component', 'name': name, 'raw': url} + elif sx_truthy((starts_with_p(url, '/(') if not sx_truthy(starts_with_p(url, '/(')) else ends_with_p(url, ')'))): + return {'type': 'absolute', 'raw': url} + else: + return {'type': 'path', 'raw': url} + +# url-special-form-name +def url_special_form_name(url): + parsed = parse_sx_url(url) + if sx_truthy((get(parsed, 'type') == 'special-form')): + return get(parsed, 'form') + else: + return NIL + +# url-special-form-inner +def url_special_form_inner(url): + parsed = parse_sx_url(url) + if sx_truthy((get(parsed, 'type') == 'special-form')): + return get(parsed, 'inner') + else: + return NIL + +# url-to-expr +def url_to_expr(url_path): + if sx_truthy(((url_path == '/') if sx_truthy((url_path == '/')) else empty_p(url_path))): + return [] + else: + trimmed = (slice(url_path, 1) if sx_truthy(starts_with_p(url_path, '/')) else url_path) + sx_source = replace(trimmed, '.', ' ') + exprs = sx_parse(sx_source) + if sx_truthy(empty_p(exprs)): + return [] + else: + return first(exprs) + +# auto-quote-unknowns +def auto_quote_unknowns(expr, env): + if sx_truthy((not sx_truthy(list_p(expr)))): + return expr + else: + if sx_truthy(empty_p(expr)): + return expr + else: + return cons(first(expr), map(lambda child: (auto_quote_unknowns(child, env) if sx_truthy(list_p(child)) else ((lambda name: (child if sx_truthy((env_has(env, name) if sx_truthy(env_has(env, name)) else (starts_with_p(name, ':') if sx_truthy(starts_with_p(name, ':')) else (starts_with_p(name, '~') if sx_truthy(starts_with_p(name, '~')) else starts_with_p(name, '!'))))) else name))(symbol_name(child)) if sx_truthy((type_of(child) == 'symbol')) else child)), rest(expr))) + +# prepare-url-expr +def prepare_url_expr(url_path, env): + expr = url_to_expr(url_path) + if sx_truthy(empty_p(expr)): + return expr + else: + return auto_quote_unknowns(expr, env) + + # === Transpiled from signals (reactive signal runtime) === # signal diff --git a/sx/sxc/pages/sx_router.py b/sx/sxc/pages/sx_router.py index 2439a27..dc9398a 100644 --- a/sx/sxc/pages/sx_router.py +++ b/sx/sxc/pages/sx_router.py @@ -1,12 +1,17 @@ """GraphSX URL router — evaluate s-expression URLs. Handles URLs like /(language.(doc.introduction)) by: -1. Converting dots to spaces (dot = whitespace sugar) -2. Parsing the path as an SX expression -3. Auto-quoting unknown symbols to strings (slugs) -4. Evaluating the expression against page functions -5. Wrapping the result in (~layouts/doc :path "..." content) -6. Returning full page or OOB response +1. Delegating to the bootstrapped spec (router.sx → prepare_url_expr): + - Dots → spaces (URL-safe whitespace encoding) + - Parse as SX expression + - Auto-quote unknowns (symbols not in env → strings) +2. Evaluating the prepared expression against page functions +3. Wrapping the result in (~layouts/doc :path "..." content) +4. Returning full page or OOB response + +The URL evaluation logic lives in the SX spec (shared/sx/ref/router.sx) +and is bootstrapped to Python (sx_ref.py) and JavaScript (sx-browser.js). +This handler is generic infrastructure — all routing semantics are in SX. Special cases: - "/" → home page @@ -22,110 +27,9 @@ from urllib.parse import unquote logger = logging.getLogger("sx.router") -# --------------------------------------------------------------------------- -# Page function names — known in the eval env, NOT auto-quoted -# --------------------------------------------------------------------------- - -# Section functions (structural, pass through) -_SECTION_FNS = { - "home", "language", "geography", "applications", "etc", - "hypermedia", "reactive", "marshes", "isomorphism", -} - -# Page functions (leaf dispatch) -_PAGE_FNS = { - "doc", "spec", "explore", "bootstrapper", "test", - "reference", "reference-detail", "example", - "cssx", "protocol", "essay", "philosophy", "plan", - "sx-urls", -} - -# All known function names (don't auto-quote these) -_KNOWN_FNS = _SECTION_FNS | _PAGE_FNS | { - # Helpers defined in page-functions.sx - "make-spec-files", "page-helpers-demo-content-fn", -} - # --------------------------------------------------------------------------- -# Auto-quote slugs — convert unknown symbols to strings -# --------------------------------------------------------------------------- - -def auto_quote_slugs(expr: Any, known_fns: set[str]) -> Any: - """Walk AST and replace unknown symbols with their name as a string. - - Known function names stay as symbols so they resolve to callables. - Everything else (slugs like 'introduction', 'getting-started') becomes - a string literal — no quoting needed in the URL. - """ - from shared.sx.types import Symbol, Keyword - - if isinstance(expr, Symbol): - if expr.name in known_fns or expr.name.startswith("~"): - return expr - return expr.name # auto-quote to string - - if isinstance(expr, list) and expr: - head = expr[0] - # Head stays as-is (it's the function position) - result = [head] - for item in expr[1:]: - result.append(auto_quote_slugs(item, known_fns)) - return result - - return expr - - -# --------------------------------------------------------------------------- -# Dot → space conversion -# --------------------------------------------------------------------------- - -def _dots_to_spaces(s: str) -> str: - """Convert dots to spaces in URL expressions. - - Dots are unreserved in RFC 3986 and serve as URL-safe whitespace. - Applied before SX parsing: /(language.(doc.introduction)) - becomes /(language (doc introduction)). - """ - return s.replace(".", " ") - - -# --------------------------------------------------------------------------- -# Build expression from URL path -# --------------------------------------------------------------------------- - -def _parse_url_path(raw_path: str) -> Any: - """Parse a URL path into an SX AST. - - Returns the parsed expression, or None if the path isn't an SX URL. - """ - from shared.sx.parser import parse as sx_parse - from shared.sx.types import Symbol - - path = unquote(raw_path).strip() - - if path == "/": - return [Symbol("home")] - - # SX URLs start with /( — e.g. /(language (doc intro)) - if path.startswith("/(") and path.endswith(")"): - sx_source = _dots_to_spaces(path[1:]) # strip leading / - return sx_parse(sx_source) - - # Direct component URLs: /~component-name - if path.startswith("/~"): - name = path[1:] # keep the ~ prefix - sx_source = _dots_to_spaces(name) - if " " in sx_source: - # /~comp.arg1.arg2 → (~comp arg1 arg2) - return sx_parse(f"({sx_source})") - return [Symbol(sx_source)] - - return None # not an SX URL - - -# --------------------------------------------------------------------------- -# Streaming detection +# Streaming detection (host-level concern, not in spec) # --------------------------------------------------------------------------- _STREAMING_PAGES = { @@ -176,25 +80,48 @@ async def eval_sx_url(raw_path: str) -> Any: This is the main entry point for the catch-all route handler. Returns a Quart Response object, or None if the path isn't an SX URL. + + URL parsing and auto-quoting are delegated to the bootstrapped spec + functions (prepare_url_expr from router.sx). This handler provides + only the host-level concerns: building the env, async eval, response + formatting, streaming detection. """ from quart import make_response, Response from shared.sx.jinja_bridge import get_component_env, _get_request_context from shared.sx.pages import get_page, get_page_helpers, _eval_slot from shared.sx.types import Symbol, Keyword - from shared.sx.parser import serialize from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response from shared.sx.page import get_template_context from shared.browser.app.utils.htmx import is_htmx_request + from shared.sx.ref.sx_ref import prepare_url_expr - # Parse URL - expr = _parse_url_path(raw_path) - if expr is None: - return None # not an SX URL — let other handlers try + path = unquote(raw_path).strip() - # Check for streaming page BEFORE auto-quoting + # Home page + if path == "/": + expr = [Symbol("home")] + else: + # SX URLs: /(expr) or /~component + if not (path.startswith("/(") or path.startswith("/~")): + return None # not an SX URL — let other handlers try + + # Build env for auto-quoting: components + page helpers + env = dict(get_component_env()) + env.update(get_page_helpers("sx")) + + # Use the bootstrapped spec: parse URL, auto-quote unknowns + expr = prepare_url_expr(path[1:], env) # strip leading / + + # Bare symbol (e.g. /~comp) → wrap in list for eval + if isinstance(expr, Symbol): + expr = [expr] + + if not expr: + return None + + # Check for streaming page BEFORE eval streaming_page = _is_streaming_url(expr) if streaming_page: - # Delegate to existing streaming PageDef infrastructure page_def = get_page("sx", streaming_page) if page_def: from shared.sx.pages import execute_page_streaming, execute_page_streaming_oob @@ -204,42 +131,24 @@ async def eval_sx_url(raw_path: str) -> Any: gen = await execute_page_streaming(page_def, "sx") return Response(gen, content_type="text/html; charset=utf-8") - # Build env: components + page helpers (includes page functions from define) - env = dict(get_component_env()) - env.update(get_page_helpers("sx")) - - # Auto-quote unknown symbols (slugs become strings) - known = _KNOWN_FNS | set(env.keys()) - quoted_expr = auto_quote_slugs(expr, known) + # Build env if not already built (home case) + if path == "/": + env = dict(get_component_env()) + env.update(get_page_helpers("sx")) ctx = _get_request_context() # Nav hrefs use /sx/ prefix — reconstruct the full path for nav matching path_str = f"/sx{raw_path}" if raw_path != "/" else "/sx/" - # Check if expression head is a component (~plans/content-addressed-components/name) — if so, skip - # async_eval and pass directly to _eval_slot. Components contain HTML - # tags that only the aser path can handle, not eval_expr. - head = quoted_expr[0] if isinstance(quoted_expr, list) and quoted_expr else None - is_component_call = ( - isinstance(head, Symbol) - and head.name.startswith("~") - ) + # Component calls go straight to _eval_slot (aser handles expansion). + # Page function calls need async_eval first (routing + data fetching). + head = expr[0] if isinstance(expr, list) and expr else None + is_component_call = isinstance(head, Symbol) and head.name.startswith("~") if is_component_call: - # Direct component URL: /(~essays/sx-sucks/essay-sx-sucks) or /(~comp :key val) - # Pass straight to _eval_slot — aser handles component expansion. - page_ast = quoted_expr + page_ast = expr else: - # Two-phase evaluation for page function calls: - # Phase 1: Evaluate the page function expression with async_eval. - # Page functions return QUOTED expressions (unevaluated ASTs like - # [Symbol("~docs-intro-content")] or quasiquoted trees with data). - # This phase resolves routing + fetches data, but does NOT expand - # components or handle HTML tags (eval_expr can't do that). - # Phase 2: Wrap the returned AST in (~layouts/doc :path "..." ) and - # pass to _eval_slot (aser), which expands components and handles - # HTML tags correctly. import os if os.environ.get("SX_USE_REF") == "1": from shared.sx.ref.async_eval_ref import async_eval @@ -247,14 +156,13 @@ async def eval_sx_url(raw_path: str) -> Any: from shared.sx.async_eval import async_eval try: - page_ast = await async_eval(quoted_expr, env, ctx) + page_ast = await async_eval(expr, env, ctx) except Exception as e: logger.error("SX URL page-fn eval failed for %s: %s", raw_path, e, exc_info=True) return None - # page_ast is a quoted expression (list of Symbols/Keywords/data) or nil if page_ast is None: - page_ast = [] # empty content for sections with no index + page_ast = [] wrapped_ast = [ Symbol("~layouts/doc"), Keyword("path"), path_str,