Spec URL evaluation in router.sx, bootstrap to Python/JS
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "..." <ast>) 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,
|
||||
|
||||
Reference in New Issue
Block a user