"""GraphSX URL router — evaluate s-expression URLs. Handles URLs like /(language.(doc.introduction)) by: 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 - Streaming pages → delegate to existing PageDef infrastructure - Direct component URLs: /(~component-name) → render component """ from __future__ import annotations import logging import re from typing import Any from urllib.parse import unquote logger = logging.getLogger("sx.router") # --------------------------------------------------------------------------- # Streaming detection (host-level concern, not in spec) # --------------------------------------------------------------------------- _STREAMING_PAGES = { # URL slug → defpage name for streaming pages "streaming": "streaming-demo", } def _is_streaming_url(expr: Any) -> str | None: """Check if expression resolves to a streaming page. Returns the defpage name if streaming, None otherwise. Detects: (geography (isomorphism "streaming")) """ from shared.sx.types import Symbol # Check for (geography (isomorphism "streaming")) if not isinstance(expr, list) or len(expr) < 2: return None # Walk nested structure to find isomorphism call def _find_slug(e: Any) -> str | None: if not isinstance(e, list) or not e: return None head = e[0] if isinstance(head, Symbol) and head.name == "isomorphism": for arg in e[1:]: if isinstance(arg, str) and arg in _STREAMING_PAGES: return _STREAMING_PAGES[arg] if isinstance(arg, Symbol) and arg.name in _STREAMING_PAGES: return _STREAMING_PAGES[arg.name] # Recurse into nested calls for arg in e[1:]: result = _find_slug(arg) if result: return result return None return _find_slug(expr) # --------------------------------------------------------------------------- # Main eval handler # --------------------------------------------------------------------------- async def eval_sx_url(raw_path: str) -> Any: """Evaluate an SX URL and return the response. 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.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 path = unquote(raw_path).strip() # 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: page_def = get_page("sx", streaming_page) if page_def: from shared.sx.pages import execute_page_streaming, execute_page_streaming_oob if is_htmx_request(): gen = await execute_page_streaming_oob(page_def, "sx") return Response(gen, content_type="text/sx; charset=utf-8") gen = await execute_page_streaming(page_def, "sx") return Response(gen, content_type="text/html; charset=utf-8") # 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/" # 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: page_ast = expr else: import os if os.environ.get("SX_USE_REF") == "1": from shared.sx.ref.async_eval_ref import async_eval else: from shared.sx.async_eval import async_eval try: 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 if page_ast is None: page_ast = [] wrapped_ast = [ Symbol("~layouts/doc"), Keyword("path"), path_str, page_ast, ] try: content_sx = await _eval_slot(wrapped_ast, env, ctx) except Exception as e: logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True) return None # Return response if is_htmx_request(): return sx_response(await oob_page_sx(content=content_sx)) else: tctx = await get_template_context() html = await full_page_sx(tctx, header_rows="", content=content_sx) return await make_response(html, 200) # --------------------------------------------------------------------------- # Old URL → SX URL redirect patterns # --------------------------------------------------------------------------- _REDIRECT_PATTERNS = [ # --- API endpoint redirects (most specific first) --- # Reference API: /geography/hypermedia/reference/api/item/ (re.compile(r"^/geography/hypermedia/reference/api/item/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference.(api.(item.{m.group(1)})))))"), # Reference API: /geography/hypermedia/reference/api/ (re.compile(r"^/geography/hypermedia/reference/api/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference.(api.{m.group(1)}))))"), # Example API: sub-paths with params (re.compile(r"^/geography/hypermedia/examples/api/editrow/(.+?)/cancel/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.(api.(editrow-cancel.{m.group(1)})))))"), (re.compile(r"^/geography/hypermedia/examples/api/editrow/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.(api.(editrow.{m.group(1)})))))"), (re.compile(r"^/geography/hypermedia/examples/api/delete/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.(api.(delete.{m.group(1)})))))"), (re.compile(r"^/geography/hypermedia/examples/api/tabs/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.(api.(tabs.{m.group(1)})))))"), # Example API: sub-paths collapsed to hyphens (re.compile(r"^/geography/hypermedia/examples/api/edit/cancel/?$"), "/sx/(geography.(hypermedia.(example.(api.edit-cancel))))"), (re.compile(r"^/geography/hypermedia/examples/api/progress/start/?$"), "/sx/(geography.(hypermedia.(example.(api.progress-start))))"), (re.compile(r"^/geography/hypermedia/examples/api/progress/status/?$"), "/sx/(geography.(hypermedia.(example.(api.progress-status))))"), (re.compile(r"^/geography/hypermedia/examples/api/validate/submit/?$"), "/sx/(geography.(hypermedia.(example.(api.validate-submit))))"), (re.compile(r"^/geography/hypermedia/examples/api/dialog/close/?$"), "/sx/(geography.(hypermedia.(example.(api.dialog-close))))"), (re.compile(r"^/geography/hypermedia/examples/api/putpatch/edit-all/?$"), "/sx/(geography.(hypermedia.(example.(api.putpatch-edit-all))))"), (re.compile(r"^/geography/hypermedia/examples/api/putpatch/cancel/?$"), "/sx/(geography.(hypermedia.(example.(api.putpatch-cancel))))"), # Example API: simple names (re.compile(r"^/geography/hypermedia/examples/api/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.(api.{m.group(1)}))))"), # Reactive API: sub-paths collapsed (re.compile(r"^/geography/reactive/api/search/(.+?)/?$"), lambda m: f"/sx/(geography.(reactive.(api.search-{m.group(1)})))"), (re.compile(r"^/geography/reactive/api/(.+?)/?$"), lambda m: f"/sx/(geography.(reactive.(api.{m.group(1)})))"), # --- Page redirects --- # More specific first (re.compile(r"^/language/specs/explore/(.+?)/?$"), lambda m: f"/sx/(language.(spec.(explore.{m.group(1)})))"), (re.compile(r"^/language/docs/(.+?)/?$"), lambda m: f"/sx/(language.(doc.{m.group(1)}))"), (re.compile(r"^/language/docs/?$"), "/sx/(language.(doc))"), (re.compile(r"^/language/specs/(.+?)/?$"), lambda m: f"/sx/(language.(spec.{m.group(1)}))"), (re.compile(r"^/language/bootstrappers/page-helpers/?$"), "/sx/(language.(bootstrapper.page-helpers))"), (re.compile(r"^/language/bootstrappers/(.+?)/?$"), lambda m: f"/sx/(language.(bootstrapper.{m.group(1)}))"), (re.compile(r"^/language/testing/(.+?)/?$"), lambda m: f"/sx/(language.(test.{m.group(1)}))"), (re.compile(r"^/geography/hypermedia/reference/attributes/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference-detail.attributes.{m.group(1)})))"), (re.compile(r"^/geography/hypermedia/reference/headers/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference-detail.headers.{m.group(1)})))"), (re.compile(r"^/geography/hypermedia/reference/events/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference-detail.events.{m.group(1)})))"), (re.compile(r"^/geography/hypermedia/reference/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(reference.{m.group(1)})))"), (re.compile(r"^/geography/hypermedia/examples/(.+?)/?$"), lambda m: f"/sx/(geography.(hypermedia.(example.{m.group(1)})))"), (re.compile(r"^/geography/hypermedia/reference/?$"), "/sx/(geography.(hypermedia.(reference)))"), (re.compile(r"^/geography/hypermedia/examples/?$"), "/sx/(geography.(hypermedia.(example)))"), (re.compile(r"^/geography/reactive/(.+?)/?$"), lambda m: f"/sx/(geography.(reactive.{m.group(1)}))"), (re.compile(r"^/geography/isomorphism/(.+?)/?$"), lambda m: f"/sx/(geography.(isomorphism.{m.group(1)}))"), (re.compile(r"^/geography/cek/(.+?)/?$"), lambda m: f"/sx/(geography.(cek.{m.group(1)}))"), (re.compile(r"^/geography/spreads/?$"), "/sx/(geography.(spreads))"), (re.compile(r"^/geography/marshes/?$"), "/sx/(geography.(marshes))"), (re.compile(r"^/applications/cssx/(.+?)/?$"), lambda m: f"/sx/(applications.(cssx.{m.group(1)}))"), (re.compile(r"^/applications/protocols/(.+?)/?$"), lambda m: f"/sx/(applications.(protocol.{m.group(1)}))"), (re.compile(r"^/etc/essays/(.+?)/?$"), lambda m: f"/sx/(etc.(essay.{m.group(1)}))"), (re.compile(r"^/etc/philosophy/(.+?)/?$"), lambda m: f"/sx/(etc.(philosophy.{m.group(1)}))"), (re.compile(r"^/etc/plans/(.+?)/?$"), lambda m: f"/sx/(etc.(plan.{m.group(1)}))"), # Section indices (re.compile(r"^/language/docs/?$"), "/sx/(language.(doc))"), (re.compile(r"^/language/specs/?$"), "/sx/(language.(spec))"), (re.compile(r"^/language/bootstrappers/?$"), "/sx/(language.(bootstrapper))"), (re.compile(r"^/language/testing/?$"), "/sx/(language.(test))"), (re.compile(r"^/language/?$"), "/sx/(language)"), (re.compile(r"^/geography/hypermedia/?$"), "/sx/(geography.(hypermedia))"), (re.compile(r"^/geography/reactive/?$"), "/sx/(geography.(reactive))"), (re.compile(r"^/geography/isomorphism/?$"), "/sx/(geography.(isomorphism))"), (re.compile(r"^/geography/cek/?$"), "/sx/(geography.(cek))"), (re.compile(r"^/geography/?$"), "/sx/(geography)"), (re.compile(r"^/applications/cssx/?$"), "/sx/(applications.(cssx))"), (re.compile(r"^/applications/protocols/?$"), "/sx/(applications.(protocol))"), (re.compile(r"^/applications/?$"), "/sx/(applications)"), (re.compile(r"^/etc/essays/?$"), "/sx/(etc.(essay))"), (re.compile(r"^/etc/philosophy/?$"), "/sx/(etc.(philosophy))"), (re.compile(r"^/etc/plans/?$"), "/sx/(etc.(plan))"), (re.compile(r"^/etc/?$"), "/sx/(etc)"), ] def redirect_old_url(path: str) -> str | None: """Check if an old-style path should redirect to an SX URL. Returns the new SX URL if a redirect is needed, None otherwise. """ for pattern, replacement in _REDIRECT_PATTERNS: m = pattern.match(path) if m: if callable(replacement): return replacement(m) return replacement return None