Add sx documentation app (sx.rose-ash.com)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m42s

New public-facing service documenting the s-expression rendering engine.
Modelled on four.htmx.org with violet theme, all content rendered via sx.

Sections: docs, reference, protocols, examples (live demos), essays
(including "sx sucks"). No database — purely static documentation.

Port 8012, Redis DB 10. CI and deploy.sh updated with app_dir() mapping
for sx_docs -> sx/ directory. Caddy reverse proxy entry added separately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:25:52 +00:00
parent 815c5285d5
commit 03196c3ad0
20 changed files with 2001 additions and 6 deletions

View File

@@ -58,13 +58,22 @@ jobs:
fi
fi
for app in blog market cart events federation account relations likes orders test; do
# Map compose service name to source directory
app_dir() {
case \"\$1\" in
sx_docs) echo \"sx\" ;;
*) echo \"\$1\" ;;
esac
}
for app in blog market cart events federation account relations likes orders test sx_docs; do
dir=\$(app_dir \"\$app\")
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$dir/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
echo \"Building \$app...\"
docker build \
--build-arg CACHEBUST=\$(date +%s) \
-f \$app/Dockerfile \
-f \$dir/Dockerfile \
-t ${{ env.REGISTRY }}/\$app:latest \
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
.

View File

@@ -2,7 +2,7 @@
set -euo pipefail
REGISTRY="registry.rose-ash.com:5000"
APPS="blog market cart events federation account relations likes orders test"
APPS="blog market cart events federation account relations likes orders test sx_docs"
usage() {
echo "Usage: deploy.sh [app ...]"
@@ -15,6 +15,14 @@ usage() {
cd "$(dirname "$0")"
_app_dir() {
# Map compose service name to source directory when they differ
case "$1" in
sx_docs) echo "sx" ;;
*) echo "$1" ;;
esac
}
# Determine which apps to build
if [ $# -eq 0 ]; then
# Auto-detect: uncommitted changes + last commit
@@ -24,7 +32,8 @@ if [ $# -eq 0 ]; then
BUILD=($APPS)
else
for app in $APPS; do
if echo "$CHANGED" | grep -q "^$app/"; then
dir=$(_app_dir "$app")
if echo "$CHANGED" | grep -q "^$dir/"; then
BUILD+=("$app")
fi
done
@@ -56,8 +65,9 @@ echo "Unit tests passed."
echo ""
for app in "${BUILD[@]}"; do
dir=$(_app_dir "$app")
echo "=== $app ==="
docker build -f "$app/Dockerfile" -t "$REGISTRY/$app:latest" .
docker build -f "$dir/Dockerfile" -t "$REGISTRY/$app:latest" .
docker push "$REGISTRY/$app:latest"
docker service update --force "coop_$app" 2>/dev/null \
|| echo " (service coop_$app not running — will start on next stack deploy)"

View File

@@ -378,6 +378,43 @@ services:
- ./likes:/app/likes:ro
- ./orders:/app/orders:ro
sx_docs:
restart: unless-stopped
ports:
- "8012:8000"
environment:
<<: *dev-env
volumes:
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
- ./shared:/app/shared
- ./sx/app.py:/app/app.py
- ./sx/sxc:/app/sxc
- ./sx/bp:/app/bp
- ./sx/services:/app/services
- ./sx/content:/app/content
- ./sx/path_setup.py:/app/path_setup.py
- ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh
- ./sx/__init__.py:/app/__init__.py:ro
# sibling models
- ./blog/__init__.py:/app/blog/__init__.py:ro
- ./blog/models:/app/blog/models:ro
- ./market/__init__.py:/app/market/__init__.py:ro
- ./market/models:/app/market/models:ro
- ./cart/__init__.py:/app/cart/__init__.py:ro
- ./cart/models:/app/cart/models:ro
- ./events/__init__.py:/app/events/__init__.py:ro
- ./events/models:/app/events/models:ro
- ./federation/__init__.py:/app/federation/__init__.py:ro
- ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
test-unit:
build:
context: .

View File

@@ -35,6 +35,7 @@ x-app-env: &app-env
APP_URL_ORDERS: https://orders.rose-ash.com
APP_URL_RELATIONS: http://relations:8000
APP_URL_LIKES: http://likes:8000
APP_URL_SX: https://sx.rose-ash.com
APP_URL_TEST: https://test.rose-ash.com
APP_URL_ARTDAG: https://celery-artdag.rose-ash.com
APP_URL_ARTDAG_L2: https://artdag.rose-ash.com
@@ -47,6 +48,7 @@ x-app-env: &app-env
INTERNAL_URL_ORDERS: http://orders:8000
INTERNAL_URL_RELATIONS: http://relations:8000
INTERNAL_URL_LIKES: http://likes:8000
INTERNAL_URL_SX: http://sx_docs:8000
INTERNAL_URL_TEST: http://test:8000
INTERNAL_URL_ARTDAG: http://l1-server:8100
AP_DOMAIN: federation.rose-ash.com
@@ -214,6 +216,17 @@ services:
REDIS_URL: redis://redis:6379/9
WORKERS: "1"
sx_docs:
<<: *app-common
image: registry.rose-ash.com:5000/sx_docs:latest
build:
context: .
dockerfile: sx/Dockerfile
environment:
<<: *app-env
REDIS_URL: redis://redis:6379/10
WORKERS: "1"
db:
image: postgres:16
environment:

58
sx/Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code
COPY shared/ ./shared/
# App code
COPY sx/ ./sx-app-tmp/
RUN cp -r sx-app-tmp/app.py sx-app-tmp/path_setup.py \
sx-app-tmp/bp sx-app-tmp/sxc sx-app-tmp/services \
sx-app-tmp/content sx-app-tmp/__init__.py ./ 2>/dev/null || true && \
rm -rf sx-app-tmp
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
COPY sx/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

0
sx/__init__.py Normal file
View File

45
sx/app.py Normal file
View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import path_setup # noqa: F401
import sxc.sx_components as sx_components # noqa: F401
from shared.infrastructure.factory import create_base_app
from shared.sx.jinja_bridge import render
from bp import register_pages
from services import register_domain_services
async def sx_docs_context() -> dict:
"""SX docs app context processor — minimal, no cross-service fragments."""
from shared.infrastructure.context import base_context
ctx = await base_context()
ctx["menu_items"] = []
blog_url = ctx.get("blog_url", "")
if callable(blog_url):
blog_url_str = blog_url("")
else:
blog_url_str = str(blog_url or "")
ctx["cart_mini"] = render(
"cart-mini", cart_count=0, blog_url=blog_url_str, cart_url="",
)
ctx["auth_menu"] = ""
ctx["nav_tree"] = ""
return ctx
def create_app() -> "Quart":
app = create_base_app(
"sx",
context_fn=sx_docs_context,
domain_services_fn=register_domain_services,
)
import sxc.sx_components # noqa: F401
app.register_blueprint(register_pages(url_prefix="/"))
return app
app = create_app()

1
sx/bp/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .pages.routes import register as register_pages

0
sx/bp/pages/__init__.py Normal file
View File

217
sx/bp/pages/routes.py Normal file
View File

@@ -0,0 +1,217 @@
"""SX docs page routes."""
from __future__ import annotations
from datetime import datetime
from quart import Blueprint, Response, make_response, request
def register(url_prefix: str = "/") -> Blueprint:
bp = Blueprint("pages", __name__, url_prefix=url_prefix)
def _is_sx_request() -> bool:
return bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
# ------------------------------------------------------------------
# Home
# ------------------------------------------------------------------
@bp.get("/")
async def index():
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import home_content_sx
return sx_response(home_content_sx())
from shared.sx.page import get_template_context
from sxc.sx_components import render_home_page_sx
ctx = await get_template_context()
html = await render_home_page_sx(ctx)
return await make_response(html, 200)
# ------------------------------------------------------------------
# Docs
# ------------------------------------------------------------------
@bp.get("/docs/")
async def docs_index():
from quart import redirect
return redirect("/docs/introduction")
@bp.get("/docs/<slug>")
async def docs_page(slug: str):
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import docs_content_partial_sx
return sx_response(docs_content_partial_sx(slug))
from shared.sx.page import get_template_context
from sxc.sx_components import render_docs_page_sx
ctx = await get_template_context()
html = await render_docs_page_sx(ctx, slug)
return await make_response(html, 200)
# ------------------------------------------------------------------
# Reference
# ------------------------------------------------------------------
@bp.get("/reference/")
async def reference_index():
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import reference_content_partial_sx
return sx_response(reference_content_partial_sx(""))
from shared.sx.page import get_template_context
from sxc.sx_components import render_reference_page_sx
ctx = await get_template_context()
html = await render_reference_page_sx(ctx, "")
return await make_response(html, 200)
@bp.get("/reference/<slug>")
async def reference_page(slug: str):
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import reference_content_partial_sx
return sx_response(reference_content_partial_sx(slug))
from shared.sx.page import get_template_context
from sxc.sx_components import render_reference_page_sx
ctx = await get_template_context()
html = await render_reference_page_sx(ctx, slug)
return await make_response(html, 200)
# ------------------------------------------------------------------
# Protocols
# ------------------------------------------------------------------
@bp.get("/protocols/")
async def protocols_index():
from quart import redirect
return redirect("/protocols/wire-format")
@bp.get("/protocols/<slug>")
async def protocol_page(slug: str):
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import protocol_content_partial_sx
return sx_response(protocol_content_partial_sx(slug))
from shared.sx.page import get_template_context
from sxc.sx_components import render_protocol_page_sx
ctx = await get_template_context()
html = await render_protocol_page_sx(ctx, slug)
return await make_response(html, 200)
# ------------------------------------------------------------------
# Examples
# ------------------------------------------------------------------
@bp.get("/examples/")
async def examples_index():
from quart import redirect
return redirect("/examples/click-to-load")
@bp.get("/examples/<slug>")
async def examples_page(slug: str):
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import examples_content_partial_sx
return sx_response(examples_content_partial_sx(slug))
from shared.sx.page import get_template_context
from sxc.sx_components import render_examples_page_sx
ctx = await get_template_context()
html = await render_examples_page_sx(ctx, slug)
return await make_response(html, 200)
# ------------------------------------------------------------------
# Example API endpoints (for live demos)
# ------------------------------------------------------------------
@bp.get("/examples/api/click")
async def api_click():
from shared.sx.helpers import sx_response
return sx_response('(~click-result)')
@bp.post("/examples/api/form")
async def api_form():
from shared.sx.helpers import sx_response
form = await request.form
name = form.get("name", "")
escaped = name.replace('"', '\\"')
return sx_response(f'(~form-result :name "{escaped}")')
_poll_count = {"n": 0}
@bp.get("/examples/api/poll")
async def api_poll():
from shared.sx.helpers import sx_response
_poll_count["n"] += 1
now = datetime.now().strftime("%H:%M:%S")
count = min(_poll_count["n"], 10)
return sx_response(f'(~poll-result :time "{now}" :count {count})')
@bp.delete("/examples/api/delete/<item_id>")
async def api_delete(item_id: str):
# Return empty response — the row's outerHTML swap removes it
return Response("", status=200, content_type="text/sx")
@bp.get("/examples/api/edit")
async def api_edit_form():
from shared.sx.helpers import sx_response
value = request.args.get("value", "")
escaped = value.replace('"', '\\"')
return sx_response(f'(~inline-edit-form :value "{escaped}")')
@bp.post("/examples/api/edit")
async def api_edit_save():
from shared.sx.helpers import sx_response
form = await request.form
value = form.get("value", "")
escaped = value.replace('"', '\\"')
return sx_response(f'(~inline-view :value "{escaped}")')
@bp.get("/examples/api/edit/cancel")
async def api_edit_cancel():
from shared.sx.helpers import sx_response
value = request.args.get("value", "")
escaped = value.replace('"', '\\"')
return sx_response(f'(~inline-view :value "{escaped}")')
@bp.get("/examples/api/oob")
async def api_oob():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
return sx_response(
f'(<>'
f' (p :class "text-emerald-600 font-medium" "Box A updated!")'
f' (p :class "text-sm text-stone-500" "at {now}")'
f' (div :id "oob-box-b" :sx-swap-oob "innerHTML"'
f' (p :class "text-violet-600 font-medium" "Box B updated via OOB!")'
f' (p :class "text-sm text-stone-500" "at {now}")))'
)
# ------------------------------------------------------------------
# Essays
# ------------------------------------------------------------------
@bp.get("/essays/")
async def essays_index():
from quart import redirect
return redirect("/essays/sx-sucks")
@bp.get("/essays/<slug>")
async def essay_page(slug: str):
if _is_sx_request():
from shared.sx.helpers import sx_response
from sxc.sx_components import essay_content_partial_sx
return sx_response(essay_content_partial_sx(slug))
from shared.sx.page import get_template_context
from sxc.sx_components import render_essay_page_sx
ctx = await get_template_context()
html = await render_essay_page_sx(ctx, slug)
return await make_response(html, 200)
return bp

0
sx/content/__init__.py Normal file
View File

184
sx/content/pages.py Normal file
View File

@@ -0,0 +1,184 @@
"""Documentation content for the sx docs site.
All page content as Python data structures, consumed by sx_components.py
to build s-expression page trees.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Navigation
# ---------------------------------------------------------------------------
DOCS_NAV = [
("Introduction", "/docs/introduction"),
("Getting Started", "/docs/getting-started"),
("Components", "/docs/components"),
("Evaluator", "/docs/evaluator"),
("Primitives", "/docs/primitives"),
("CSS", "/docs/css"),
("Server Rendering", "/docs/server-rendering"),
]
REFERENCE_NAV = [
("Attributes", "/reference/"),
("Headers", "/reference/headers"),
("Events", "/reference/events"),
("JS API", "/reference/js-api"),
]
PROTOCOLS_NAV = [
("Wire Format", "/protocols/wire-format"),
("Fragments", "/protocols/fragments"),
("Resolver I/O", "/protocols/resolver-io"),
("Internal Services", "/protocols/internal-services"),
("ActivityPub", "/protocols/activitypub"),
("Future", "/protocols/future"),
]
EXAMPLES_NAV = [
("Click to Load", "/examples/click-to-load"),
("Form Submission", "/examples/form-submission"),
("Polling", "/examples/polling"),
("Delete Row", "/examples/delete-row"),
("Inline Edit", "/examples/inline-edit"),
("OOB Swaps", "/examples/oob-swaps"),
]
ESSAYS_NAV = [
("sx sucks", "/essays/sx-sucks"),
("Why S-Expressions", "/essays/why-sexps"),
("The htmx/React Hybrid", "/essays/htmx-react-hybrid"),
("On-Demand CSS", "/essays/on-demand-css"),
]
MAIN_NAV = [
("Docs", "/docs/introduction"),
("Reference", "/reference/"),
("Protocols", "/protocols/wire-format"),
("Examples", "/examples/click-to-load"),
("Essays", "/essays/sx-sucks"),
]
# ---------------------------------------------------------------------------
# Reference: Attributes
# ---------------------------------------------------------------------------
REQUEST_ATTRS = [
("sx-get", "Issue a GET request to the given URL", True),
("sx-post", "Issue a POST request to the given URL", True),
("sx-put", "Issue a PUT request to the given URL", True),
("sx-delete", "Issue a DELETE request to the given URL", True),
("sx-patch", "Issue a PATCH request to the given URL", True),
]
BEHAVIOR_ATTRS = [
("sx-trigger", "Specifies the event that triggers the request. Modifiers: once, changed, delay:<time>, from:<selector>, intersect, revealed, load, every:<time>", True),
("sx-target", "CSS selector for the target element to update", True),
("sx-swap", "How to swap the response: outerHTML, innerHTML, afterend, beforeend, afterbegin, beforebegin, delete, none", True),
("sx-swap-oob", "Out-of-band swap — update elements elsewhere in the DOM by ID", True),
("sx-select", "CSS selector to pick a fragment from the response", True),
("sx-confirm", "Shows a confirmation dialog before issuing the request", True),
("sx-push-url", "Push the request URL into the browser location bar", True),
("sx-sync", "Synchronization strategy for requests from this element", True),
("sx-encoding", "Set the encoding for the request (e.g. multipart/form-data)", True),
("sx-headers", "Add headers to the request as a JSON string", True),
("sx-include", "Include additional element values in the request", True),
("sx-vals", "Add values to the request as a JSON string", True),
("sx-media", "Only enable this element when the media query matches", True),
("sx-disable", "Disable sx processing on this element and its children", True),
]
SX_UNIQUE_ATTRS = [
("sx-retry", "Exponential backoff retry on request failure", True),
("data-sx", "Client-side rendering — evaluate the sx source in this attribute and render into the element", True),
("data-sx-env", "Provide environment variables as JSON for data-sx rendering", True),
]
HTMX_MISSING_ATTRS = [
("hx-boost", "Progressively enhance links and forms (not yet implemented)", False),
("hx-preload", "Preload content on hover/focus (not yet implemented)", False),
("hx-preserve", "Preserve element across swaps (not yet implemented)", False),
("hx-optimistic", "Optimistic UI updates (not yet implemented)", False),
("hx-indicator", "sx uses .sx-request CSS class instead — no dedicated attribute (not yet implemented)", False),
("hx-validate", "Custom validation (not yet implemented — sx has sx-disable)", False),
("hx-ignore", "Ignore element (not yet implemented — sx has sx-disable)", False),
]
# ---------------------------------------------------------------------------
# Reference: Headers
# ---------------------------------------------------------------------------
REQUEST_HEADERS = [
("SX-Request", "true", "Set on every sx-initiated request"),
("SX-Current-URL", "URL", "The current URL of the browser"),
("SX-Target", "CSS selector", "The target element for the response"),
("SX-Components", "~comp1,~comp2,...", "Component names the client already has cached"),
("SX-Css", "hash or class list", "CSS classes/hash the client already has"),
("SX-History-Restore", "true", "Set when restoring from browser history"),
("SX-Css-Hash", "8-char hash", "Hash of the client's known CSS class set"),
]
RESPONSE_HEADERS = [
("SX-Css-Hash", "8-char hash", "Hash of the cumulative CSS class set after this response"),
("SX-Css-Add", "class1,class2,...", "New CSS classes added by this response"),
]
# ---------------------------------------------------------------------------
# Reference: Events
# ---------------------------------------------------------------------------
EVENTS = [
("sx:beforeRequest", "Fired before an sx request is issued. Call preventDefault() to cancel."),
("sx:afterRequest", "Fired after a successful sx response is received."),
("sx:afterSwap", "Fired after the response has been swapped into the DOM."),
("sx:afterSettle", "Fired after the DOM has settled (scripts executed, etc)."),
("sx:responseError", "Fired on HTTP error responses (4xx, 5xx)."),
("sx:sendError", "Fired when the request fails to send (network error)."),
]
# ---------------------------------------------------------------------------
# Reference: JS API
# ---------------------------------------------------------------------------
JS_API = [
("Sx.parse(text)", "Parse a single s-expression from text"),
("Sx.parseAll(text)", "Parse multiple s-expressions from text"),
("Sx.eval(expr, env)", "Evaluate an expression in the given environment"),
("Sx.render(expr, env)", "Render an expression to DOM nodes"),
("Sx.renderToString(expr, env)", "Render an expression to an HTML string"),
("Sx.renderComponent(name, kwargs, env)", "Render a named component with keyword arguments"),
("Sx.loadComponents(text)", "Parse and register component definitions"),
("Sx.getEnv()", "Get the current component environment"),
("Sx.mount(target, expr, env)", "Mount an expression into a DOM element"),
("Sx.update(target, newEnv)", "Re-render an element with new environment data"),
("Sx.hydrate(root)", "Find and render all [data-sx] elements within root"),
("SxEngine.process(root)", "Process all sx attributes in the DOM subtree"),
("SxEngine.executeRequest(elt, verb, url)", "Programmatically trigger an sx request"),
]
# ---------------------------------------------------------------------------
# Primitives
# ---------------------------------------------------------------------------
PRIMITIVES = {
"Arithmetic": ["+", "-", "*", "/", "mod", "sqrt", "pow", "abs", "floor", "ceil", "round", "min", "max"],
"Comparison": ["=", "!=", "<", ">", "<=", ">="],
"Logic": ["not", "and", "or"],
"String": ["str", "upper", "lower", "trim", "split", "join", "starts-with?", "ends-with?", "replace", "substring"],
"Collections": ["list", "dict", "len", "first", "last", "rest", "nth", "cons", "append", "keys", "vals", "merge", "assoc", "range", "concat", "reverse", "sort", "flatten", "zip"],
"Higher-Order": ["map", "map-indexed", "filter", "reduce", "some", "every?", "for-each"],
"Predicates": ["nil?", "number?", "string?", "list?", "dict?", "empty?", "contains?", "odd?", "even?", "zero?"],
"Type Conversion": ["int", "float", "number"],
}
# ---------------------------------------------------------------------------
# Example items for delete demo
# ---------------------------------------------------------------------------
DELETE_DEMO_ITEMS = [
("1", "Implement dark mode"),
("2", "Fix login bug"),
("3", "Write documentation"),
("4", "Deploy to production"),
("5", "Add unit tests"),
]

25
sx/entrypoint.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
# No database — skip DB wait and migrations
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushdb()
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
python3 -m shared.dev_watcher &
fi
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" \
--bind 0.0.0.0:${PORT:-8000} \
--workers ${WORKERS:-1} \
--keep-alive 75 \
${RELOAD_FLAG}

9
sx/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

6
sx/services/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""SX docs app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the sx docs app (none needed)."""

0
sx/sxc/__init__.py Normal file
View File

70
sx/sxc/docs.sx Normal file
View File

@@ -0,0 +1,70 @@
;; SX docs — documentation page components
(defcomp ~doc-page (&key title &rest children)
(div :class "max-w-4xl mx-auto px-6 py-8"
(h1 :class "text-4xl font-bold text-stone-900 mb-8" title)
(div :class "prose prose-stone max-w-none space-y-6" children)))
(defcomp ~doc-section (&key title id &rest children)
(section :id id :class "space-y-4"
(h2 :class "text-2xl font-semibold text-stone-800" title)
children))
(defcomp ~doc-subsection (&key title &rest children)
(div :class "space-y-3"
(h3 :class "text-xl font-semibold text-stone-700" title)
children))
(defcomp ~doc-code (&key language code)
(div :class "bg-stone-900 rounded-lg p-4 overflow-x-auto"
(pre :class "text-sm"
(code :class (str "language-" (or language "lisp")) code))))
(defcomp ~doc-note (&key &rest children)
(div :class "border-l-4 border-violet-400 bg-violet-50 p-4 text-stone-700 text-sm"
children))
(defcomp ~doc-table (&key headers rows)
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead
(tr :class "border-b border-stone-200 bg-stone-50"
(map (fn (h) (th :class "px-3 py-2 font-medium text-stone-600" h)) headers)))
(tbody
(map (fn (row)
(tr :class "border-b border-stone-100"
(map (fn (cell) (td :class "px-3 py-2 text-stone-700" cell)) row)))
rows)))))
(defcomp ~doc-attr-row (&key attr description exists)
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" attr)
(td :class "px-3 py-2 text-stone-700 text-sm" description)
(td :class "px-3 py-2 text-center"
(if exists
(span :class "text-emerald-600 text-sm" "yes")
(span :class "text-stone-400 text-sm italic" "not yet")))))
(defcomp ~doc-primitives-table (&key category primitives)
(div :class "space-y-2"
(h4 :class "text-lg font-semibold text-stone-700" category)
(div :class "flex flex-wrap gap-2"
(map (fn (p)
(span :class "inline-block px-2 py-1 rounded bg-stone-100 font-mono text-sm text-stone-700" p))
primitives))))
(defcomp ~doc-nav (&key items current)
(nav :class "flex flex-wrap gap-2 mb-8"
(map (fn (item)
(a :href (nth 1 item)
:sx-get (nth 1 item)
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class (str "px-3 py-1.5 rounded text-sm font-medium no-underline "
(if (= (nth 0 item) current)
"bg-violet-100 text-violet-800"
"bg-stone-100 text-stone-600 hover:bg-stone-200"))
(nth 0 item)))
items)))

