"""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 (~sx-doc :path "..." content) 6. Returning full page or OOB response 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") # --------------------------------------------------------------------------- # 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_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. """ 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 # Parse URL expr = _parse_url_path(raw_path) if expr is None: return None # not an SX URL — let other handlers try # Check for streaming page BEFORE auto-quoting 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 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: 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) 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 (~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("~") ) if is_component_call: # Direct component URL: /(~essay-sx-sucks) or /(~comp :key val) # Pass straight to _eval_slot — aser handles component expansion. page_ast = quoted_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 (~sx-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 else: from shared.sx.async_eval import async_eval try: page_ast = await async_eval(quoted_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 wrapped_ast = [ Symbol("~sx-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/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/?$"), "/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