All routes moved under /sx/ prefix: - / redirects to /sx/ - /sx/ serves home page - /sx/<path:expr> is the catch-all for SX expression URLs - Bare /(...) and /~... redirect to /sx/(...) and /sx/~... - All ~600 hrefs, sx-get attrs, defhandler paths, redirect targets, and blueprint routes updated across 44 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
406 lines
17 KiB
Python
406 lines
17 KiB
Python
"""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 "..." <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
|
|
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/<id>
|
|
(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/<name>
|
|
(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
|