159
sx/sxc/examples.sx Normal file
View File

@@ -0,0 +1,159 @@
;; SX docs — example and demo components
(defcomp ~example-card (&key title description &rest children)
(div :class "border border-stone-200 rounded-lg overflow-hidden"
(div :class "bg-stone-50 px-4 py-3 border-b border-stone-200"
(h3 :class "font-semibold text-stone-800" title)
(when description
(p :class "text-sm text-stone-500 mt-1" description)))
(div :class "p-4" children)))
(defcomp ~example-demo (&key &rest children)
(div :class "border border-dashed border-stone-300 rounded p-4 bg-white" children))
(defcomp ~example-source (&key code)
(div :class "bg-stone-900 rounded p-4 mt-3 overflow-x-auto"
(pre :class "text-sm"
(code :class "language-lisp" code))))
;; --- Click to load demo ---
(defcomp ~click-to-load-demo ()
(div :class "space-y-4"
(div :id "click-result" :class "p-4 rounded bg-stone-50 text-stone-500 text-center"
"Click the button to load content.")
(button
:sx-get "/examples/api/click"
:sx-target "#click-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors"
"Load content")))
(defcomp ~click-result ()
(div :class "space-y-2"
(p :class "text-stone-800 font-medium" "Content loaded!")
(p :class "text-stone-500 text-sm" "This was fetched from the server via sx-get and swapped into the target div.")))
;; --- Form submission demo ---
(defcomp ~form-demo ()
(div :class "space-y-4"
(form
:sx-post "/examples/api/form"
:sx-target "#form-result"
:sx-swap "innerHTML"
:class "space-y-3"
(div
(label :class "block text-sm font-medium text-stone-700 mb-1" "Name")
(input :type "text" :name "name" :placeholder "Enter a name"
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"))
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "form-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm text-center"
"Submit the form to see the result.")))
(defcomp ~form-result (&key name)
(div :class "text-stone-800"
(p (str "Hello, " (if (empty? name) "stranger" name) "!"))
(p :class "text-sm text-stone-500 mt-1" "Submitted via sx-post. The form data was sent as a POST request.")))
;; --- Polling demo ---
(defcomp ~polling-demo ()
(div :class "space-y-4"
(div :id "poll-target"
:sx-get "/examples/api/poll"
:sx-trigger "load, every 2s"
:sx-swap "innerHTML"
:class "p-4 rounded border border-stone-200 bg-white text-center font-mono"
"Loading...")))
(defcomp ~poll-result (&key time count)
(div
(p :class "text-stone-800 font-medium" (str "Server time: " time))
(p :class "text-stone-500 text-sm mt-1" (str "Poll count: " count))
(div :class "mt-2 flex justify-center"
(div :class "flex gap-1"
(map (fn (i)
(div :class (str "w-2 h-2 rounded-full "
(if (<= i count) "bg-violet-500" "bg-stone-200"))))
(list 1 2 3 4 5 6 7 8 9 10))))))
;; --- Delete row demo ---
(defcomp ~delete-demo (&key items)
(div
(table :class "w-full text-left text-sm"
(thead
(tr :class "border-b border-stone-200"
(th :class "px-3 py-2 font-medium text-stone-600" "Item")
(th :class "px-3 py-2 font-medium text-stone-600 w-20" "")))
(tbody :id "delete-rows"
(map (fn (item)
(~delete-row :id (nth 0 item) :name (nth 1 item)))
items)))))
(defcomp ~delete-row (&key id name)
(tr :id (str "row-" id) :class "border-b border-stone-100 transition-all"
(td :class "px-3 py-2 text-stone-700" name)
(td :class "px-3 py-2"
(button
:sx-delete (str "/examples/api/delete/" id)
:sx-target (str "#row-" id)
:sx-swap "outerHTML"
:sx-confirm "Delete this item?"
:class "text-rose-500 hover:text-rose-700 text-sm"
"delete"))))
;; --- Inline edit demo ---
(defcomp ~inline-edit-demo ()
(div :id "edit-target" :class "space-y-3"
(~inline-view :value "Click edit to change this text")))
(defcomp ~inline-view (&key value)
(div :class "flex items-center justify-between p-3 rounded border border-stone-200"
(span :class "text-stone-800" value)
(button
:sx-get (str "/examples/api/edit?value=" value)
:sx-target "#edit-target"
:sx-swap "innerHTML"
:class "text-sm text-violet-600 hover:text-violet-800"
"edit")))
(defcomp ~inline-edit-form (&key value)
(form
:sx-post "/examples/api/edit"
:sx-target "#edit-target"
:sx-swap "innerHTML"
:class "flex items-center gap-2 p-3 rounded border border-violet-300 bg-violet-50"
(input :type "text" :name "value" :value value
:class "flex-1 px-3 py-1.5 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(button :type "submit"
:class "px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"save")
(button :type "button"
:sx-get (str "/examples/api/edit/cancel?value=" value)
:sx-target "#edit-target"
:sx-swap "innerHTML"
:class "px-3 py-1.5 bg-stone-200 text-stone-700 rounded text-sm hover:bg-stone-300"
"cancel")))
;; --- OOB swap demo ---
(defcomp ~oob-demo ()
(div :class "space-y-4"
(div :class "grid grid-cols-2 gap-4"
(div :id "oob-box-a" :class "p-4 rounded border border-stone-200 bg-white text-center"
(p :class "text-stone-500" "Box A")
(p :class "text-sm text-stone-400" "Waiting..."))
(div :id "oob-box-b" :class "p-4 rounded border border-stone-200 bg-white text-center"
(p :class "text-stone-500" "Box B")
(p :class "text-sm text-stone-400" "Waiting...")))
(button
:sx-get "/examples/api/oob"
:sx-target "#oob-box-a"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Update both boxes")))

66
sx/sxc/home.sx Normal file
View File

@@ -0,0 +1,66 @@
;; SX docs — home page components
(defcomp ~sx-hero ()
(div :class "max-w-4xl mx-auto px-6 py-16 text-center"
(h1 :class "text-5xl font-bold text-stone-900 mb-4"
(span :class "text-violet-600" "sx"))
(p :class "text-2xl text-stone-600 mb-8"
"High power tools for HTML — with s-expressions")
(p :class "text-lg text-stone-500 max-w-2xl mx-auto mb-12"
"A hypermedia-driven UI engine that combines htmx's server-first philosophy "
"with React's component model. All rendered via s-expressions over the wire.")
(div :class "bg-stone-900 rounded-lg p-6 text-left font-mono text-sm overflow-x-auto"
(pre :class "text-stone-300"
(code :class "language-lisp"
"(defcomp ~greeting (&key name)\n (div :class \"p-4 rounded bg-violet-50\"\n (h2 :class \"text-lg font-bold\"\n (str \"Hello, \" name \"!\"))\n (button\n :sx-get \"/api/greet\"\n :sx-target \"closest div\"\n :sx-swap \"outerHTML\"\n :class \"mt-2 px-4 py-2 bg-violet-600 text-white rounded\"\n \"Refresh\")))")))))
(defcomp ~sx-philosophy ()
(div :class "max-w-4xl mx-auto px-6 py-12"
(h2 :class "text-3xl font-bold text-stone-900 mb-8" "Design philosophy")
(div :class "grid md:grid-cols-2 gap-8"
(div :class "space-y-4"
(h3 :class "text-xl font-semibold text-violet-700" "From htmx")
(ul :class "space-y-2 text-stone-600"
(li "Server-rendered HTML over the wire")
(li "Hypermedia attributes on any element (sx-get, sx-post, ...)")
(li "Target/swap model for partial page updates")
(li "No client-side routing, no virtual DOM")
(li "Progressive enhancement — works without JS (mostly)")))
(div :class "space-y-4"
(h3 :class "text-xl font-semibold text-violet-700" "From React")
(ul :class "space-y-2 text-stone-600"
(li "Composable components with defcomp")
(li "Client-side rendering from s-expression source")
(li "Component caching via localStorage + hash invalidation")
(li "On-demand CSS — only ship what's used")
(li "DOM morphing for smooth history navigation"))))))
(defcomp ~sx-how-it-works ()
(div :class "max-w-4xl mx-auto px-6 py-12"
(h2 :class "text-3xl font-bold text-stone-900 mb-8" "How it works")
(div :class "space-y-6"
(div :class "flex items-start gap-4"
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "1")
(div
(h3 :class "font-semibold text-stone-900" "Server renders sx")
(p :class "text-stone-600" "Python builds s-expression trees. Components, HTML elements, data — all in one format.")))
(div :class "flex items-start gap-4"
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "2")
(div
(h3 :class "font-semibold text-stone-900" "Wire sends text/sx")
(p :class "text-stone-600" "Responses are s-expression source code with content type text/sx. Component definitions cached client-side.")))
(div :class "flex items-start gap-4"
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 text-violet-700 flex items-center justify-center font-bold" "3")
(div
(h3 :class "font-semibold text-stone-900" "Client evaluates + renders")
(p :class "text-stone-600" "sx.js parses, evaluates, and renders to DOM. Same evaluator runs server-side (Python) and client-side (JS)."))))))
(defcomp ~sx-credits ()
(div :class "max-w-4xl mx-auto px-6 py-12 border-t border-stone-200"
(p :class "text-stone-500 text-sm"
"sx is heavily inspired by "
(a :href "https://htmx.org" :class "text-violet-600 hover:underline" "htmx")
" by Carson Gross. This documentation site is modelled on "
(a :href "https://four.htmx.org" :class "text-violet-600 hover:underline" "four.htmx.org")
". htmx showed that HTML is the right hypermedia format. "
"sx takes that idea and wraps it in parentheses.")))

1086
sx/sxc/sx_components.py Normal file

File diff suppressed because it is too large Load Diff