From 03196c3ad0c6c2361f2ea0b205dd926a48acba40 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 2 Mar 2026 21:25:52 +0000 Subject: [PATCH] Add sx documentation app (sx.rose-ash.com) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/ci.yml | 15 +- deploy.sh | 16 +- docker-compose.dev.yml | 37 ++ docker-compose.yml | 13 + sx/Dockerfile | 58 +++ sx/__init__.py | 0 sx/app.py | 45 ++ sx/bp/__init__.py | 1 + sx/bp/pages/__init__.py | 0 sx/bp/pages/routes.py | 217 ++++++++ sx/content/__init__.py | 0 sx/content/pages.py | 184 +++++++ sx/entrypoint.sh | 25 + sx/path_setup.py | 9 + sx/services/__init__.py | 6 + sx/sxc/__init__.py | 0 sx/sxc/docs.sx | 70 +++ sx/sxc/examples.sx | 159 ++++++ sx/sxc/home.sx | 66 +++ sx/sxc/sx_components.py | 1086 +++++++++++++++++++++++++++++++++++++++ 20 files changed, 2001 insertions(+), 6 deletions(-) create mode 100644 sx/Dockerfile create mode 100644 sx/__init__.py create mode 100644 sx/app.py create mode 100644 sx/bp/__init__.py create mode 100644 sx/bp/pages/__init__.py create mode 100644 sx/bp/pages/routes.py create mode 100644 sx/content/__init__.py create mode 100644 sx/content/pages.py create mode 100755 sx/entrypoint.sh create mode 100644 sx/path_setup.py create mode 100644 sx/services/__init__.py create mode 100644 sx/sxc/__init__.py create mode 100644 sx/sxc/docs.sx create mode 100644 sx/sxc/examples.sx create mode 100644 sx/sxc/home.sx create mode 100644 sx/sxc/sx_components.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 356331a..4d38535 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 }} \ . diff --git a/deploy.sh b/deploy.sh index 20c67b3..495d36e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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)" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 935f918..187b8ef 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: . diff --git a/docker-compose.yml b/docker-compose.yml index c0f2cdd..b0fa048 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/sx/Dockerfile b/sx/Dockerfile new file mode 100644 index 0000000..166a76d --- /dev/null +++ b/sx/Dockerfile @@ -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"] diff --git a/sx/__init__.py b/sx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sx/app.py b/sx/app.py new file mode 100644 index 0000000..5627281 --- /dev/null +++ b/sx/app.py @@ -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() diff --git a/sx/bp/__init__.py b/sx/bp/__init__.py new file mode 100644 index 0000000..e8a9549 --- /dev/null +++ b/sx/bp/__init__.py @@ -0,0 +1 @@ +from .pages.routes import register as register_pages diff --git a/sx/bp/pages/__init__.py b/sx/bp/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py new file mode 100644 index 0000000..ebbaab1 --- /dev/null +++ b/sx/bp/pages/routes.py @@ -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/") + 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/") + 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/") + 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/") + 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/") + 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/") + 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 diff --git a/sx/content/__init__.py b/sx/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sx/content/pages.py b/sx/content/pages.py new file mode 100644 index 0000000..1577680 --- /dev/null +++ b/sx/content/pages.py @@ -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: