Compare commits
92 Commits
1a74d811f7
...
relations
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7a4a5128 | |||
| 8e4c2c139e | |||
| db3f48ec75 | |||
| b40f3d124c | |||
| 3809affcab | |||
| 81e51ae7bc | |||
| b6119b7f04 | |||
| 75cb5d43b9 | |||
| f628b35fc3 | |||
| 2e4fbd5777 | |||
| b47ad6224b | |||
| 2d08d6f787 | |||
| beebe559cd | |||
| b63aa72efb | |||
| 8cfa12de6b | |||
| 3dd62bd9bf | |||
| c926e5221d | |||
| d62643312a | |||
| 8852ab1108 | |||
| 1559c5c931 | |||
| 00efbc2a35 | |||
| 6c44a5f3d0 | |||
| 6d43404b12 | |||
| 97c4e25ba7 | |||
| f1b7fdd37d | |||
| 597b0d7a2f | |||
| ee41e30d5b | |||
| 5957bd8941 | |||
| a8edc26a1d | |||
| 6a331e4ad8 | |||
| 4a99bc56e9 | |||
| 4fe5afe3e6 | |||
| efae7f5533 | |||
| 105f4c4679 | |||
| a7cca2f720 | |||
| 8269977751 | |||
| 0df932bd94 | |||
| c220fe21d6 | |||
| f9d9697c67 | |||
| f4c2f4b6b8 | |||
| 881ed2cdcc | |||
| 2ce2077d14 | |||
| 8cf834dd55 | |||
| 4daecabf30 | |||
| 19240c6ca3 | |||
| 3e29c2a334 | |||
| a70d3648ec | |||
| 0d1ce92e52 | |||
| 09b5a5b4f6 | |||
| f0a100fd77 | |||
| 16da08ff05 | |||
| 5c6d83f474 | |||
| da8a766e3f | |||
| 9fa3b8800c | |||
| f24292f99d | |||
| de3a6e4dde | |||
| 0bb57136d2 | |||
| 495e6589dc | |||
| 903193d825 | |||
| eda95ec58b | |||
| d2f1da4944 | |||
| 53c4a0a1e0 | |||
| 9c6170ed31 | |||
| a0a0f5ebc2 | |||
| 6f1d5bac3c | |||
| b52ef719bf | |||
| 838ec982eb | |||
| e65232761b | |||
| 1c794b6c0e | |||
| d53b9648a9 | |||
| 8013317b41 | |||
| 04419a1ec6 | |||
| 573aec7dfa | |||
| 36b5f1d19d | |||
| 28c66c3650 | |||
| 5d9f1586af | |||
| fbb7a1422c | |||
| 09010db70e | |||
| 0fb87e3b1c | |||
| 996ddad2ea | |||
| f486e02413 | |||
| 69a0989b7a | |||
| 0c4682e4d7 | |||
| bcac8e5adc | |||
| e1b47e5b62 | |||
| ae134907a4 | |||
| db7342c7d2 | |||
| 94b1fca938 | |||
| 96b02d93df | |||
| fe34ea8e5b | |||
| f2d040c323 | |||
| 22460db450 |
@@ -2,7 +2,7 @@ name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, decoupling]
|
||||
branches: ['**']
|
||||
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
for app in blog market cart events federation account; do
|
||||
for app in blog market cart events federation account relations likes orders; do
|
||||
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
|
||||
echo \"Building \$app...\"
|
||||
@@ -75,13 +75,18 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
source .env
|
||||
docker stack deploy -c docker-compose.yml rose-ash
|
||||
echo 'Waiting for swarm services to update...'
|
||||
sleep 10
|
||||
docker stack services rose-ash
|
||||
# Deploy swarm stack only on main branch
|
||||
if [ '${{ github.ref_name }}' = 'main' ]; then
|
||||
source .env
|
||||
docker stack deploy -c docker-compose.yml rose-ash
|
||||
echo 'Waiting for swarm services to update...'
|
||||
sleep 10
|
||||
docker stack services rose-ash
|
||||
else
|
||||
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
|
||||
fi
|
||||
|
||||
# Deploy dev stack (bind-mounted source + auto-reload)
|
||||
# Dev stack always deployed (bind-mounted source + auto-reload)
|
||||
echo 'Deploying dev stack...'
|
||||
docker compose -p rose-ash-dev -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
echo 'Dev stack deployed'
|
||||
|
||||
151
CLAUDE.md
151
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# Art DAG Monorepo
|
||||
# Rose Ash Monorepo
|
||||
|
||||
Federated content-addressed DAG execution engine for distributed media processing with ActivityPub ownership and provenance tracking.
|
||||
Cooperative web platform: federated content, commerce, events, and media processing. Each domain runs as an independent Quart microservice with its own database, communicating via HMAC-signed internal HTTP and ActivityPub events.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -9,71 +9,134 @@ Federated content-addressed DAG execution engine for distributed media processin
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
core/ # DAG engine (artdag package) - nodes, effects, analysis, planning
|
||||
l1/ # L1 Celery rendering server (FastAPI + Celery + Redis + PostgreSQL)
|
||||
l2/ # L2 ActivityPub registry (FastAPI + PostgreSQL)
|
||||
common/ # Shared templates, middleware, models (artdag_common package)
|
||||
client/ # CLI client
|
||||
test/ # Integration & e2e tests
|
||||
blog/ # Content management, Ghost CMS sync, navigation, WYSIWYG editor
|
||||
market/ # Product catalog, marketplace pages, web scraping
|
||||
cart/ # Shopping cart CRUD, checkout (delegates order creation to orders)
|
||||
events/ # Calendar & event management, ticketing
|
||||
federation/ # ActivityPub social hub, user profiles
|
||||
account/ # OAuth2 authorization server, user dashboard, membership
|
||||
orders/ # Order history, SumUp payment/webhook handling, reconciliation
|
||||
relations/ # (internal) Cross-domain parent/child relationship tracking
|
||||
likes/ # (internal) Unified like/favourite tracking across domains
|
||||
shared/ # Shared library: models, infrastructure, templates, static assets
|
||||
artdag/ # Art DAG — media processing engine (separate codebase, see below)
|
||||
```
|
||||
|
||||
### Shared Library (`shared/`)
|
||||
|
||||
```
|
||||
shared/
|
||||
models/ # Canonical SQLAlchemy ORM models for all domains
|
||||
db/ # Async session management, per-domain DB support, alembic helpers
|
||||
infrastructure/ # App factory, OAuth, ActivityPub, fragments, internal auth, Jinja
|
||||
services/ # Domain service implementations + DI registry
|
||||
contracts/ # DTOs and service protocols
|
||||
browser/ # Middleware, Redis caching, CSRF, error handlers
|
||||
events/ # Activity bus + background processor (AP-shaped events)
|
||||
config/ # YAML config loading (frozen/readonly)
|
||||
static/ # Shared CSS, JS, images
|
||||
templates/ # Base HTML layouts, partials (inherited by all apps)
|
||||
```
|
||||
|
||||
### Art DAG (`artdag/`)
|
||||
|
||||
Federated content-addressed DAG execution engine for distributed media processing.
|
||||
|
||||
```
|
||||
artdag/
|
||||
core/ # DAG engine (artdag package) — nodes, effects, analysis, planning
|
||||
l1/ # L1 Celery rendering server (FastAPI + Celery + Redis + PostgreSQL)
|
||||
l2/ # L2 ActivityPub registry (FastAPI + PostgreSQL)
|
||||
common/ # Shared templates, middleware, models (artdag_common package)
|
||||
client/ # CLI client
|
||||
test/ # Integration & e2e tests
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Python 3.11+, FastAPI, Celery, Redis, PostgreSQL (asyncpg for L1), SQLAlchemy, Pydantic, JAX (CPU/GPU), IPFS/Kubo, Docker Swarm, HTMX + Jinja2 for web UI.
|
||||
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
|
||||
|
||||
**Art DAG:** FastAPI, Celery, JAX (CPU/GPU), IPFS/Kubo, Pydantic.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Testing
|
||||
### Development
|
||||
```bash
|
||||
cd l1 && pytest tests/ # L1 unit tests
|
||||
cd core && pytest tests/ # Core unit tests
|
||||
cd test && python run.py # Full integration pipeline
|
||||
./dev.sh # Start all services + infra (db, redis, pgbouncer)
|
||||
./dev.sh blog market # Start specific services + infra
|
||||
./dev.sh --build blog # Rebuild image then start
|
||||
./dev.sh down # Stop everything
|
||||
./dev.sh logs blog # Tail service logs
|
||||
```
|
||||
- pytest uses `asyncio_mode = "auto"` for async tests
|
||||
- Test files: `test_*.py`, fixtures in `conftest.py`
|
||||
|
||||
### Linting & Type Checking (L1)
|
||||
### Deployment
|
||||
```bash
|
||||
cd l1 && ruff check . # Lint (E, F, I, UP rules)
|
||||
cd l1 && mypy app/types.py app/routers/recipes.py tests/
|
||||
./deploy.sh # Auto-detect changed apps, build + push + restart
|
||||
./deploy.sh blog market # Deploy specific apps
|
||||
./deploy.sh --all # Deploy everything
|
||||
```
|
||||
- Line length: 100 chars (E501 ignored)
|
||||
- Mypy: strict on `app/types.py`, `app/routers/recipes.py`, `tests/`; gradual elsewhere
|
||||
- Mypy ignores imports for: celery, redis, artdag, artdag_common, ipfs_client
|
||||
|
||||
### Docker
|
||||
### Art DAG
|
||||
```bash
|
||||
docker build -f l1/Dockerfile -t celery-l1-server:latest .
|
||||
docker build -f l1/Dockerfile.gpu -t celery-l1-gpu:latest .
|
||||
docker build -f l2/Dockerfile -t l2-server:latest .
|
||||
./deploy.sh # Build, push, deploy stacks
|
||||
cd artdag/l1 && pytest tests/ # L1 unit tests
|
||||
cd artdag/core && pytest tests/ # Core unit tests
|
||||
cd artdag/test && python run.py # Full integration pipeline
|
||||
cd artdag/l1 && ruff check . # Lint
|
||||
cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
- **3-Phase Execution**: Analyze -> Plan -> Execute (tasks in `l1/tasks/`)
|
||||
- **Content-Addressed**: All data identified by SHA3-256 hashes or IPFS CIDs
|
||||
- **Services Pattern**: Business logic in `app/services/`, API endpoints in `app/routers/`
|
||||
- **Types Module**: Pydantic models and TypedDicts in `app/types.py`
|
||||
- **Celery Tasks**: In `l1/tasks/`, decorated with `@app.task`
|
||||
- **S-Expression Effects**: Composable effect language in `l1/sexp_effects/`
|
||||
- **Storage**: Local filesystem, S3, or IPFS backends (`storage_providers.py`)
|
||||
- **Inter-Service Reads**: `fetch_data()` → GET `/internal/data/{query}` (HMAC-signed)
|
||||
- **Inter-Service Actions**: `call_action()` → POST `/internal/actions/{name}` (HMAC-signed)
|
||||
- **Inter-Service AP Inbox**: `send_internal_activity()` → POST `/internal/inbox` (HMAC-signed, AP-shaped activities for cross-service writes with denormalized data)
|
||||
### Web Platform
|
||||
|
||||
## Auth
|
||||
- **App factory:** `create_base_app(name, context_fn, before_request_fns, domain_services_fn)` in `shared/infrastructure/factory.py` — creates Quart app with DB, Redis, CSRF, OAuth, AP, session management
|
||||
- **Blueprint pattern:** Each blueprint exposes `register() -> Blueprint`, handlers stored in `_handlers` dict
|
||||
- **Per-service database:** Each service has own PostgreSQL DB via PgBouncer; cross-domain data fetched via HTTP
|
||||
- **Alembic per-service:** Each service declares `MODELS` and `TABLES` in `alembic/env.py`, delegates to `shared.db.alembic_env.run_alembic()`
|
||||
- **Inter-service reads:** `fetch_data(service, query, params)` → GET `/internal/data/{query}` (HMAC-signed, 3s timeout)
|
||||
- **Inter-service writes:** `call_action(service, action, payload)` → POST `/internal/actions/{action}` (HMAC-signed, 5s timeout)
|
||||
- **Inter-service AP inbox:** `send_internal_activity()` → POST `/internal/inbox` (HMAC-signed, AP-shaped activities for cross-service writes)
|
||||
- **Fragments:** HTML fragments fetched cross-service via `fetch_fragments()` for composing shared UI (nav, cart mini, auth menu)
|
||||
- **Soft deletes:** Models use `deleted_at` column pattern
|
||||
- **Context processors:** Each app provides its own `context_fn` that assembles template context from local DB + cross-service fragments
|
||||
|
||||
- L1 <-> L2: scoped JWT tokens (no shared secrets)
|
||||
- L2: password + OAuth SSO, token revocation in Redis (30-day expiry)
|
||||
- Federation: ActivityPub RSA signatures (`core/artdag/activitypub/`)
|
||||
### Auth
|
||||
|
||||
- **Account** is the OAuth2 authorization server; all other apps are OAuth clients
|
||||
- Per-app first-party session cookies (Safari ITP compatible), synchronized via device ID
|
||||
- Grant verification: apps check grant validity against account DB (cached in Redis)
|
||||
- Silent SSO: `prompt=none` OAuth flow for automatic cross-app login
|
||||
- ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair
|
||||
|
||||
### Art DAG
|
||||
|
||||
- **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`)
|
||||
- **Content-Addressed:** All data identified by SHA3-256 hashes or IPFS CIDs
|
||||
- **S-Expression Effects:** Composable effect language in `artdag/l1/sexp_effects/`
|
||||
- **Storage:** Local filesystem, S3, or IPFS backends
|
||||
- L1 ↔ L2: scoped JWT tokens; L2: password + OAuth SSO
|
||||
|
||||
## Domains
|
||||
|
||||
| Service | Public URL | Dev Port |
|
||||
|---------|-----------|----------|
|
||||
| blog | blog.rose-ash.com | 8001 |
|
||||
| market | market.rose-ash.com | 8002 |
|
||||
| cart | cart.rose-ash.com | 8003 |
|
||||
| events | events.rose-ash.com | 8004 |
|
||||
| federation | federation.rose-ash.com | 8005 |
|
||||
| account | account.rose-ash.com | 8006 |
|
||||
| relations | (internal only) | 8008 |
|
||||
| likes | (internal only) | 8009 |
|
||||
| orders | orders.rose-ash.com | 8010 |
|
||||
|
||||
## Key Config Files
|
||||
|
||||
- `l1/pyproject.toml` - mypy, pytest, ruff config for L1
|
||||
- `l1/celery_app.py` - Celery initialization
|
||||
- `l1/database.py` / `l2/db.py` - SQLAlchemy models
|
||||
- `l1/docker-compose.yml` / `l2/docker-compose.yml` - Swarm stacks
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes
|
||||
- `deploy.sh` / `dev.sh` — deployment and development scripts
|
||||
- `shared/infrastructure/factory.py` — app factory (all services use this)
|
||||
- `{service}/alembic/env.py` — per-service migration config
|
||||
- `_config/app-config.yaml` — runtime YAML config (mounted into containers)
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
render_template,
|
||||
make_response,
|
||||
redirect,
|
||||
g,
|
||||
@@ -47,14 +46,17 @@ def register(url_prefix="/"):
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_account_page, render_account_oob
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/"))
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/auth/index.html")
|
||||
html = await render_account_page(ctx)
|
||||
else:
|
||||
html = await render_template("_types/auth/_oob_elements.html")
|
||||
html = await render_account_oob(ctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -86,20 +88,14 @@ def register(url_prefix="/"):
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_newsletters_page, render_newsletters_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/auth/index.html",
|
||||
oob=nl_oob,
|
||||
newsletter_list=newsletter_list,
|
||||
)
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/auth/_oob_elements.html",
|
||||
oob=nl_oob,
|
||||
newsletter_list=newsletter_list,
|
||||
)
|
||||
html = await render_newsletters_oob(ctx, newsletter_list)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -128,10 +124,8 @@ def register(url_prefix="/"):
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return await render_template(
|
||||
"_types/auth/_newsletter_toggle.html",
|
||||
un=un,
|
||||
)
|
||||
from sexp.sexp_components import render_newsletter_toggle
|
||||
return render_newsletter_toggle(un)
|
||||
|
||||
# Catch-all for fragment-provided pages — must be last
|
||||
@account_bp.get("/<slug>/")
|
||||
@@ -149,20 +143,14 @@ def register(url_prefix="/"):
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
|
||||
w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_fragment_page, render_fragment_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/auth/index.html",
|
||||
oob=w_oob,
|
||||
page_fragment_html=fragment_html,
|
||||
)
|
||||
html = await render_fragment_page(ctx, fragment_html)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/auth/_oob_elements.html",
|
||||
oob=w_oob,
|
||||
page_fragment_html=fragment_html,
|
||||
)
|
||||
html = await render_fragment_oob(ctx, fragment_html)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from datetime import datetime, timezone, timedelta
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
session as qsession,
|
||||
@@ -45,7 +44,7 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "artdag", "artdag_l2"}
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "artdag", "artdag_l2"}
|
||||
|
||||
|
||||
def register(url_prefix="/auth"):
|
||||
@@ -275,7 +274,11 @@ def register(url_prefix="/auth"):
|
||||
if g.get("user"):
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
return await render_template("auth/login.html")
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
|
||||
@rate_limit(
|
||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
@@ -288,28 +291,20 @@ def register(url_prefix="/auth"):
|
||||
|
||||
is_valid, email = validate_email(email_input)
|
||||
if not is_valid:
|
||||
return (
|
||||
await render_template(
|
||||
"auth/login.html",
|
||||
error="Please enter a valid email address.",
|
||||
email=email_input,
|
||||
),
|
||||
400,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
||||
return await render_login_page(ctx), 400
|
||||
|
||||
# Per-email rate limit: 5 magic links per 15 minutes
|
||||
from shared.infrastructure.rate_limit import _check_rate_limit
|
||||
try:
|
||||
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
||||
if not allowed:
|
||||
return (
|
||||
await render_template(
|
||||
"auth/check_email.html",
|
||||
email=email,
|
||||
email_error=None,
|
||||
),
|
||||
200,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=None)
|
||||
return await render_check_email_page(ctx), 200
|
||||
except Exception:
|
||||
pass # Redis down — allow the request
|
||||
|
||||
@@ -329,11 +324,10 @@ def register(url_prefix="/auth"):
|
||||
"Please try again in a moment."
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"auth/check_email.html",
|
||||
email=email,
|
||||
email_error=email_error,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=email_error)
|
||||
return await render_check_email_page(ctx)
|
||||
|
||||
@auth_bp.get("/magic/<token>/")
|
||||
async def magic(token: str):
|
||||
@@ -346,20 +340,17 @@ def register(url_prefix="/auth"):
|
||||
user, error = await validate_magic_link(s, token)
|
||||
|
||||
if error:
|
||||
return (
|
||||
await render_template("auth/login.html", error=error),
|
||||
400,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error=error)
|
||||
return await render_login_page(ctx), 400
|
||||
user_id = user.id
|
||||
|
||||
except Exception:
|
||||
return (
|
||||
await render_template(
|
||||
"auth/login.html",
|
||||
error="Could not sign you in right now. Please try again.",
|
||||
),
|
||||
502,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
||||
return await render_login_page(ctx), 502
|
||||
|
||||
assert user_id is not None
|
||||
|
||||
@@ -688,8 +679,11 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/")
|
||||
async def device_form():
|
||||
"""Browser form where user enters the code displayed in terminal."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
code = request.args.get("code", "")
|
||||
return await render_template("auth/device.html", code=code)
|
||||
ctx = await get_template_context(code=code)
|
||||
return await render_device_page(ctx)
|
||||
|
||||
@auth_bp.post("/device")
|
||||
@auth_bp.post("/device/")
|
||||
@@ -699,22 +693,20 @@ def register(url_prefix="/auth"):
|
||||
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
||||
|
||||
if not user_code or len(user_code) != 8:
|
||||
return await render_template(
|
||||
"auth/device.html",
|
||||
error="Please enter a valid 8-character code.",
|
||||
code=form.get("code", ""),
|
||||
), 400
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
|
||||
r = await get_auth_redis()
|
||||
device_code = await r.get(f"devflow_uc:{user_code}")
|
||||
if not device_code:
|
||||
return await render_template(
|
||||
"auth/device.html",
|
||||
error="Code not found or expired. Please try again.",
|
||||
code=form.get("code", ""),
|
||||
), 400
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
|
||||
if isinstance(device_code, bytes):
|
||||
device_code = device_code.decode()
|
||||
@@ -728,17 +720,23 @@ def register(url_prefix="/auth"):
|
||||
# Logged in — approve immediately
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
return await render_template(
|
||||
"auth/device.html",
|
||||
error="Code expired or already used.",
|
||||
), 400
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Code expired or already used.")
|
||||
return await render_device_page(ctx), 400
|
||||
|
||||
return await render_template("auth/device_approved.html")
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_approved_page
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
|
||||
@auth_bp.get("/device/complete")
|
||||
@auth_bp.get("/device/complete/")
|
||||
async def device_complete():
|
||||
"""Post-login redirect — completes approval after magic link auth."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page, render_device_approved_page
|
||||
|
||||
device_code = request.args.get("code", "")
|
||||
|
||||
if not device_code:
|
||||
@@ -750,11 +748,12 @@ def register(url_prefix="/auth"):
|
||||
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
return await render_template(
|
||||
"auth/device.html",
|
||||
ctx = await get_template_context(
|
||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||
), 400
|
||||
)
|
||||
return await render_device_page(ctx), 400
|
||||
|
||||
return await render_template("auth/device_approved.html")
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
|
||||
return auth_bp
|
||||
|
||||
@@ -9,7 +9,7 @@ Fragments:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request, render_template
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
@@ -22,10 +22,13 @@ def register():
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _auth_menu():
|
||||
from shared.infrastructure.urls import account_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
user_email = request.args.get("email", "")
|
||||
return await render_template(
|
||||
"fragments/auth_menu.html",
|
||||
user_email=user_email,
|
||||
return render_sexp(
|
||||
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||
**{"user-email": user_email or None, "account-url": account_url("")},
|
||||
)
|
||||
|
||||
_handlers = {
|
||||
|
||||
58
account/sexp/auth.sexpr
Normal file
58
account/sexp/auth.sexpr
Normal file
@@ -0,0 +1,58 @@
|
||||
;; Auth page components (login, device, check email)
|
||||
|
||||
(defcomp ~account-login-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-login-form (&key error-html action csrf-token email)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
(raw! error-html)
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
|
||||
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
|
||||
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~account-device-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-device-form (&key error-html action csrf-token code)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
|
||||
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
|
||||
(raw! error-html)
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "code" :class "block text-sm font-medium mb-1" "Device code")
|
||||
(input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"
|
||||
:required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"
|
||||
:class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Authorize"))))
|
||||
|
||||
(defcomp ~account-device-approved ()
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
(defcomp ~account-check-email-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-check-email (&key email error-html)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
|
||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
||||
(raw! error-html)))
|
||||
48
account/sexp/dashboard.sexpr
Normal file
48
account/sexp/dashboard.sexpr
Normal file
@@ -0,0 +1,48 @@
|
||||
;; Account dashboard components
|
||||
|
||||
(defcomp ~account-error-banner (&key error)
|
||||
(when error
|
||||
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-user-email (&key email)
|
||||
(when email
|
||||
(p :class "text-sm text-stone-500 mt-1" (raw! email))))
|
||||
|
||||
(defcomp ~account-user-name (&key name)
|
||||
(when name
|
||||
(p :class "text-sm text-stone-600" (raw! name))))
|
||||
|
||||
(defcomp ~account-logout-form (&key csrf-token)
|
||||
(form :action "/auth/logout/" :method "post"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(button :type "submit"
|
||||
:class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-right-from-bracket text-xs") " Sign out")))
|
||||
|
||||
(defcomp ~account-label-item (&key name)
|
||||
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
|
||||
(raw! name)))
|
||||
|
||||
(defcomp ~account-labels-section (&key items-html)
|
||||
(when items-html
|
||||
(div
|
||||
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
|
||||
(div :class "flex flex-wrap gap-2" (raw! items-html)))))
|
||||
|
||||
(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html)
|
||||
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
|
||||
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
|
||||
(raw! error-html)
|
||||
(div :class "flex items-center justify-between"
|
||||
(div
|
||||
(h1 :class "text-xl font-semibold tracking-tight" "Account")
|
||||
(raw! email-html)
|
||||
(raw! name-html))
|
||||
(raw! logout-html))
|
||||
(raw! labels-html))))
|
||||
|
||||
;; Header child wrapper
|
||||
(defcomp ~account-header-child (&key inner-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
37
account/sexp/newsletters.sexpr
Normal file
37
account/sexp/newsletters.sexpr
Normal file
@@ -0,0 +1,37 @@
|
||||
;; Newsletter management components
|
||||
|
||||
(defcomp ~account-newsletter-desc (&key description)
|
||||
(when description
|
||||
(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description))))
|
||||
|
||||
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
|
||||
:class cls :role "switch" :aria-checked checked
|
||||
(span :class knob-cls))))
|
||||
|
||||
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
|
||||
:class "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
|
||||
:role "switch" :aria-checked "false"
|
||||
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"))))
|
||||
|
||||
(defcomp ~account-newsletter-item (&key name desc-html toggle-html)
|
||||
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
|
||||
(div :class "min-w-0 flex-1"
|
||||
(p :class "text-sm font-medium text-stone-800" (raw! name))
|
||||
(raw! desc-html))
|
||||
(div :class "ml-4 flex-shrink-0" (raw! toggle-html))))
|
||||
|
||||
(defcomp ~account-newsletter-list (&key items-html)
|
||||
(div :class "divide-y divide-stone-100" (raw! items-html)))
|
||||
|
||||
(defcomp ~account-newsletter-empty ()
|
||||
(p :class "text-sm text-stone-500" "No newsletters available."))
|
||||
|
||||
(defcomp ~account-newsletters-panel (&key list-html)
|
||||
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
|
||||
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
|
||||
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
|
||||
(raw! list-html))))
|
||||
385
account/sexp/sexp_components.py
Normal file
385
account/sexp/sexp_components.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||
auth pages. Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, root_header_html, search_desktop_html,
|
||||
search_mobile_html, full_page, oob_page,
|
||||
)
|
||||
|
||||
# Load account-specific .sexpr components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_nav_html(ctx: dict) -> str:
|
||||
"""Auth section desktop nav items."""
|
||||
html = render(
|
||||
"nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row."""
|
||||
return render(
|
||||
"menu-row",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
nav_html=_auth_nav_html(ctx),
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_nav_mobile_html(ctx: dict) -> str:
|
||||
"""Mobile nav menu for auth section."""
|
||||
html = render(
|
||||
"nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account dashboard (GET /)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_main_panel_html(ctx: dict) -> str:
|
||||
"""Account info panel with user details and logout."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
error = ctx.get("error", "")
|
||||
|
||||
error_html = render("account-error-banner", error=error) if error else ""
|
||||
|
||||
user_email_html = ""
|
||||
user_name_html = ""
|
||||
if user:
|
||||
user_email_html = render("account-user-email", email=user.email)
|
||||
if user.name:
|
||||
user_name_html = render("account-user-name", name=user.name)
|
||||
|
||||
logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
|
||||
|
||||
labels_html = ""
|
||||
if user and hasattr(user, "labels") and user.labels:
|
||||
label_items = "".join(
|
||||
render("account-label-item", name=label.name)
|
||||
for label in user.labels
|
||||
)
|
||||
labels_html = render("account-labels-section", items_html=label_items)
|
||||
|
||||
return render(
|
||||
"account-main-panel",
|
||||
error_html=error_html, email_html=user_email_html,
|
||||
name_html=user_name_html, logout_html=logout_html,
|
||||
labels_html=labels_html,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletters (GET /newsletters/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||
"""Render a single newsletter toggle switch."""
|
||||
nid = un.newsletter_id
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
if un.subscribed:
|
||||
bg = "bg-emerald-500"
|
||||
translate = "translate-x-6"
|
||||
checked = "true"
|
||||
else:
|
||||
bg = "bg-stone-300"
|
||||
translate = "translate-x-1"
|
||||
checked = "false"
|
||||
return render(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
|
||||
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
||||
return render(
|
||||
"account-newsletter-toggle-off",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Newsletters management panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
account_url_fn = ctx.get("account_url") or (lambda p: p)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
if newsletter_list:
|
||||
items = []
|
||||
for item in newsletter_list:
|
||||
nl = item["newsletter"]
|
||||
un = item.get("un")
|
||||
|
||||
desc_html = render(
|
||||
"account-newsletter-desc", description=nl.description
|
||||
) if nl.description else ""
|
||||
|
||||
if un:
|
||||
toggle = _newsletter_toggle_html(un, account_url_fn, csrf)
|
||||
else:
|
||||
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
|
||||
|
||||
items.append(render(
|
||||
"account-newsletter-item",
|
||||
name=nl.name, desc_html=desc_html, toggle_html=toggle,
|
||||
))
|
||||
list_html = render(
|
||||
"account-newsletter-list",
|
||||
items_html="".join(items),
|
||||
)
|
||||
else:
|
||||
list_html = render("account-newsletter-empty")
|
||||
|
||||
return render("account-newsletters-panel", list_html=list_html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_page_content(ctx: dict) -> str:
|
||||
"""Login form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
|
||||
error_html = render("account-login-error", error=error) if error else ""
|
||||
|
||||
return render(
|
||||
"account-login-form",
|
||||
error_html=error_html, action=action,
|
||||
csrf_token=generate_csrf_token(), email=email,
|
||||
)
|
||||
|
||||
|
||||
def _device_page_content(ctx: dict) -> str:
|
||||
"""Device authorization form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
action = url_for("auth.device_submit")
|
||||
|
||||
error_html = render("account-device-error", error=error) if error else ""
|
||||
|
||||
return render(
|
||||
"account-device-form",
|
||||
error_html=error_html, action=action,
|
||||
csrf_token=generate_csrf_token(), code=code,
|
||||
)
|
||||
|
||||
|
||||
def _device_approved_content() -> str:
|
||||
"""Device approved success content."""
|
||||
return render("account-device-approved")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_account_page(ctx: dict) -> str:
|
||||
"""Full page: account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_account_oob(ctx: dict) -> str:
|
||||
"""OOB response for account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||
"""OOB response for newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""Full page: fragment-provided content."""
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_login_page_content(ctx),
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_page_content(ctx),
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_approved_content(),
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Check email page (POST /start/ success)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_email_content(email: str, email_error: str | None = None) -> str:
|
||||
"""Check email confirmation content."""
|
||||
from markupsafe import escape
|
||||
|
||||
error_html = render(
|
||||
"account-check-email-error", error=str(escape(email_error))
|
||||
) if email_error else ""
|
||||
|
||||
return render(
|
||||
"account-check-email",
|
||||
email=str(escape(email)), error_html=error_html,
|
||||
)
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
"""Full page: check email after magic link sent."""
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_check_email_content(email, email_error),
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment renderers for POST handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_newsletter_toggle_html(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
|
||||
generate_csrf_token())
|
||||
|
||||
|
||||
def render_newsletter_toggle(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response (uses account_url)."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g
|
||||
account_url_fn = getattr(g, "_account_url", None)
|
||||
if account_url_fn is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
account_url_fn = account_url
|
||||
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())
|
||||
@@ -1,36 +0,0 @@
|
||||
{# Desktop auth menu #}
|
||||
<span id="auth-menu-desktop" class="hidden md:inline-flex">
|
||||
{% if user_email %}
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{ user_email }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{# Mobile auth menu #}
|
||||
<span id="auth-menu-mobile" class="block md:hidden text-md font-bold">
|
||||
{% if user_email %}
|
||||
<a href="{{ account_url('/') }}" data-close-details>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{ user_email }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ account_url('/') }}">
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
0
account/tests/__init__.py
Normal file
0
account/tests/__init__.py
Normal file
39
account/tests/test_auth_operations.py
Normal file
39
account/tests/test_auth_operations.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Unit tests for account auth operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from account.bp.auth.services.auth_operations import validate_email
|
||||
|
||||
|
||||
class TestValidateEmail:
|
||||
def test_valid_email(self):
|
||||
ok, email = validate_email("user@example.com")
|
||||
assert ok is True
|
||||
assert email == "user@example.com"
|
||||
|
||||
def test_uppercase_lowered(self):
|
||||
ok, email = validate_email("USER@EXAMPLE.COM")
|
||||
assert ok is True
|
||||
assert email == "user@example.com"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
ok, email = validate_email(" user@example.com ")
|
||||
assert ok is True
|
||||
assert email == "user@example.com"
|
||||
|
||||
def test_empty_string(self):
|
||||
ok, email = validate_email("")
|
||||
assert ok is False
|
||||
|
||||
def test_no_at_sign(self):
|
||||
ok, email = validate_email("notanemail")
|
||||
assert ok is False
|
||||
|
||||
def test_just_at(self):
|
||||
ok, email = validate_email("@")
|
||||
assert ok is True # has "@", passes the basic check
|
||||
|
||||
def test_spaces_only(self):
|
||||
ok, email = validate_email(" ")
|
||||
assert ok is False
|
||||
164
account/tests/test_ghost_membership.py
Normal file
164
account/tests/test_ghost_membership.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Unit tests for Ghost membership helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from account.services.ghost_membership import (
|
||||
_iso, _to_str_or_none, _member_email,
|
||||
_price_cents, _sanitize_member_payload,
|
||||
)
|
||||
|
||||
|
||||
class TestIso:
|
||||
def test_none(self):
|
||||
assert _iso(None) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert _iso("") is None
|
||||
|
||||
def test_z_suffix(self):
|
||||
result = _iso("2024-06-15T12:00:00Z")
|
||||
assert isinstance(result, datetime)
|
||||
assert result.year == 2024
|
||||
|
||||
def test_offset(self):
|
||||
result = _iso("2024-06-15T12:00:00+00:00")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
|
||||
class TestToStrOrNone:
|
||||
def test_none(self):
|
||||
assert _to_str_or_none(None) is None
|
||||
|
||||
def test_dict(self):
|
||||
assert _to_str_or_none({"a": 1}) is None
|
||||
|
||||
def test_list(self):
|
||||
assert _to_str_or_none([1, 2]) is None
|
||||
|
||||
def test_bytes(self):
|
||||
assert _to_str_or_none(b"hello") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _to_str_or_none("") is None
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert _to_str_or_none(" ") is None
|
||||
|
||||
def test_valid_string(self):
|
||||
assert _to_str_or_none("hello") == "hello"
|
||||
|
||||
def test_int(self):
|
||||
assert _to_str_or_none(42) == "42"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _to_str_or_none(" hi ") == "hi"
|
||||
|
||||
def test_set(self):
|
||||
assert _to_str_or_none({1, 2}) is None
|
||||
|
||||
def test_tuple(self):
|
||||
assert _to_str_or_none((1,)) is None
|
||||
|
||||
def test_bytearray(self):
|
||||
assert _to_str_or_none(bytearray(b"x")) is None
|
||||
|
||||
|
||||
class TestMemberEmail:
|
||||
def test_normal(self):
|
||||
assert _member_email({"email": "USER@EXAMPLE.COM"}) == "user@example.com"
|
||||
|
||||
def test_none(self):
|
||||
assert _member_email({"email": None}) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert _member_email({"email": ""}) is None
|
||||
|
||||
def test_whitespace(self):
|
||||
assert _member_email({"email": " "}) is None
|
||||
|
||||
def test_missing_key(self):
|
||||
assert _member_email({}) is None
|
||||
|
||||
def test_strips(self):
|
||||
assert _member_email({"email": " a@b.com "}) == "a@b.com"
|
||||
|
||||
|
||||
class TestPriceCents:
|
||||
def test_valid(self):
|
||||
assert _price_cents({"price": {"amount": 1500}}) == 1500
|
||||
|
||||
def test_string_amount(self):
|
||||
assert _price_cents({"price": {"amount": "2000"}}) == 2000
|
||||
|
||||
def test_missing_price(self):
|
||||
assert _price_cents({}) is None
|
||||
|
||||
def test_missing_amount(self):
|
||||
assert _price_cents({"price": {}}) is None
|
||||
|
||||
def test_none_amount(self):
|
||||
assert _price_cents({"price": {"amount": None}}) is None
|
||||
|
||||
def test_nested_none(self):
|
||||
assert _price_cents({"price": None}) is None
|
||||
|
||||
|
||||
class TestSanitizeMemberPayload:
|
||||
def test_email_lowercased(self):
|
||||
result = _sanitize_member_payload({"email": "USER@EXAMPLE.COM"})
|
||||
assert result["email"] == "user@example.com"
|
||||
|
||||
def test_empty_email_excluded(self):
|
||||
result = _sanitize_member_payload({"email": ""})
|
||||
assert "email" not in result
|
||||
|
||||
def test_name_included(self):
|
||||
result = _sanitize_member_payload({"name": "Alice"})
|
||||
assert result["name"] == "Alice"
|
||||
|
||||
def test_note_included(self):
|
||||
result = _sanitize_member_payload({"note": "VIP"})
|
||||
assert result["note"] == "VIP"
|
||||
|
||||
def test_subscribed_bool(self):
|
||||
result = _sanitize_member_payload({"subscribed": 1})
|
||||
assert result["subscribed"] is True
|
||||
|
||||
def test_labels_with_id(self):
|
||||
result = _sanitize_member_payload({
|
||||
"labels": [{"id": "abc"}, {"name": "VIP"}]
|
||||
})
|
||||
assert result["labels"] == [{"id": "abc"}, {"name": "VIP"}]
|
||||
|
||||
def test_labels_empty_items_excluded(self):
|
||||
result = _sanitize_member_payload({
|
||||
"labels": [{"id": None, "name": None}]
|
||||
})
|
||||
assert "labels" not in result
|
||||
|
||||
def test_newsletters_with_id(self):
|
||||
result = _sanitize_member_payload({
|
||||
"newsletters": [{"id": "n1", "subscribed": True}]
|
||||
})
|
||||
assert result["newsletters"] == [{"subscribed": True, "id": "n1"}]
|
||||
|
||||
def test_newsletters_default_subscribed(self):
|
||||
result = _sanitize_member_payload({
|
||||
"newsletters": [{"name": "Weekly"}]
|
||||
})
|
||||
assert result["newsletters"][0]["subscribed"] is True
|
||||
|
||||
def test_dict_email_excluded(self):
|
||||
result = _sanitize_member_payload({"email": {"bad": "input"}})
|
||||
assert "email" not in result
|
||||
|
||||
def test_id_passthrough(self):
|
||||
result = _sanitize_member_payload({"id": "ghost-member-123"})
|
||||
assert result["id"] == "ghost-member-123"
|
||||
|
||||
def test_empty_payload(self):
|
||||
result = _sanitize_member_payload({})
|
||||
assert result == {}
|
||||
44
blog/alembic/versions/0003_add_page_configs.py
Normal file
44
blog/alembic/versions/0003_add_page_configs.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Add page_configs table — moved from cart service to blog.
|
||||
|
||||
Revision ID: blog_0003
|
||||
Revises: blog_0002
|
||||
Create Date: 2026-02-27
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "blog_0003"
|
||||
down_revision = "blog_0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
|
||||
), {"t": name})
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _table_exists(op.get_bind(), "page_configs"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"page_configs",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("container_type", sa.String(32), nullable=False, server_default=sa.text("'page'")),
|
||||
sa.Column("container_id", sa.Integer, nullable=False),
|
||||
sa.Column("features", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("sumup_merchant_code", sa.String(64), nullable=True),
|
||||
sa.Column("sumup_api_key", sa.Text, nullable=True),
|
||||
sa.Column("sumup_checkout_prefix", sa.String(64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("page_configs")
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
|
||||
@@ -29,27 +29,28 @@ def register(url_prefix):
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_settings_page, render_settings_oob
|
||||
|
||||
# Determine which template to use based on request type and pagination
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/root/settings/index.html",
|
||||
)
|
||||
|
||||
html = await render_settings_page(tctx)
|
||||
else:
|
||||
html = await render_template("_types/root/settings/_oob_elements.html")
|
||||
|
||||
html = await render_settings_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/cache/")
|
||||
@require_admin
|
||||
async def cache():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_cache_page, render_cache_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/root/settings/cache/index.html")
|
||||
html = await render_cache_page(tctx)
|
||||
else:
|
||||
html = await render_template("_types/root/settings/cache/_oob_elements.html")
|
||||
html = await render_cache_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/cache_clear/")
|
||||
@@ -58,7 +59,8 @@ def register(url_prefix):
|
||||
await clear_all_cache()
|
||||
if is_htmx_request():
|
||||
now = datetime.now()
|
||||
html = f'<span class="text-green-600 font-bold">Cache cleared at {now.strftime("%H:%M:%S")}</span>'
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||
return html
|
||||
|
||||
return redirect(url_for("settings.cache"))
|
||||
|
||||
@@ -57,10 +57,15 @@ def register():
|
||||
|
||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await render_template("_types/blog/admin/tag_groups/index.html", **ctx)
|
||||
return await make_response(await render_tag_groups_page(tctx))
|
||||
else:
|
||||
return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx)
|
||||
return await make_response(await render_tag_groups_oob(tctx))
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@@ -117,10 +122,15 @@ def register():
|
||||
"assigned_tag_ids": assigned_tag_ids,
|
||||
}
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx)
|
||||
return await make_response(await render_tag_group_edit_page(tctx))
|
||||
else:
|
||||
return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx)
|
||||
return await make_response(await render_tag_group_edit_oob(tctx))
|
||||
|
||||
@bp.post("/<int:id>/")
|
||||
@require_admin
|
||||
|
||||
@@ -248,13 +248,23 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
|
||||
finally:
|
||||
sess.autoflush = old_autoflush
|
||||
|
||||
# Auto-create PageConfig for pages (lives in db_cart, accessed via internal API)
|
||||
# Auto-create PageConfig for pages (blog owns this table — direct DB,
|
||||
# not via HTTP, since this may run during startup before the server is ready)
|
||||
if obj.is_page:
|
||||
await fetch_data(
|
||||
"cart", "page-config-ensure",
|
||||
params={"container_type": "page", "container_id": obj.id},
|
||||
required=False,
|
||||
)
|
||||
from shared.models.page_config import PageConfig
|
||||
existing = (await sess.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == obj.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
sess.add(PageConfig(
|
||||
container_type="page",
|
||||
container_id=obj.id,
|
||||
features={},
|
||||
))
|
||||
await sess.flush()
|
||||
|
||||
return obj, old_status
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import os
|
||||
|
||||
from quart import (
|
||||
request,
|
||||
render_template,
|
||||
make_response,
|
||||
g,
|
||||
Blueprint,
|
||||
@@ -104,7 +103,7 @@ def register(url_prefix, title):
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
p_data = await _post_data("home", g.s, include_drafts=False)
|
||||
if not p_data:
|
||||
@@ -117,26 +116,16 @@ def register(url_prefix, title):
|
||||
db_post_id = p_data["post"]["id"]
|
||||
post_slug = p_data["post"]["slug"]
|
||||
|
||||
# Fetch container nav fragments from events + market
|
||||
paginate_url = url_for(
|
||||
'blog.post.widget_paginate',
|
||||
slug=post_slug, widget_domain='calendar',
|
||||
)
|
||||
nav_params = {
|
||||
# Fetch container nav from relations service
|
||||
container_nav_html = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
"paginate_url": paginate_url,
|
||||
}
|
||||
events_nav_html, market_nav_html = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
])
|
||||
container_nav_html = events_nav_html + market_nav_html
|
||||
})
|
||||
|
||||
ctx = {
|
||||
**p_data,
|
||||
"base_title": f"{get_config()['title']} {p_data['post']['title']}",
|
||||
"base_title": get_config()["title"],
|
||||
"container_nav_html": container_nav_html,
|
||||
}
|
||||
|
||||
@@ -153,10 +142,15 @@ def register(url_prefix, title):
|
||||
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_home_page, render_home_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/home/index.html", **ctx)
|
||||
html = await render_home_page(tctx)
|
||||
else:
|
||||
html = await render_template("_types/home/_oob_elements.html", **ctx)
|
||||
html = await render_home_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.get("/index")
|
||||
@@ -185,12 +179,17 @@ def register(url_prefix, title):
|
||||
"tag_groups": [],
|
||||
"posts": data.get("pages", []),
|
||||
}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/blog/index.html", **context)
|
||||
html = await render_blog_page(tctx)
|
||||
elif q.page > 1:
|
||||
html = await render_template("_types/blog/_page_cards.html", **context)
|
||||
html = await render_blog_page_cards(tctx)
|
||||
else:
|
||||
html = await render_template("_types/blog/_oob_elements.html", **context)
|
||||
html = await render_blog_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
# Default: posts listing
|
||||
@@ -221,28 +220,32 @@ def register(url_prefix, title):
|
||||
"drafts": q.drafts if show_drafts else None,
|
||||
}
|
||||
|
||||
# Determine which template to use based on request type and pagination
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/blog/index.html", **context)
|
||||
html = await render_blog_page(tctx)
|
||||
elif q.page > 1:
|
||||
# HTMX pagination: just blog cards + sentinel
|
||||
html = await render_template("_types/blog/_cards.html", **context)
|
||||
html = await render_blog_cards(tctx)
|
||||
else:
|
||||
# HTMX navigation (page 1): main panel + OOB elements
|
||||
#main_panel = await render_template("_types/blog/_main_panel.html", **context)
|
||||
html = await render_template("_types/blog/_oob_elements.html", **context)
|
||||
#html = oob_elements + main_panel
|
||||
html = await render_blog_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_post():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/blog_new/index.html")
|
||||
html = await render_new_post_page(tctx)
|
||||
else:
|
||||
html = await render_template("_types/blog_new/_oob_elements.html")
|
||||
html = await render_new_post_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.post("/new/")
|
||||
@@ -264,18 +267,20 @@ def register(url_prefix, title):
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
html = await render_template(
|
||||
"_types/blog_new/index.html",
|
||||
save_error="Invalid JSON in editor content.",
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
html = await render_template(
|
||||
"_types/blog_new/index.html",
|
||||
save_error=reason,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason)
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create in Ghost
|
||||
@@ -312,10 +317,16 @@ def register(url_prefix, title):
|
||||
@blogs_bp.get("/new-page/")
|
||||
@require_admin
|
||||
async def new_page():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(is_page=True)
|
||||
tctx["is_page"] = True
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/blog_new/index.html", is_page=True)
|
||||
html = await render_new_post_page(tctx)
|
||||
else:
|
||||
html = await render_template("_types/blog_new/_oob_elements.html", is_page=True)
|
||||
html = await render_new_post_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.post("/new-page/")
|
||||
@@ -337,20 +348,22 @@ def register(url_prefix, title):
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
html = await render_template(
|
||||
"_types/blog_new/index.html",
|
||||
save_error="Invalid JSON in editor content.",
|
||||
is_page=True,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
html = await render_template(
|
||||
"_types/blog_new/index.html",
|
||||
save_error=reason,
|
||||
is_page=True,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create in Ghost (as page)
|
||||
|
||||
@@ -10,6 +10,7 @@ from quart import Blueprint, Response, g, render_template, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.navigation import get_navigation_tree
|
||||
from shared.sexp.jinja_bridge import sexp
|
||||
|
||||
|
||||
def register():
|
||||
@@ -57,7 +58,21 @@ def register():
|
||||
|
||||
_handlers["nav-tree"] = _nav_tree_handler
|
||||
|
||||
# --- link-card fragment ---
|
||||
# --- link-card fragment (s-expression rendered) ---
|
||||
def _render_blog_link_card(post, link: str) -> str:
|
||||
"""Render a blog link-card via the ~link-card s-expression component."""
|
||||
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
|
||||
return sexp(
|
||||
'(~link-card :link link :title title :image image'
|
||||
' :icon "fas fa-file-alt" :subtitle excerpt'
|
||||
' :detail published :data-app "blog")',
|
||||
link=link,
|
||||
title=post.title,
|
||||
image=post.feature_image,
|
||||
excerpt=post.custom_excerpt or post.excerpt,
|
||||
published=published,
|
||||
)
|
||||
|
||||
async def _link_card_handler():
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.urls import blog_url
|
||||
@@ -73,14 +88,7 @@ def register():
|
||||
parts.append(f"<!-- fragment:{s} -->")
|
||||
post = await services.blog.get_post_by_slug(g.s, s)
|
||||
if post:
|
||||
parts.append(await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=post.title,
|
||||
feature_image=post.feature_image,
|
||||
excerpt=post.custom_excerpt or post.excerpt,
|
||||
published_at=post.published_at,
|
||||
link=blog_url(f"/{post.slug}"),
|
||||
))
|
||||
parts.append(_render_blog_link_card(post, blog_url(f"/{post.slug}")))
|
||||
return "\n".join(parts)
|
||||
|
||||
# Single mode
|
||||
@@ -89,14 +97,7 @@ def register():
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return ""
|
||||
return await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=post.title,
|
||||
feature_image=post.feature_image,
|
||||
excerpt=post.custom_excerpt or post.excerpt,
|
||||
published_at=post.published_at,
|
||||
link=blog_url(f"/{post.slug}"),
|
||||
)
|
||||
return _render_blog_link_card(post, blog_url(f"/{post.slug}"))
|
||||
|
||||
_handlers["link-card"] = _link_card_handler
|
||||
|
||||
|
||||
@@ -17,15 +17,10 @@ from shared.browser.app.utils.htmx import is_htmx_request
|
||||
def register():
|
||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||
|
||||
async def get_menu_items_nav_oob():
|
||||
def get_menu_items_nav_oob_sync(menu_items):
|
||||
"""Helper to generate OOB update for root nav menu items"""
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/menu_items/_nav_oob.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
return nav_oob
|
||||
from sexp.sexp_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
@@ -34,20 +29,15 @@ def register():
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/menu_items/index.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_oob_elements.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
#html = await render_template("_types/root/settings/_oob_elements.html")
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_menu_items_page, render_menu_items_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
if not is_htmx_request():
|
||||
html = await render_menu_items_page(tctx)
|
||||
else:
|
||||
html = await render_menu_items_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -82,12 +72,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
nav_oob = await get_menu_items_nav_oob()
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_list.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
except MenuItemError as e:
|
||||
@@ -128,12 +115,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
nav_oob = await get_menu_items_nav_oob()
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_list.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
except MenuItemError as e:
|
||||
@@ -152,12 +136,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
nav_oob = await get_menu_items_nav_oob()
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_list.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
@bp.get("/pages/search/")
|
||||
@@ -202,12 +183,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
nav_oob = await get_menu_items_nav_oob()
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_list.html",
|
||||
menu_items=menu_items,
|
||||
)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -80,9 +80,11 @@ async def create_menu_item(
|
||||
)
|
||||
session.add(menu_node)
|
||||
await session.flush()
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
await call_action("relations", "relate", payload={
|
||||
"relation_type": "page->menu_node",
|
||||
"from_id": post_id, "to_id": menu_node.id,
|
||||
"label": post.title,
|
||||
"metadata": {"slug": post.slug},
|
||||
})
|
||||
|
||||
return menu_node
|
||||
@@ -134,13 +136,15 @@ async def update_menu_item(
|
||||
await session.flush()
|
||||
|
||||
if post_id is not None and post_id != old_post_id:
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": old_post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
await call_action("relations", "unrelate", payload={
|
||||
"relation_type": "page->menu_node",
|
||||
"from_id": old_post_id, "to_id": menu_node.id,
|
||||
})
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
await call_action("relations", "relate", payload={
|
||||
"relation_type": "page->menu_node",
|
||||
"from_id": post_id, "to_id": menu_node.id,
|
||||
"label": post.title,
|
||||
"metadata": {"slug": post.slug},
|
||||
})
|
||||
|
||||
return menu_node
|
||||
@@ -154,9 +158,9 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
||||
|
||||
menu_node.deleted_at = func.now()
|
||||
await session.flush()
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": menu_node.container_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
await call_action("relations", "unrelate", payload={
|
||||
"relation_type": "page->menu_node",
|
||||
"from_id": menu_node.container_id, "to_id": menu_node.id,
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
@@ -51,13 +51,15 @@ def register():
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
# Determine which template to use based on request type
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_admin_page, render_post_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/post/admin/index.html", **ctx)
|
||||
html = await render_post_admin_page(tctx)
|
||||
else:
|
||||
# HTMX request: main panel + OOB elements
|
||||
html = await render_template("_types/post/admin/_oob_elements.html", **ctx)
|
||||
html = await render_post_admin_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -96,10 +98,9 @@ def register():
|
||||
|
||||
features = result.get("features", {})
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_features_panel.html",
|
||||
features=features,
|
||||
post=post,
|
||||
from sexp.sexp_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
@@ -136,10 +137,9 @@ def register():
|
||||
result = await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
features = result.get("features", {})
|
||||
html = await render_template(
|
||||
"_types/post/admin/_features_panel.html",
|
||||
features=features,
|
||||
post=post,
|
||||
from sexp.sexp_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
@@ -149,14 +149,16 @@ def register():
|
||||
@bp.get("/data/")
|
||||
@require_admin
|
||||
async def data(slug: str):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_data_page, render_post_data_oob
|
||||
|
||||
data_html = await render_template("_types/post_data/_main_panel.html")
|
||||
tctx = await get_template_context()
|
||||
tctx["data_html"] = data_html
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/post_data/index.html",
|
||||
)
|
||||
html = await render_post_data_page(tctx)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/post_data/_oob_elements.html",
|
||||
)
|
||||
html = await render_post_data_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -266,18 +268,20 @@ def register():
|
||||
# Load entries and post for each calendar
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_entries_page, render_post_entries_oob
|
||||
|
||||
entries_html = await render_template(
|
||||
"_types/post_entries/_main_panel.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["entries_html"] = entries_html
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/post_entries/index.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
html = await render_post_entries_page(tctx)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/post_entries/_oob_elements.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
html = await render_post_entries_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -325,20 +329,13 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
# Return the associated entries admin list + OOB update for nav entries
|
||||
admin_list = await render_template(
|
||||
"_types/post/admin/_associated_entries.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
from sexp.sexp_components import render_associated_entries, render_nav_entries_oob
|
||||
|
||||
nav_entries_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=calendars,
|
||||
post=g.post_data["post"],
|
||||
)
|
||||
post = g.post_data["post"]
|
||||
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
|
||||
|
||||
return await make_response(admin_list + nav_entries_oob)
|
||||
return await make_response(admin_list + nav_entries_html)
|
||||
|
||||
@bp.get("/settings/")
|
||||
@require_post_author
|
||||
@@ -350,18 +347,20 @@ def register():
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
save_success = request.args.get("saved") == "1"
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_settings_page, render_post_settings_oob
|
||||
|
||||
settings_html = await render_template(
|
||||
"_types/post_settings/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["settings_html"] = settings_html
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/post_settings/index.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
)
|
||||
html = await render_post_settings_page(tctx)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/post_settings/_oob_elements.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
)
|
||||
html = await render_post_settings_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -444,6 +443,7 @@ def register():
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
save_success = request.args.get("saved") == "1"
|
||||
save_error = request.args.get("error", "")
|
||||
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
@@ -451,20 +451,22 @@ def register():
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_edit_page, render_post_edit_oob
|
||||
|
||||
edit_html = await render_template(
|
||||
"_types/post_edit/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
save_error=save_error,
|
||||
newsletters=newsletters,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["edit_html"] = edit_html
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/post_edit/index.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
newsletters=newsletters,
|
||||
)
|
||||
html = await render_post_edit_page(tctx)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/post_edit/_oob_elements.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
newsletters=newsletters,
|
||||
)
|
||||
html = await render_post_edit_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -491,28 +493,15 @@ def register():
|
||||
feature_image_caption = form.get("feature_image_caption", "").strip()
|
||||
|
||||
# Validate the lexical JSON
|
||||
from urllib.parse import quote
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
html = await render_template(
|
||||
"_types/post_edit/index.html",
|
||||
ghost_post=ghost_post,
|
||||
save_error="Invalid JSON in editor content.",
|
||||
)
|
||||
return await make_response(html, 400)
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
html = await render_template(
|
||||
"_types/post_edit/index.html",
|
||||
ghost_post=ghost_post,
|
||||
save_error=reason,
|
||||
)
|
||||
return await make_response(html, 400)
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Update in Ghost (content save — no status change yet)
|
||||
ghost_post = await update_post(
|
||||
@@ -608,11 +597,8 @@ def register():
|
||||
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_markets_panel.html",
|
||||
markets=page_markets,
|
||||
post=post,
|
||||
)
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/markets/new/")
|
||||
@@ -638,11 +624,8 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_markets_panel.html",
|
||||
markets=page_markets,
|
||||
post=post,
|
||||
)
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.delete("/markets/<market_slug>/")
|
||||
@@ -662,11 +645,8 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_markets_panel.html",
|
||||
markets=page_markets,
|
||||
post=post,
|
||||
)
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
g,
|
||||
Blueprint,
|
||||
@@ -14,7 +13,7 @@ from .services.post_data import post_data
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
@@ -70,26 +69,16 @@ def register():
|
||||
db_post_id = (g.post_data.get("post") or {}).get("id")
|
||||
post_slug = (g.post_data.get("post") or {}).get("slug", "")
|
||||
|
||||
# Fetch container nav fragments from events + market
|
||||
paginate_url = url_for(
|
||||
'blog.post.widget_paginate',
|
||||
slug=post_slug, widget_domain='calendar',
|
||||
)
|
||||
nav_params = {
|
||||
# Fetch container nav from relations service
|
||||
container_nav_html = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
"paginate_url": paginate_url,
|
||||
}
|
||||
events_nav_html, market_nav_html = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
])
|
||||
container_nav_html = events_nav_html + market_nav_html
|
||||
})
|
||||
|
||||
ctx = {
|
||||
**p_data,
|
||||
"base_title": f"{config()['title']} {p_data['post']['title']}",
|
||||
"base_title": config()["title"],
|
||||
"container_nav_html": container_nav_html,
|
||||
}
|
||||
|
||||
@@ -114,13 +103,14 @@ def register():
|
||||
@bp.get("/")
|
||||
@cache_page(tag="post.post_detail")
|
||||
async def post_detail(slug: str):
|
||||
# Determine which template to use based on request type
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_page, render_post_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/post/index.html")
|
||||
html = await render_post_page(tctx)
|
||||
else:
|
||||
# HTMX request: main panel + OOB elements
|
||||
html = await render_template("_types/post/_oob_elements.html")
|
||||
html = await render_post_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -128,16 +118,13 @@ def register():
|
||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||
async def like_toggle(slug: str):
|
||||
from shared.utils import host_url
|
||||
from sexp.sexp_components import render_like_toggle_button
|
||||
|
||||
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
|
||||
|
||||
# Get post_id from g.post_data
|
||||
if not g.user:
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
slug=slug,
|
||||
liked=False,
|
||||
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
|
||||
item_type='post',
|
||||
)
|
||||
html = render_like_toggle_button(slug, False, like_url)
|
||||
resp = make_response(html, 403)
|
||||
return resp
|
||||
|
||||
@@ -149,13 +136,7 @@ def register():
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
slug=slug,
|
||||
liked=liked,
|
||||
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
|
||||
item_type='post',
|
||||
)
|
||||
html = render_like_toggle_button(slug, liked, like_url)
|
||||
return html
|
||||
|
||||
@bp.get("/w/<widget_domain>/")
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.infrastructure.actions import call_action, ActionError
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
@@ -41,11 +40,11 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
raw_pc = await fetch_data("blog", "page-config",
|
||||
params={"container_type": "page", "container_id": post_id},
|
||||
required=False)
|
||||
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):
|
||||
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
|
||||
check = await call_action("relations", "can-relate", payload={
|
||||
"relation_type": "page->market", "from_id": post_id,
|
||||
})
|
||||
if not check.get("allowed"):
|
||||
raise MarketError(check.get("reason", "Cannot create market for this page."))
|
||||
|
||||
try:
|
||||
result = await call_action("market", "create-marketplace", payload={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, render_template, make_response, request, g, abort
|
||||
from quart import Blueprint, make_response, request, g, abort
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -38,18 +38,16 @@ def register():
|
||||
snippets = await _visible_snippets(g.s)
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_snippets_page, render_snippets_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/snippets/index.html",
|
||||
snippets=snippets,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
html = await render_snippets_page(tctx)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/snippets/_oob_elements.html",
|
||||
snippets=snippets,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
html = await render_snippets_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -69,11 +67,8 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
html = await render_template(
|
||||
"_types/snippets/_list.html",
|
||||
snippets=snippets,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
from sexp.sexp_components import render_snippets_list
|
||||
html = render_snippets_list(snippets, is_admin)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.patch("/<int:snippet_id>/visibility/")
|
||||
@@ -97,11 +92,8 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
html = await render_template(
|
||||
"_types/snippets/_list.html",
|
||||
snippets=snippets,
|
||||
is_admin=True,
|
||||
)
|
||||
from sexp.sexp_components import render_snippets_list
|
||||
html = render_snippets_list(snippets, True)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
0
blog/sexp/__init__.py
Normal file
0
blog/sexp/__init__.py
Normal file
178
blog/sexp/admin.sexpr
Normal file
178
blog/sexp/admin.sexpr
Normal file
@@ -0,0 +1,178 @@
|
||||
;; Blog admin panel components
|
||||
|
||||
(defcomp ~blog-cache-panel (&key clear-url csrf)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(div :class "flex flex-col md:flex-row gap-3 items-start"
|
||||
(form :hx-post clear-url :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
|
||||
(div :id "cache-status" :class "py-2"))))
|
||||
|
||||
(defcomp ~blog-snippets-panel (&key list-html)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-between items-center"
|
||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||
(div :id "snippets-list" (raw! list-html))))
|
||||
|
||||
(defcomp ~blog-snippets-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-puzzle-piece text-4xl mb-2")
|
||||
(p "No snippets yet. Create one from the blog editor."))))
|
||||
|
||||
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls)
|
||||
(select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||
(raw! options-html)))
|
||||
|
||||
(defcomp ~blog-snippet-option (&key value selected label)
|
||||
(option :value value :selected selected label))
|
||||
|
||||
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
|
||||
(i :class "fa fa-trash") " Delete"))
|
||||
|
||||
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra-html)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-500" owner))
|
||||
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
|
||||
(raw! extra-html)))
|
||||
|
||||
(defcomp ~blog-snippets-list (&key rows-html)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
|
||||
|
||||
(defcomp ~blog-menu-items-panel (&key new-url list-html)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-end items-center"
|
||||
(button :type "button" :hx-get new-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
(i :class "fa fa-plus") " Add Menu Item"))
|
||||
(div :id "menu-item-form" :class "mb-6")
|
||||
(div :id "menu-items-list" (raw! list-html))))
|
||||
|
||||
(defcomp ~blog-menu-items-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-inbox text-4xl mb-2")
|
||||
(p "No menu items yet. Add one to get started!"))))
|
||||
|
||||
(defcomp ~blog-menu-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-menu-item-row (&key img-html label slug sort-order edit-url delete-url confirm-text hx-headers)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||
(raw! img-html)
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" label)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))
|
||||
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
|
||||
(div :class "flex gap-2 flex-shrink-0"
|
||||
(button :type "button" :hx-get edit-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
|
||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||
(i :class "fa fa-edit") " Edit")
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
|
||||
(i :class "fa fa-trash") " Delete"))))
|
||||
|
||||
(defcomp ~blog-menu-items-list (&key rows-html)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
|
||||
|
||||
;; Tag groups admin
|
||||
|
||||
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
|
||||
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
|
||||
(div :class "flex flex-col sm:flex-row gap-3"
|
||||
(input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")
|
||||
(input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")
|
||||
(input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))
|
||||
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-color (&key style initial)
|
||||
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||
:style style initial))
|
||||
|
||||
(defcomp ~blog-tag-group-li (&key icon-html edit-href name slug sort-order)
|
||||
(li :class "border rounded p-3 bg-white flex items-center gap-3"
|
||||
(raw! icon-html)
|
||||
(div :class "flex-1"
|
||||
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
|
||||
(span :class "text-xs text-stone-500 ml-2" slug))
|
||||
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
|
||||
|
||||
(defcomp ~blog-tag-groups-list (&key items-html)
|
||||
(ul :class "space-y-2" (raw! items-html)))
|
||||
|
||||
(defcomp ~blog-tag-groups-empty ()
|
||||
(p :class "text-stone-500 text-sm" "No tag groups yet."))
|
||||
|
||||
(defcomp ~blog-unassigned-tag (&key name)
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
||||
|
||||
(defcomp ~blog-unassigned-tags (&key heading spans-html)
|
||||
(div :class "border-t pt-4"
|
||||
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
|
||||
(div :class "flex flex-wrap gap-2" (raw! spans-html))))
|
||||
|
||||
(defcomp ~blog-tag-groups-main (&key form-html groups-html unassigned-html)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
|
||||
(raw! form-html) (raw! groups-html) (raw! unassigned-html)))
|
||||
|
||||
;; Tag group edit
|
||||
|
||||
(defcomp ~blog-tag-checkbox (&key tag-id checked img-html name)
|
||||
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
|
||||
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
|
||||
(raw! img-html) (span name)))
|
||||
|
||||
(defcomp ~blog-tag-checkbox-image (&key src)
|
||||
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags-html)
|
||||
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")
|
||||
(input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "flex gap-3"
|
||||
(div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")
|
||||
(input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")
|
||||
(input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")
|
||||
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
|
||||
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
|
||||
(raw! tags-html)))
|
||||
(div :class "flex gap-3"
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
|
||||
|
||||
(defcomp ~blog-tag-group-delete-form (&key delete-url csrf)
|
||||
(form :method "post" :action delete-url :class "border-t pt-4"
|
||||
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-main (&key edit-form-html delete-form-html)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(raw! edit-form-html) (raw! delete-form-html)))
|
||||
89
blog/sexp/cards.sexpr
Normal file
89
blog/sexp/cards.sexpr
Normal file
@@ -0,0 +1,89 @@
|
||||
;; Blog card components
|
||||
|
||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||
(button :hx-post like-url :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
|
||||
|
||||
(defcomp ~blog-published-status (&key timestamp)
|
||||
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
|
||||
|
||||
(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(raw! like-html)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(raw! status-html))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
(when widget-html (raw! widget-html))
|
||||
(raw! at-bar-html)))
|
||||
|
||||
(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html)
|
||||
(article :class "relative"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
|
||||
(div :class "p-3 text-center"
|
||||
(h2 :class "text-lg font-bold text-stone-900" title)
|
||||
(raw! status-html)
|
||||
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
|
||||
(raw! at-bar-html)))
|
||||
|
||||
(defcomp ~blog-tag-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-icon-initial (&key initial)
|
||||
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial))
|
||||
|
||||
(defcomp ~blog-tag-li (&key icon-html name)
|
||||
(li (a :class "flex items-center gap-1" (raw! icon-html)
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
|
||||
|
||||
(defcomp ~blog-tag-bar (&key items-html)
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
|
||||
|
||||
(defcomp ~blog-author-with-image (&key image name)
|
||||
(li :class "flex items-center gap-1"
|
||||
(img :src image :alt name :class "h-5 w-5 rounded-full object-cover")
|
||||
(span :class "text-stone-700" name)))
|
||||
|
||||
(defcomp ~blog-author-text (&key name)
|
||||
(li :class "text-stone-700" name))
|
||||
|
||||
(defcomp ~blog-author-bar (&key items-html)
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
|
||||
|
||||
(defcomp ~blog-at-bar (&key tag-items-html author-items-html)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(raw! tag-items-html) (div) (raw! author-items-html)))
|
||||
|
||||
(defcomp ~blog-page-badges (&key has-calendar has-market)
|
||||
(div :class "flex justify-center gap-2 mt-2"
|
||||
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
(i :class "fa fa-calendar mr-1") "Calendar"))
|
||||
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
(i :class "fa fa-shopping-bag mr-1") "Market"))))
|
||||
|
||||
(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(raw! badges-html) (raw! pub-html))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))
|
||||
57
blog/sexp/detail.sexpr
Normal file
57
blog/sexp/detail.sexpr
Normal file
@@ -0,0 +1,57 @@
|
||||
;; Blog post detail components
|
||||
|
||||
(defcomp ~blog-detail-edit-link (&key href hx-select)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
(i :class "fa fa-pencil mr-1") " Edit"))
|
||||
|
||||
(defcomp ~blog-detail-draft (&key publish-requested edit-html)
|
||||
(div :class "flex items-center justify-center gap-2 mb-3"
|
||||
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
(raw! edit-html)))
|
||||
|
||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(button :hx-post like-url :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||
|
||||
(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html)
|
||||
(<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html))))
|
||||
|
||||
(defcomp ~blog-detail-main (&key draft-html chrome-html feature-image html-content)
|
||||
(<> (article :class "relative"
|
||||
(raw! draft-html) (raw! chrome-html)
|
||||
(when feature-image (div :class "mb-3 flex justify-center"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
||||
(when html-content (div :class "blog-content p-2" (raw! html-content))))
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||
(<>
|
||||
(meta :name "robots" :content robots)
|
||||
(title page-title)
|
||||
(meta :name "description" :content desc)
|
||||
(when canonical (link :rel "canonical" :href canonical))
|
||||
(meta :property "og:type" :content og-type)
|
||||
(meta :property "og:title" :content og-title)
|
||||
(meta :property "og:description" :content desc)
|
||||
(when canonical (meta :property "og:url" :content canonical))
|
||||
(when image (meta :property "og:image" :content image))
|
||||
(meta :name "twitter:card" :content twitter-card)
|
||||
(meta :name "twitter:title" :content twitter-title)
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(when image (meta :name "twitter:image" :content image))))
|
||||
|
||||
(defcomp ~blog-home-main (&key html-content)
|
||||
(article :class "relative" (div :class "blog-content p-2" (raw! html-content))))
|
||||
|
||||
(defcomp ~blog-admin-empty ()
|
||||
(div :class "pb-8"))
|
||||
|
||||
(defcomp ~blog-settings-empty ()
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
54
blog/sexp/editor.sexpr
Normal file
54
blog/sexp/editor.sexpr
Normal file
@@ -0,0 +1,54 @@
|
||||
;; Blog editor components
|
||||
|
||||
(defcomp ~blog-editor-error (&key error)
|
||||
(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"
|
||||
(strong "Save failed:") " " error))
|
||||
|
||||
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
|
||||
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty"
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class "relative hidden"
|
||||
(img :id "feature-image-preview" :src "" :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(button :type "button" :id "feature-image-delete-btn"
|
||||
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||
:title "Remove feature image"
|
||||
(i :class "fa-solid fa-trash-can"))
|
||||
(input :type "text" :id "feature-image-caption" :value ""
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :id "feature-image-uploading"
|
||||
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
|
||||
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value "" :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
|
||||
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent")
|
||||
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :name "status"
|
||||
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||
(option :value "draft" :selected t "Draft")
|
||||
(option :value "published" "Published"))
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
"#lexical-editor { display: flow-root; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||
|
||||
(defcomp ~blog-editor-scripts (&key js-src init-js)
|
||||
(<> (script :src js-src) (script (raw! init-js))))
|
||||
65
blog/sexp/filters.sexpr
Normal file
65
blog/sexp/filters.sexpr
Normal file
@@ -0,0 +1,65 @@
|
||||
;; Blog filter components
|
||||
|
||||
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class btn-class :title title (i :class icon-class) label))
|
||||
|
||||
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-action-buttons-wrapper (&key inner-html)
|
||||
(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html)))
|
||||
|
||||
(defcomp ~blog-filter-any-topic (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded border " cls)
|
||||
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" "Any Topic")))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-color (&key style initial)
|
||||
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
|
||||
|
||||
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon-html name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
|
||||
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
(raw! icon-html)
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name)
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-nav (&key items-html)
|
||||
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
|
||||
(ul :class "divide-y flex flex-col gap-3" (raw! items-html))))
|
||||
|
||||
(defcomp ~blog-filter-any-author (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded " cls)
|
||||
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" "Any author")))
|
||||
|
||||
(defcomp ~blog-filter-author-icon (&key src name)
|
||||
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon-html name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
|
||||
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
(raw! icon-html)
|
||||
(span :class "text-stone-700" name)
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-summary (&key text)
|
||||
(span :class "text-sm text-stone-600" text))
|
||||
24
blog/sexp/header.sexpr
Normal file
24
blog/sexp/header.sexpr
Normal file
@@ -0,0 +1,24 @@
|
||||
;; Blog header components
|
||||
|
||||
(defcomp ~blog-header-label ()
|
||||
(div))
|
||||
|
||||
(defcomp ~blog-container-nav (&key container-nav-html)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "entries-calendars-nav-wrapper" (raw! container-nav-html)))
|
||||
|
||||
(defcomp ~blog-admin-label ()
|
||||
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
|
||||
|
||||
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
|
||||
label)))
|
||||
|
||||
(defcomp ~blog-sub-settings-label (&key icon label)
|
||||
(<> (i :class icon :aria-hidden "true") " " label))
|
||||
|
||||
(defcomp ~blog-sub-admin-label (&key icon label)
|
||||
(<> (i :class icon :aria-hidden "true") (div label)))
|
||||
72
blog/sexp/index.sexpr
Normal file
72
blog/sexp/index.sexpr
Normal file
@@ -0,0 +1,72 @@
|
||||
;; Blog index components
|
||||
|
||||
(defcomp ~blog-end-of-results ()
|
||||
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
|
||||
|
||||
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
|
||||
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
||||
:hx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-8"
|
||||
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
||||
|
||||
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
|
||||
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry"
|
||||
:hx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-2"
|
||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
||||
|
||||
(defcomp ~blog-page-sentinel (&key id next-url)
|
||||
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"))
|
||||
|
||||
(defcomp ~blog-no-pages ()
|
||||
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
||||
|
||||
(defcomp ~blog-list-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
|
||||
|
||||
(defcomp ~blog-tile-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round"
|
||||
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
|
||||
|
||||
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg-html tile-svg-html)
|
||||
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
||||
(a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
||||
:_ "on click js localStorage.removeItem('blog_view') end" (raw! list-svg-html))
|
||||
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
||||
:_ "on click js localStorage.setItem('blog_view','tile') end" (raw! tile-svg-html))))
|
||||
|
||||
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||
(div :class "flex justify-center gap-1 px-3 pt-3"
|
||||
(a :href posts-href :hx-get posts-href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
|
||||
(a :href pages-href :hx-get pages-href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
|
||||
|
||||
(defcomp ~blog-main-panel-pages (&key tabs-html cards-html)
|
||||
(<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-main-panel-posts (&key tabs-html toggle-html grid-cls cards-html)
|
||||
(<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-aside (&key search-html action-buttons-html tag-groups-filter-html authors-filter-html)
|
||||
(<> (raw! search-html) (raw! action-buttons-html)
|
||||
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
|
||||
(raw! tag-groups-filter-html) (raw! authors-filter-html))
|
||||
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
||||
67
blog/sexp/nav.sexpr
Normal file
67
blog/sexp/nav.sexpr
Normal file
@@ -0,0 +1,67 @@
|
||||
;; Blog navigation components
|
||||
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :hx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img-html label)
|
||||
(div (a :href href :hx-get hx-get :hx-target "#main-panel"
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
:aria-selected selected :class nav-cls
|
||||
(raw! img-html) (span label))))
|
||||
|
||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img-html label)
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
(raw! img-html) (span label))))
|
||||
|
||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items-html)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML"
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(div :id container-id
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
||||
(a :href href :class nav-cls
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items-html)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "entries-calendars-nav-wrapper" :hx-swap-oob "true"
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll left"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
||||
(i :class "fa fa-chevron-left"))
|
||||
(div :id "associated-items-container"
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll right"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
(i :class "fa fa-chevron-right"))))
|
||||
113
blog/sexp/settings.sexpr
Normal file
113
blog/sexp/settings.sexpr
Normal file
@@ -0,0 +1,113 @@
|
||||
;; Blog settings panel components (features, markets, associated entries)
|
||||
|
||||
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
||||
(form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML"
|
||||
:hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :class "space-y-3"
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
||||
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||
:_ hs-trigger)
|
||||
(span :class "text-sm text-stone-700"
|
||||
(i :class "fa fa-calendar text-blue-600 mr-1")
|
||||
" Calendar \u2014 enable event booking on this page"))
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
(input :type "checkbox" :name "market" :value "true" :checked market-checked
|
||||
:class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"
|
||||
:_ hs-trigger)
|
||||
(span :class "text-sm text-stone-700"
|
||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||
" Market \u2014 enable product catalog on this page"))))
|
||||
|
||||
(defcomp ~blog-sumup-connected ()
|
||||
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
|
||||
|
||||
(defcomp ~blog-sumup-key-hint ()
|
||||
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
|
||||
|
||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint-html checkout-prefix connected-html)
|
||||
(div :class "mt-4 pt-4 border-t border-stone-100"
|
||||
(h4 :class "text-sm font-medium text-stone-700"
|
||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
||||
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
||||
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
||||
(form :hx-put sumup-url :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
|
||||
(raw! key-hint-html))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(button :type "submit"
|
||||
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
||||
"Save SumUp Settings")
|
||||
(raw! connected-html))))
|
||||
|
||||
(defcomp ~blog-features-panel (&key form-html sumup-html)
|
||||
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
|
||||
(raw! form-html) (raw! sumup-html)))
|
||||
|
||||
;; Markets panel
|
||||
|
||||
(defcomp ~blog-market-item (&key name slug delete-url confirm-text)
|
||||
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
|
||||
(div (span :class "font-medium" name)
|
||||
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
|
||||
(button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML"
|
||||
:hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
||||
|
||||
(defcomp ~blog-markets-list (&key items-html)
|
||||
(ul :class "space-y-2 mb-4" (raw! items-html)))
|
||||
|
||||
(defcomp ~blog-markets-empty ()
|
||||
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
|
||||
|
||||
(defcomp ~blog-markets-panel (&key list-html create-url)
|
||||
(div :id "markets-panel"
|
||||
(h3 :class "text-lg font-semibold mb-3" "Markets")
|
||||
(raw! list-html)
|
||||
(form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"
|
||||
(input :type "text" :name "name" :placeholder "Market name" :required ""
|
||||
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
|
||||
(button :type "submit"
|
||||
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
||||
|
||||
;; Associated entries
|
||||
|
||||
(defcomp ~blog-entry-image (&key src title)
|
||||
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img-html name date-str)
|
||||
(button :type "button"
|
||||
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
|
||||
:data-confirm "" :data-confirm-title "Remove entry?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, remove it"
|
||||
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
||||
:hx-post toggle-url :hx-trigger "confirmed"
|
||||
:hx-target "#associated-entries-list" :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers
|
||||
:_ "on htmx:afterRequest trigger entryToggled on body"
|
||||
(div :class "flex items-center justify-between gap-3"
|
||||
(raw! img-html)
|
||||
(div :class "flex-1"
|
||||
(div :class "font-medium text-sm" name)
|
||||
(div :class "text-xs text-stone-600 mt-1" date-str))
|
||||
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
|
||||
|
||||
(defcomp ~blog-associated-entries-content (&key items-html)
|
||||
(div :class "space-y-1" (raw! items-html)))
|
||||
|
||||
(defcomp ~blog-associated-entries-empty ()
|
||||
(div :class "text-sm text-stone-400"
|
||||
"No entries associated yet. Browse calendars below to add entries."))
|
||||
|
||||
(defcomp ~blog-associated-entries-panel (&key content-html)
|
||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||
(raw! content-html)))
|
||||
1892
blog/sexp/sexp_components.py
Normal file
1892
blog/sexp/sexp_components.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
|
||||
|
||||
{% for entry in entry_list %}
|
||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
{% set _entry_path = '/' + post.slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
|
||||
calendars
|
||||
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="{{styles.nav_button}}">
|
||||
calendar
|
||||
</a>
|
||||
</div>
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ events_url('/' + post.slug + '/markets/') }}" class="{{styles.nav_button}}">
|
||||
<a href="{{ market_url('/' + post.slug + '/') }}" class="{{styles.nav_button}}">
|
||||
markets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,80 +1,34 @@
|
||||
{# OOB swap for nav entries and calendars when toggling associations or editing calendars #}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{# OOB swap for nav entries and calendars — blog's version using shared macro #}
|
||||
{% from 'macros/nav_entries.html' import nav_entries_oob %}
|
||||
|
||||
{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #}
|
||||
{% if (associated_entries and associated_entries.entries) or calendars %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entries-calendars-nav-wrapper"
|
||||
hx-swap-oob="true">
|
||||
{# Left scroll arrow - desktop only #}
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<div id="associated-items-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="on load or scroll
|
||||
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||
remove .hidden from .entries-nav-arrow
|
||||
add .flex to .entries-nav-arrow
|
||||
else
|
||||
add .hidden to .entries-nav-arrow
|
||||
remove .flex from .entries-nav-arrow
|
||||
end">
|
||||
<div class="flex flex-col sm:flex-row gap-1">
|
||||
{# Calendar entries #}
|
||||
{% if associated_entries and associated_entries.entries %}
|
||||
{% for entry in associated_entries.entries %}
|
||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{# Calendar links #}
|
||||
{% if calendars %}
|
||||
{% for calendar in calendars %}
|
||||
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
|
||||
{# Right scroll arrow - desktop only #}
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Empty placeholder to remove nav items when all are disassociated/deleted #}
|
||||
<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>
|
||||
{% endif %}
|
||||
{% set has_items = (associated_entries and associated_entries.entries) or calendars %}
|
||||
{% call nav_entries_oob(has_items) %}
|
||||
{% if associated_entries and associated_entries.entries %}
|
||||
{% for entry in associated_entries.entries %}
|
||||
{% set _entry_path = '/' + post.slug + '/' +entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if calendars %}
|
||||
{% for calendar in calendars %}
|
||||
{% set local_href=events_url('/' + post.slug + '/' +calendar.slug + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_data-row', oob=oob) %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
<i class="fa fa-database" aria-hidden="true"></i>
|
||||
<div>data</div>
|
||||
</a>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="blog" data-hx-disable>
|
||||
<div class="flex flex-row items-start gap-3 p-3">
|
||||
{% if feature_image %}
|
||||
<img src="{{ feature_image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||
{% else %}
|
||||
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||
<i class="fas fa-file-alt text-lg"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||
{% if excerpt %}
|
||||
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ excerpt }}</div>
|
||||
{% endif %}
|
||||
{% if published_at %}
|
||||
<div class="text-xs text-stone-400 mt-1">{{ published_at.strftime('%d %b %Y') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
0
blog/tests/__init__.py
Normal file
0
blog/tests/__init__.py
Normal file
44
blog/tests/test_card_fragments.py
Normal file
44
blog/tests/test_card_fragments.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Unit tests for card fragment parser."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.blog.services.posts_data import _parse_card_fragments
|
||||
|
||||
|
||||
class TestParseCardFragments:
|
||||
def test_empty_string(self):
|
||||
assert _parse_card_fragments("") == {}
|
||||
|
||||
def test_single_block(self):
|
||||
html = '<!-- card-widget:42 --><div>card</div><!-- /card-widget:42 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {"42": "<div>card</div>"}
|
||||
|
||||
def test_multiple_blocks(self):
|
||||
html = (
|
||||
'<!-- card-widget:1 -->A<!-- /card-widget:1 -->'
|
||||
'<!-- card-widget:2 -->B<!-- /card-widget:2 -->'
|
||||
)
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {"1": "A", "2": "B"}
|
||||
|
||||
def test_empty_inner_skipped(self):
|
||||
html = '<!-- card-widget:99 --> <!-- /card-widget:99 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {}
|
||||
|
||||
def test_multiline_content(self):
|
||||
html = '<!-- card-widget:5 -->\n<p>line1</p>\n<p>line2</p>\n<!-- /card-widget:5 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert "5" in result
|
||||
assert "<p>line1</p>" in result["5"]
|
||||
|
||||
def test_mismatched_ids_not_captured(self):
|
||||
html = '<!-- card-widget:1 -->content<!-- /card-widget:2 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {}
|
||||
|
||||
def test_no_markers(self):
|
||||
html = '<div>no markers here</div>'
|
||||
assert _parse_card_fragments(html) == {}
|
||||
103
blog/tests/test_ghost_sync.py
Normal file
103
blog/tests/test_ghost_sync.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Unit tests for Ghost sync helper functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.blog.ghost.ghost_sync import _iso, _build_ap_post_data
|
||||
|
||||
|
||||
class TestIso:
|
||||
def test_none(self):
|
||||
assert _iso(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _iso("") is None
|
||||
|
||||
def test_z_suffix(self):
|
||||
result = _iso("2024-01-15T10:30:00Z")
|
||||
assert isinstance(result, datetime)
|
||||
assert result.tzinfo is not None
|
||||
assert result.year == 2024
|
||||
assert result.month == 1
|
||||
assert result.hour == 10
|
||||
|
||||
def test_offset_suffix(self):
|
||||
result = _iso("2024-06-01T08:00:00+00:00")
|
||||
assert isinstance(result, datetime)
|
||||
assert result.hour == 8
|
||||
|
||||
|
||||
class TestBuildApPostData:
|
||||
def _post(self, **kwargs):
|
||||
defaults = {
|
||||
"title": "My Post",
|
||||
"plaintext": "Some body text.",
|
||||
"custom_excerpt": None,
|
||||
"excerpt": None,
|
||||
"feature_image": None,
|
||||
"feature_image_alt": None,
|
||||
"html": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def _tag(self, slug):
|
||||
return SimpleNamespace(slug=slug)
|
||||
|
||||
def test_basic_post(self):
|
||||
post = self._post()
|
||||
result = _build_ap_post_data(post, "https://blog.example.com/post/", [])
|
||||
assert result["name"] == "My Post"
|
||||
assert "My Post" in result["content"]
|
||||
assert "Some body text." in result["content"]
|
||||
assert result["url"] == "https://blog.example.com/post/"
|
||||
|
||||
def test_no_title(self):
|
||||
post = self._post(title=None)
|
||||
result = _build_ap_post_data(post, "https://example.com/", [])
|
||||
assert result["name"] == ""
|
||||
|
||||
def test_feature_image(self):
|
||||
post = self._post(feature_image="https://img.com/photo.jpg",
|
||||
feature_image_alt="A photo")
|
||||
result = _build_ap_post_data(post, "https://example.com/", [])
|
||||
assert "attachment" in result
|
||||
assert result["attachment"][0]["url"] == "https://img.com/photo.jpg"
|
||||
assert result["attachment"][0]["name"] == "A photo"
|
||||
|
||||
def test_inline_images_capped_at_4(self):
|
||||
html = "".join(f'<img src="https://img.com/{i}.jpg">' for i in range(10))
|
||||
post = self._post(html=html)
|
||||
result = _build_ap_post_data(post, "https://example.com/", [])
|
||||
assert len(result["attachment"]) == 4
|
||||
|
||||
def test_tags(self):
|
||||
tags = [self._tag("my-tag"), self._tag("another")]
|
||||
post = self._post()
|
||||
result = _build_ap_post_data(post, "https://example.com/", tags)
|
||||
assert "tag" in result
|
||||
assert len(result["tag"]) == 2
|
||||
assert result["tag"][0]["name"] == "#mytag" # dashes removed
|
||||
assert result["tag"][0]["type"] == "Hashtag"
|
||||
|
||||
def test_hashtag_in_content(self):
|
||||
tags = [self._tag("web-dev")]
|
||||
post = self._post()
|
||||
result = _build_ap_post_data(post, "https://example.com/", tags)
|
||||
assert "#webdev" in result["content"]
|
||||
|
||||
def test_no_duplicate_images(self):
|
||||
post = self._post(
|
||||
feature_image="https://img.com/same.jpg",
|
||||
html='<img src="https://img.com/same.jpg">',
|
||||
)
|
||||
result = _build_ap_post_data(post, "https://example.com/", [])
|
||||
assert len(result["attachment"]) == 1
|
||||
|
||||
def test_multiline_body(self):
|
||||
post = self._post(plaintext="Para one.\n\nPara two.\n\nPara three.")
|
||||
result = _build_ap_post_data(post, "https://example.com/", [])
|
||||
assert result["content"].count("<p>") >= 4 # title + 3 paras + read more
|
||||
393
blog/tests/test_lexical_renderer.py
Normal file
393
blog/tests/test_lexical_renderer.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Unit tests for the Lexical JSON → HTML renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.blog.ghost.lexical_renderer import (
|
||||
render_lexical, _wrap_format, _align_style,
|
||||
_FORMAT_BOLD, _FORMAT_ITALIC, _FORMAT_STRIKETHROUGH,
|
||||
_FORMAT_UNDERLINE, _FORMAT_CODE, _FORMAT_SUBSCRIPT,
|
||||
_FORMAT_SUPERSCRIPT, _FORMAT_HIGHLIGHT,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wrap_format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWrapFormat:
|
||||
def test_no_format(self):
|
||||
assert _wrap_format("hello", 0) == "hello"
|
||||
|
||||
def test_bold(self):
|
||||
assert _wrap_format("x", _FORMAT_BOLD) == "<strong>x</strong>"
|
||||
|
||||
def test_italic(self):
|
||||
assert _wrap_format("x", _FORMAT_ITALIC) == "<em>x</em>"
|
||||
|
||||
def test_strikethrough(self):
|
||||
assert _wrap_format("x", _FORMAT_STRIKETHROUGH) == "<s>x</s>"
|
||||
|
||||
def test_underline(self):
|
||||
assert _wrap_format("x", _FORMAT_UNDERLINE) == "<u>x</u>"
|
||||
|
||||
def test_code(self):
|
||||
assert _wrap_format("x", _FORMAT_CODE) == "<code>x</code>"
|
||||
|
||||
def test_subscript(self):
|
||||
assert _wrap_format("x", _FORMAT_SUBSCRIPT) == "<sub>x</sub>"
|
||||
|
||||
def test_superscript(self):
|
||||
assert _wrap_format("x", _FORMAT_SUPERSCRIPT) == "<sup>x</sup>"
|
||||
|
||||
def test_highlight(self):
|
||||
assert _wrap_format("x", _FORMAT_HIGHLIGHT) == "<mark>x</mark>"
|
||||
|
||||
def test_bold_italic(self):
|
||||
result = _wrap_format("x", _FORMAT_BOLD | _FORMAT_ITALIC)
|
||||
assert "<strong>" in result
|
||||
assert "<em>" in result
|
||||
|
||||
def test_all_flags(self):
|
||||
all_flags = (
|
||||
_FORMAT_BOLD | _FORMAT_ITALIC | _FORMAT_STRIKETHROUGH |
|
||||
_FORMAT_UNDERLINE | _FORMAT_CODE | _FORMAT_SUBSCRIPT |
|
||||
_FORMAT_SUPERSCRIPT | _FORMAT_HIGHLIGHT
|
||||
)
|
||||
result = _wrap_format("x", all_flags)
|
||||
for tag in ["strong", "em", "s", "u", "code", "sub", "sup", "mark"]:
|
||||
assert f"<{tag}>" in result
|
||||
assert f"</{tag}>" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _align_style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAlignStyle:
|
||||
def test_no_format(self):
|
||||
assert _align_style({}) == ""
|
||||
|
||||
def test_format_zero(self):
|
||||
assert _align_style({"format": 0}) == ""
|
||||
|
||||
def test_left(self):
|
||||
assert _align_style({"format": 1}) == ' style="text-align: left"'
|
||||
|
||||
def test_center(self):
|
||||
assert _align_style({"format": 2}) == ' style="text-align: center"'
|
||||
|
||||
def test_right(self):
|
||||
assert _align_style({"format": 3}) == ' style="text-align: right"'
|
||||
|
||||
def test_justify(self):
|
||||
assert _align_style({"format": 4}) == ' style="text-align: justify"'
|
||||
|
||||
def test_string_format(self):
|
||||
assert _align_style({"format": "center"}) == ' style="text-align: center"'
|
||||
|
||||
def test_unmapped_int(self):
|
||||
assert _align_style({"format": 99}) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_lexical — text nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderLexicalText:
|
||||
def test_empty_doc(self):
|
||||
assert render_lexical({"root": {"children": []}}) == ""
|
||||
|
||||
def test_plain_text(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}}
|
||||
assert render_lexical(doc) == "hello"
|
||||
|
||||
def test_html_escape(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "text", "text": "<script>alert('xss')</script>"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_bold_text(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "text", "text": "bold", "format": _FORMAT_BOLD}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<strong>bold</strong>"
|
||||
|
||||
def test_string_input(self):
|
||||
import json
|
||||
doc = {"root": {"children": [{"type": "text", "text": "hi"}]}}
|
||||
assert render_lexical(json.dumps(doc)) == "hi"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_lexical — block nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderLexicalBlocks:
|
||||
def test_paragraph(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<p>hello</p>"
|
||||
|
||||
def test_empty_paragraph(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "paragraph", "children": []}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<p><br></p>"
|
||||
|
||||
def test_heading_default(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "heading", "children": [
|
||||
{"type": "text", "text": "title"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<h2>title</h2>"
|
||||
|
||||
def test_heading_h3(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "heading", "tag": "h3", "children": [
|
||||
{"type": "text", "text": "title"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<h3>title</h3>"
|
||||
|
||||
def test_blockquote(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "quote", "children": [
|
||||
{"type": "text", "text": "quoted"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<blockquote>quoted</blockquote>"
|
||||
|
||||
def test_linebreak(self):
|
||||
doc = {"root": {"children": [{"type": "linebreak"}]}}
|
||||
assert render_lexical(doc) == "<br>"
|
||||
|
||||
def test_horizontal_rule(self):
|
||||
doc = {"root": {"children": [{"type": "horizontalrule"}]}}
|
||||
assert render_lexical(doc) == "<hr>"
|
||||
|
||||
def test_unordered_list(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "bullet", "children": [
|
||||
{"type": "listitem", "children": [
|
||||
{"type": "text", "text": "item"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<ul><li>item</li></ul>"
|
||||
|
||||
def test_ordered_list(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "number", "children": [
|
||||
{"type": "listitem", "children": [
|
||||
{"type": "text", "text": "one"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<ol><li>one</li></ol>"
|
||||
|
||||
def test_ordered_list_custom_start(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "number", "start": 5, "children": []}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'start="5"' in result
|
||||
|
||||
def test_link(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "link", "url": "https://example.com", "children": [
|
||||
{"type": "text", "text": "click"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'href="https://example.com"' in result
|
||||
assert "click" in result
|
||||
|
||||
def test_link_xss_url(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "link", "url": 'javascript:alert("xss")', "children": []}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "javascript:alert("xss")" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_lexical — cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderLexicalCards:
|
||||
def test_image(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "photo.jpg", "alt": "A photo"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-image-card" in result
|
||||
assert 'src="photo.jpg"' in result
|
||||
assert 'alt="A photo"' in result
|
||||
assert 'loading="lazy"' in result
|
||||
|
||||
def test_image_wide(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "x.jpg", "cardWidth": "wide"}
|
||||
]}}
|
||||
assert "kg-width-wide" in render_lexical(doc)
|
||||
|
||||
def test_image_with_caption(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "x.jpg", "caption": "Caption text"}
|
||||
]}}
|
||||
assert "<figcaption>Caption text</figcaption>" in render_lexical(doc)
|
||||
|
||||
def test_codeblock(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "codeblock", "code": "print('hi')", "language": "python"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'class="language-python"' in result
|
||||
assert "print('hi')" in result
|
||||
|
||||
def test_html_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "html", "html": "<div>raw</div>"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<!--kg-card-begin: html-->" in result
|
||||
assert "<div>raw</div>" in result
|
||||
|
||||
def test_markdown_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "markdown", "markdown": "**bold**"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<!--kg-card-begin: markdown-->" in result
|
||||
assert "<strong>bold</strong>" in result
|
||||
|
||||
def test_callout(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "callout", "backgroundColor": "blue",
|
||||
"calloutEmoji": "💡", "children": [
|
||||
{"type": "text", "text": "Note"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-callout-card-blue" in result
|
||||
assert "💡" in result
|
||||
|
||||
def test_button(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "button", "buttonText": "Click",
|
||||
"buttonUrl": "https://example.com", "alignment": "left"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-align-left" in result
|
||||
assert "Click" in result
|
||||
|
||||
def test_toggle(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "toggle", "heading": "FAQ", "children": [
|
||||
{"type": "text", "text": "Answer"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-toggle-card" in result
|
||||
assert "FAQ" in result
|
||||
|
||||
def test_audio_duration(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "audio", "src": "a.mp3", "title": "Song", "duration": 185}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "3:05" in result
|
||||
|
||||
def test_audio_zero_duration(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "audio", "src": "a.mp3", "duration": 0}
|
||||
]}}
|
||||
assert "0:00" in render_lexical(doc)
|
||||
|
||||
def test_video(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "video", "src": "v.mp4", "loop": True}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-video-card" in result
|
||||
assert " loop" in result
|
||||
|
||||
def test_file_size_kb(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
|
||||
"fileSize": 512000} # 500 KB
|
||||
]}}
|
||||
assert "500 KB" in render_lexical(doc)
|
||||
|
||||
def test_file_size_mb(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "file", "src": "f.zip", "fileName": "big.zip",
|
||||
"fileSize": 5242880} # 5 MB
|
||||
]}}
|
||||
assert "5.0 MB" in render_lexical(doc)
|
||||
|
||||
def test_paywall(self):
|
||||
doc = {"root": {"children": [{"type": "paywall"}]}}
|
||||
assert render_lexical(doc) == "<!--members-only-->"
|
||||
|
||||
def test_embed(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "embed", "html": "<iframe></iframe>",
|
||||
"caption": "Video"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-embed-card" in result
|
||||
assert "<figcaption>Video</figcaption>" in result
|
||||
|
||||
def test_bookmark(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "bookmark", "url": "https://example.com",
|
||||
"metadata": {"title": "Example", "description": "A site"}}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-bookmark-card" in result
|
||||
assert "Example" in result
|
||||
|
||||
def test_unknown_node_ignored(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "unknown-future-thing"}
|
||||
]}}
|
||||
assert render_lexical(doc) == ""
|
||||
|
||||
def test_product_stars(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "product", "productTitle": "Widget",
|
||||
"rating": 3, "productDescription": "Nice"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-product-card" in result
|
||||
assert result.count("kg-product-card-rating-active") == 3
|
||||
|
||||
def test_header_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "header", "heading": "Welcome",
|
||||
"size": "large", "style": "dark"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-header-card" in result
|
||||
assert "kg-size-large" in result
|
||||
assert "Welcome" in result
|
||||
|
||||
def test_signup_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "signup", "heading": "Subscribe",
|
||||
"buttonText": "Join", "style": "light"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-signup-card" in result
|
||||
assert "Join" in result
|
||||
83
blog/tests/test_lexical_validator.py
Normal file
83
blog/tests/test_lexical_validator.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for lexical document validator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.blog.ghost.lexical_validator import (
|
||||
validate_lexical, ALLOWED_NODE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateLexical:
|
||||
def test_valid_empty_doc(self):
|
||||
ok, reason = validate_lexical({"root": {"type": "root", "children": []}})
|
||||
assert ok is True
|
||||
assert reason is None
|
||||
|
||||
def test_non_dict_input(self):
|
||||
ok, reason = validate_lexical("not a dict")
|
||||
assert ok is False
|
||||
assert "JSON object" in reason
|
||||
|
||||
def test_list_input(self):
|
||||
ok, reason = validate_lexical([])
|
||||
assert ok is False
|
||||
|
||||
def test_missing_root(self):
|
||||
ok, reason = validate_lexical({"foo": "bar"})
|
||||
assert ok is False
|
||||
assert "'root'" in reason
|
||||
|
||||
def test_root_not_dict(self):
|
||||
ok, reason = validate_lexical({"root": "string"})
|
||||
assert ok is False
|
||||
|
||||
def test_valid_paragraph(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]}}
|
||||
ok, _ = validate_lexical(doc)
|
||||
assert ok is True
|
||||
|
||||
def test_disallowed_type(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "script"}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is False
|
||||
assert "Disallowed node type: script" in reason
|
||||
|
||||
def test_nested_disallowed_type(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "list", "children": [
|
||||
{"type": "evil-widget"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is False
|
||||
assert "evil-widget" in reason
|
||||
|
||||
def test_node_without_type_allowed(self):
|
||||
"""Nodes with type=None are allowed by _walk."""
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"children": []} # no "type" key
|
||||
]}}
|
||||
ok, _ = validate_lexical(doc)
|
||||
assert ok is True
|
||||
|
||||
def test_all_allowed_types(self):
|
||||
"""Every type in the allowlist should pass."""
|
||||
for node_type in ALLOWED_NODE_TYPES:
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": node_type, "children": []}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is True, f"{node_type} should be allowed but got: {reason}"
|
||||
|
||||
def test_allowed_types_count(self):
|
||||
"""Sanity: at least 30 types in the allowlist."""
|
||||
assert len(ALLOWED_NODE_TYPES) >= 30
|
||||
50
blog/tests/test_slugify.py
Normal file
50
blog/tests/test_slugify.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for blog slugify utility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.post.services.markets import slugify
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic_ascii(self):
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_unicode_stripped(self):
|
||||
assert slugify("café") == "cafe"
|
||||
|
||||
def test_slashes_to_dashes(self):
|
||||
assert slugify("foo/bar") == "foo-bar"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert slugify("foo!!bar") == "foo-bar"
|
||||
|
||||
def test_multiple_dashes_collapsed(self):
|
||||
assert slugify("foo---bar") == "foo-bar"
|
||||
|
||||
def test_leading_trailing_dashes_stripped(self):
|
||||
assert slugify("--foo--") == "foo"
|
||||
|
||||
def test_empty_string_fallback(self):
|
||||
assert slugify("") == "market"
|
||||
|
||||
def test_none_fallback(self):
|
||||
assert slugify(None) == "market"
|
||||
|
||||
def test_max_len_truncation(self):
|
||||
result = slugify("a" * 300, max_len=10)
|
||||
assert len(result) <= 10
|
||||
|
||||
def test_truncation_no_trailing_dash(self):
|
||||
# "abcde-fgh" truncated to 5 should not end with dash
|
||||
result = slugify("abcde fgh", max_len=5)
|
||||
assert not result.endswith("-")
|
||||
|
||||
def test_already_clean(self):
|
||||
assert slugify("hello-world") == "hello-world"
|
||||
|
||||
def test_numbers_preserved(self):
|
||||
assert slugify("item-42") == "item-42"
|
||||
|
||||
def test_accented_characters(self):
|
||||
assert slugify("über straße") == "uber-strae"
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,7 @@ from bp import (
|
||||
register_cart_overview,
|
||||
register_page_cart,
|
||||
register_cart_global,
|
||||
register_page_admin,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
@@ -194,6 +196,12 @@ def create_app() -> "Quart":
|
||||
url_prefix="/",
|
||||
)
|
||||
|
||||
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
|
||||
app.register_blueprint(
|
||||
register_page_admin(),
|
||||
url_prefix="/<page_slug>/admin",
|
||||
)
|
||||
|
||||
# Page cart at /<page_slug>/ (dynamic, matched last)
|
||||
app.register_blueprint(
|
||||
register_page_cart(url_prefix="/"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .cart.overview_routes import register as register_cart_overview
|
||||
from .cart.page_routes import register as register_page_cart
|
||||
from .cart.global_routes import register as register_cart_global
|
||||
from .page_admin.routes import register as register_page_admin
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
from .data import register_data
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
||||
from quart import Blueprint, g, request, redirect, url_for, make_response
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models.market import CartItem
|
||||
@@ -150,11 +150,10 @@ def register(url_prefix: str) -> Blueprint:
|
||||
try:
|
||||
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||
except ValueError as e:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=None,
|
||||
error=str(e),
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error=str(e))
|
||||
return await make_response(html, 400)
|
||||
|
||||
ident = current_cart_identity()
|
||||
@@ -208,11 +207,10 @@ def register(url_prefix: str) -> Blueprint:
|
||||
hosted_url = result.get("sumup_hosted_url")
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=None,
|
||||
error="No hosted checkout URL returned from SumUp.",
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
|
||||
@@ -14,18 +14,16 @@ def register(url_prefix: str) -> Blueprint:
|
||||
@bp.get("/")
|
||||
async def overview():
|
||||
from quart import g
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_overview_page, render_overview_oob
|
||||
|
||||
page_groups = await get_cart_grouped_by_page(g.s)
|
||||
ctx = await get_template_context()
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/cart/overview/index.html",
|
||||
page_groups=page_groups,
|
||||
)
|
||||
html = await render_overview_page(ctx, page_groups)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/cart/overview/_oob_elements.html",
|
||||
page_groups=page_groups,
|
||||
)
|
||||
html = await render_overview_oob(ctx, page_groups)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, render_template, redirect, make_response, url_for
|
||||
from quart import Blueprint, g, redirect, make_response, url_for
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.actions import call_action
|
||||
@@ -40,10 +40,20 @@ def register(url_prefix: str) -> Blueprint:
|
||||
ticket_total=ticket_total,
|
||||
)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_page_cart_page, render_page_cart_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/cart/page/index.html", **tpl_ctx)
|
||||
html = await render_page_cart_page(
|
||||
ctx, post, cart, cal_entries, page_tickets,
|
||||
ticket_groups, total, calendar_total, ticket_total,
|
||||
)
|
||||
else:
|
||||
html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
|
||||
html = await render_page_cart_oob(
|
||||
ctx, post, cart, cal_entries, page_tickets,
|
||||
ticket_groups, total, calendar_total, ticket_total,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/checkout/")
|
||||
@@ -99,11 +109,10 @@ def register(url_prefix: str) -> Blueprint:
|
||||
hosted_url = result.get("sumup_hosted_url")
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=None,
|
||||
error="No hosted checkout URL returned from SumUp.",
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
|
||||
@@ -10,7 +10,7 @@ Fragments:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request, render_template, g
|
||||
from quart import Blueprint, Response, request, g
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
@@ -24,6 +24,8 @@ def register():
|
||||
|
||||
async def _cart_mini():
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.urls import blog_url, cart_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
session_id = request.args.get("session_id")
|
||||
@@ -32,17 +34,19 @@ def register():
|
||||
g.s, user_id=user_id, session_id=session_id,
|
||||
)
|
||||
count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
return await render_template("fragments/cart_mini.html", cart_count=count)
|
||||
oob = request.args.get("oob", "")
|
||||
return render_sexp(
|
||||
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url :oob oob)',
|
||||
**{"cart-count": count, "blog-url": blog_url(""), "cart-url": cart_url(""), "oob": oob or None},
|
||||
)
|
||||
|
||||
async def _account_nav_item():
|
||||
from shared.infrastructure.urls import cart_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
href = cart_url("/orders/")
|
||||
return (
|
||||
'<div class="relative nav-group">'
|
||||
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
|
||||
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
|
||||
'orders</a></div>'
|
||||
return render_sexp(
|
||||
'(~account-nav-item :href href :label "orders")',
|
||||
href=cart_url("/orders/"),
|
||||
)
|
||||
|
||||
_handlers = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, render_template, redirect, url_for, make_response
|
||||
from quart import Blueprint, g, redirect, url_for, make_response
|
||||
from sqlalchemy import select, func, or_, cast, String, exists
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -55,12 +55,16 @@ def register() -> Blueprint:
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_order_page, render_order_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
calendar_entries = ctx.get("calendar_entries")
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/order/index.html", order=order,)
|
||||
html = await render_order_page(ctx, order, calendar_entries, url_for)
|
||||
else:
|
||||
# HTMX navigation (page 1): main panel + OOB elements
|
||||
html = await render_template("_types/order/_oob_elements.html", order=order,)
|
||||
html = await render_order_oob(ctx, order, calendar_entries, url_for)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -116,11 +120,10 @@ def register() -> Blueprint:
|
||||
await g.s.flush()
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=order,
|
||||
error="No hosted checkout URL returned from SumUp when trying to reopen payment.",
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_checkout_error_page
|
||||
tctx = await get_template_context()
|
||||
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
|
||||
@@ -136,24 +136,30 @@ def register(url_prefix: str) -> Blueprint:
|
||||
result = await g.s.execute(stmt)
|
||||
orders = result.scalars().all()
|
||||
|
||||
context = {
|
||||
"orders": orders,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"search": search,
|
||||
"search_count": total_count, # For search display
|
||||
}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import (
|
||||
render_orders_page,
|
||||
render_orders_rows,
|
||||
render_orders_oob,
|
||||
)
|
||||
|
||||
ctx = await get_template_context()
|
||||
qs_fn = makeqs_factory()
|
||||
|
||||
# Determine which template to use based on request type and pagination
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/orders/index.html", **context)
|
||||
html = await render_orders_page(
|
||||
ctx, orders, page, total_pages, search, total_count,
|
||||
url_for, qs_fn,
|
||||
)
|
||||
elif page > 1:
|
||||
# HTMX pagination: just table rows + sentinel
|
||||
html = await render_template("_types/orders/_rows.html", **context)
|
||||
html = await render_orders_rows(
|
||||
ctx, orders, page, total_pages, url_for, qs_fn,
|
||||
)
|
||||
else:
|
||||
# HTMX navigation (page 1): main panel + OOB elements
|
||||
html = await render_template("_types/orders/_oob_elements.html", **context)
|
||||
html = await render_orders_oob(
|
||||
ctx, orders, page, total_pages, search, total_count,
|
||||
url_for, qs_fn,
|
||||
)
|
||||
|
||||
resp = await make_response(html)
|
||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||
|
||||
0
cart/bp/page_admin/__init__.py
Normal file
0
cart/bp/page_admin/__init__.py
Normal file
83
cart/bp/page_admin/routes.py
Normal file
83
cart/bp/page_admin/routes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
make_response, Blueprint, g, request
|
||||
)
|
||||
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("page_admin", __name__)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(**kwargs):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_cart_admin_page, render_cart_admin_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not is_htmx_request():
|
||||
html = await render_cart_admin_page(ctx, page_post)
|
||||
else:
|
||||
html = await render_cart_admin_oob(ctx, page_post)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/payments/")
|
||||
@require_admin
|
||||
async def payments(**kwargs):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_cart_payments_page, render_cart_payments_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not is_htmx_request():
|
||||
html = await render_cart_payments_page(ctx, page_post)
|
||||
else:
|
||||
html = await render_cart_payments_oob(ctx, page_post)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.put("/payments/")
|
||||
@require_admin
|
||||
async def update_sumup(**kwargs):
|
||||
"""Update SumUp credentials for this page (writes to blog's db_blog)."""
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not page_post:
|
||||
return await make_response("Page not found", 404)
|
||||
|
||||
form = await request.form
|
||||
merchant_code = (form.get("merchant_code") or "").strip()
|
||||
api_key = (form.get("api_key") or "").strip()
|
||||
checkout_prefix = (form.get("checkout_prefix") or "").strip()
|
||||
|
||||
payload = {
|
||||
"container_type": "page",
|
||||
"container_id": page_post.id,
|
||||
"sumup_merchant_code": merchant_code,
|
||||
"sumup_checkout_prefix": checkout_prefix,
|
||||
}
|
||||
if api_key:
|
||||
payload["sumup_api_key"] = api_key
|
||||
|
||||
await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
# Re-fetch page config to get fresh data
|
||||
from types import SimpleNamespace
|
||||
raw_pc = await fetch_data(
|
||||
"blog", "page-config",
|
||||
params={"container_type": "page", "container_id": page_post.id},
|
||||
required=False,
|
||||
)
|
||||
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_cart_payments_panel
|
||||
ctx = await get_template_context()
|
||||
html = render_cart_payments_panel(ctx)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
0
cart/sexp/__init__.py
Normal file
0
cart/sexp/__init__.py
Normal file
12
cart/sexp/calendar.sexpr
Normal file
12
cart/sexp/calendar.sexpr
Normal file
@@ -0,0 +1,12 @@
|
||||
;; Cart calendar entry components
|
||||
|
||||
(defcomp ~cart-cal-entry (&key name date-str cost)
|
||||
(li :class "flex items-start justify-between text-sm"
|
||||
(div (div :class "font-medium" (raw! name))
|
||||
(div :class "text-xs text-stone-500" (raw! date-str)))
|
||||
(div :class "ml-4 font-medium" (raw! cost))))
|
||||
|
||||
(defcomp ~cart-cal-section (&key items-html)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2" "Calendar bookings")
|
||||
(ul :class "space-y-2" (raw! items-html))))
|
||||
20
cart/sexp/checkout.sexpr
Normal file
20
cart/sexp/checkout.sexpr
Normal file
@@ -0,0 +1,20 @@
|
||||
;; Cart checkout error components
|
||||
|
||||
(defcomp ~cart-checkout-error-filter ()
|
||||
(header :class "mb-6 sm:mb-8"
|
||||
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
|
||||
(p :class "text-xs sm:text-sm text-stone-600"
|
||||
"We tried to start your payment with SumUp but hit a problem.")))
|
||||
|
||||
(defcomp ~cart-checkout-error-order-id (&key order-id)
|
||||
(p :class "text-xs text-rose-800/80"
|
||||
"Order ID: " (span :class "font-mono" (raw! order-id))))
|
||||
|
||||
(defcomp ~cart-checkout-error-content (&key error-msg order-html back-url)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4"
|
||||
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
|
||||
(p :class "font-medium" "Something went wrong.")
|
||||
(p (raw! error-msg))
|
||||
(raw! order-html))
|
||||
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))
|
||||
44
cart/sexp/header.sexpr
Normal file
44
cart/sexp/header.sexpr
Normal file
@@ -0,0 +1,44 @@
|
||||
;; Cart header components
|
||||
|
||||
(defcomp ~cart-page-label-img (&key src)
|
||||
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~cart-all-carts-link (&key href)
|
||||
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
|
||||
|
||||
(defcomp ~cart-header-child (&key inner-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
|
||||
(defcomp ~cart-header-child-nested (&key outer-html inner-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! outer-html)
|
||||
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html))))
|
||||
|
||||
(defcomp ~cart-header-child-oob (&key inner-html)
|
||||
(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
|
||||
(defcomp ~cart-auth-header-child (&key auth-html orders-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! auth-html)
|
||||
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! orders-html))))
|
||||
|
||||
(defcomp ~cart-auth-header-child-oob (&key inner-html)
|
||||
(div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
|
||||
(defcomp ~cart-order-header-child (&key auth-html orders-html order-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! auth-html)
|
||||
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! orders-html)
|
||||
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! order-html)))))
|
||||
|
||||
(defcomp ~cart-orders-header-child-oob (&key inner-html)
|
||||
(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
66
cart/sexp/items.sexpr
Normal file
66
cart/sexp/items.sexpr
Normal file
@@ -0,0 +1,66 @@
|
||||
;; Cart item components
|
||||
|
||||
(defcomp ~cart-item-img (&key src alt)
|
||||
(img :src src :alt alt :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy"))
|
||||
|
||||
(defcomp ~cart-item-no-img ()
|
||||
(div :class "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
|
||||
"No image"))
|
||||
|
||||
(defcomp ~cart-item-price (&key text)
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
|
||||
|
||||
(defcomp ~cart-item-price-was (&key text)
|
||||
(p :class "text-xs text-stone-400 line-through" (raw! text)))
|
||||
|
||||
(defcomp ~cart-item-no-price ()
|
||||
(p :class "text-xs text-stone-500" "No price"))
|
||||
|
||||
(defcomp ~cart-item-deleted ()
|
||||
(p :class "mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"
|
||||
(i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")
|
||||
" This item is no longer available or price has changed"))
|
||||
|
||||
(defcomp ~cart-item-brand (&key brand)
|
||||
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! brand)))
|
||||
|
||||
(defcomp ~cart-item-line-total (&key text)
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
|
||||
|
||||
(defcomp ~cart-item (&key id img-html prod-url title brand-html deleted-html price-html qty-url csrf minus qty plus line-total-html)
|
||||
(article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
|
||||
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img-html))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
|
||||
(div :class "min-w-0"
|
||||
(h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900"
|
||||
(a :href prod-url :class "hover:text-emerald-700" (raw! title)))
|
||||
(raw! brand-html) (raw! deleted-html))
|
||||
(div :class "text-left sm:text-right" (raw! price-html)))
|
||||
(div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"
|
||||
(div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"
|
||||
(span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")
|
||||
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "count" :value minus)
|
||||
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))
|
||||
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty))
|
||||
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "count" :value plus)
|
||||
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
|
||||
(div :class "flex items-center justify-between sm:justify-end gap-3" (raw! line-total-html))))))
|
||||
|
||||
(defcomp ~cart-page-empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"
|
||||
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
|
||||
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))))
|
||||
|
||||
(defcomp ~cart-page-panel (&key items-html cal-html tickets-html summary-html)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div (section :class "space-y-3 sm:space-y-4" (raw! items-html) (raw! cal-html) (raw! tickets-html))
|
||||
(raw! summary-html)))))
|
||||
53
cart/sexp/order_detail.sexpr
Normal file
53
cart/sexp/order_detail.sexpr
Normal file
@@ -0,0 +1,53 @@
|
||||
;; Cart single order detail components
|
||||
|
||||
(defcomp ~cart-order-item-img (&key src alt)
|
||||
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
|
||||
|
||||
(defcomp ~cart-order-item-no-img ()
|
||||
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
|
||||
|
||||
(defcomp ~cart-order-item (&key prod-url img-html title product-id qty price)
|
||||
(li (a :class "w-full py-2 flex gap-3" :href prod-url
|
||||
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img-html))
|
||||
(div :class "flex-1 flex justify-between gap-3"
|
||||
(div (p :class "font-medium" (raw! title))
|
||||
(p :class "text-[11px] text-stone-500" (raw! product-id)))
|
||||
(div :class "text-right whitespace-nowrap"
|
||||
(p (raw! qty)) (p (raw! price)))))))
|
||||
|
||||
(defcomp ~cart-order-items-panel (&key items-html)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
|
||||
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
|
||||
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items-html))))
|
||||
|
||||
(defcomp ~cart-order-cal-entry (&key name pill status date-str cost)
|
||||
(li :class "px-4 py-3 flex items-start justify-between text-sm"
|
||||
(div (div :class "font-medium flex items-center gap-2"
|
||||
(raw! name) (span :class pill (raw! status)))
|
||||
(div :class "text-xs text-stone-500" (raw! date-str)))
|
||||
(div :class "ml-4 font-medium" (raw! cost))))
|
||||
|
||||
(defcomp ~cart-order-cal-section (&key items-html)
|
||||
(section :class "mt-6 space-y-3"
|
||||
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
|
||||
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items-html))))
|
||||
|
||||
(defcomp ~cart-order-main (&key summary-html items-html cal-html)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary-html) (raw! items-html) (raw! cal-html)))
|
||||
|
||||
(defcomp ~cart-order-pay-btn (&key url)
|
||||
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
|
||||
|
||||
(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay-html)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
(p :class "text-xs sm:text-sm text-stone-600" (raw! info)))
|
||||
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
|
||||
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
|
||||
(form :method "post" :action recheck-url :class "inline"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
|
||||
(raw! pay-html))))
|
||||
51
cart/sexp/orders.sexpr
Normal file
51
cart/sexp/orders.sexpr
Normal file
@@ -0,0 +1,51 @@
|
||||
;; Cart orders list components
|
||||
|
||||
(defcomp ~cart-order-row-desktop (&key order-id created desc total pill status detail-url)
|
||||
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
|
||||
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! order-id)))
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))
|
||||
(td :class "px-3 py-2 align-top" (span :class pill (raw! status)))
|
||||
(td :class "px-3 py-0.5 align-top text-right"
|
||||
(a :href detail-url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
|
||||
|
||||
(defcomp ~cart-order-row-mobile (&key order-id pill status created total detail-url)
|
||||
(tr :class "sm:hidden border-t border-stone-100"
|
||||
(td :colspan "5" :class "px-3 py-3"
|
||||
(div :class "flex flex-col gap-2 text-xs"
|
||||
(div :class "flex items-center justify-between gap-2"
|
||||
(span :class "font-mono text-[11px] text-stone-700" (raw! order-id))
|
||||
(span :class pill (raw! status)))
|
||||
(div :class "text-[11px] text-stone-500 break-words" (raw! created))
|
||||
(div :class "flex items-center justify-between gap-2"
|
||||
(div :class "font-medium text-stone-800" (raw! total))
|
||||
(a :href detail-url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
|
||||
|
||||
(defcomp ~cart-orders-end ()
|
||||
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results")))
|
||||
|
||||
(defcomp ~cart-orders-empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
|
||||
"No orders yet.")))
|
||||
|
||||
(defcomp ~cart-orders-table (&key rows-html)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
|
||||
(table :class "min-w-full text-xs sm:text-sm"
|
||||
(thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"
|
||||
(tr
|
||||
(th :class "px-3 py-2 text-left font-medium" "Order")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Created")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Description")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Total")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Status")
|
||||
(th :class "px-3 py-2 text-left font-medium")))
|
||||
(tbody (raw! rows-html))))))
|
||||
|
||||
(defcomp ~cart-orders-filter (&key search-mobile-html)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
|
||||
(div :class "md:hidden" (raw! search-mobile-html))))
|
||||
52
cart/sexp/overview.sexpr
Normal file
52
cart/sexp/overview.sexpr
Normal file
@@ -0,0 +1,52 @@
|
||||
;; Cart overview components
|
||||
|
||||
(defcomp ~cart-badge (&key icon text)
|
||||
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"
|
||||
(i :class icon :aria-hidden "true") (raw! text)))
|
||||
|
||||
(defcomp ~cart-badges-wrap (&key badges-html)
|
||||
(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600"
|
||||
(raw! badges-html)))
|
||||
|
||||
(defcomp ~cart-group-card-img (&key src alt)
|
||||
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
|
||||
|
||||
(defcomp ~cart-group-card-placeholder ()
|
||||
(div :class "h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"
|
||||
(i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true")))
|
||||
|
||||
(defcomp ~cart-mp-subtitle (&key title)
|
||||
(p :class "text-xs text-stone-500 truncate" (raw! title)))
|
||||
|
||||
(defcomp ~cart-group-card (&key href img-html display-title subtitle-html badges-html total)
|
||||
(a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
|
||||
(div :class "flex items-start gap-4"
|
||||
(raw! img-html)
|
||||
(div :class "flex-1 min-w-0"
|
||||
(h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" (raw! display-title))
|
||||
(raw! subtitle-html) (raw! badges-html))
|
||||
(div :class "text-right flex-shrink-0"
|
||||
(div :class "text-lg font-bold text-stone-900" (raw! total))
|
||||
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
|
||||
|
||||
(defcomp ~cart-orphan-card (&key badges-html total)
|
||||
(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"
|
||||
(div :class "flex items-start gap-4"
|
||||
(div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"
|
||||
(i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true"))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items")
|
||||
(raw! badges-html))
|
||||
(div :class "text-right flex-shrink-0"
|
||||
(div :class "text-lg font-bold text-stone-900" (raw! total))))))
|
||||
|
||||
(defcomp ~cart-empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"
|
||||
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
|
||||
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))
|
||||
|
||||
(defcomp ~cart-overview-panel (&key cards-html)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "space-y-4" (raw! cards-html))))
|
||||
21
cart/sexp/payments.sexpr
Normal file
21
cart/sexp/payments.sexpr
Normal file
@@ -0,0 +1,21 @@
|
||||
;; Cart payments components
|
||||
|
||||
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
|
||||
(section :class "p-4 max-w-lg mx-auto"
|
||||
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||
(h3 :class "text-lg font-semibold text-stone-800"
|
||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
||||
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
||||
(form :hx-put update-url :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
|
||||
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
|
||||
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
||||
"Save SumUp Settings")
|
||||
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
|
||||
(i :class "fa fa-check-circle") " Connected"))))))
|
||||
854
cart/sexp/sexp_components.py
Normal file
854
cart/sexp/sexp_components.py
Normal file
@@ -0,0 +1,854 @@
|
||||
"""
|
||||
Cart service s-expression page components.
|
||||
|
||||
Renders cart overview, page cart, orders list, and single order detail.
|
||||
Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, root_header_html, post_admin_header_html,
|
||||
post_header_html as _shared_post_header_html,
|
||||
search_desktop_html, search_mobile_html, full_page, oob_page,
|
||||
)
|
||||
from shared.infrastructure.urls import market_product_url, cart_url
|
||||
|
||||
# Load cart-specific .sexpr components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_html)."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
ctx = {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
return ctx
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav_html if not already present (for post header row)."""
|
||||
if ctx.get("container_nav_html"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
slug = post.get("slug", "")
|
||||
if not post_id:
|
||||
return ctx
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav_html": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build post-level header row from page_post DTO, using shared helper."""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return _shared_post_header_html(ctx, oob=oob)
|
||||
|
||||
|
||||
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the cart section header row."""
|
||||
return render(
|
||||
"menu-row",
|
||||
id="cart-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", "/"),
|
||||
link_label="cart", icon="fa fa-shopping-cart",
|
||||
child_id="cart-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build the per-page cart header row."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
title = ((page_post.title if page_post else None) or "")[:160]
|
||||
label_html = ""
|
||||
if page_post and page_post.feature_image:
|
||||
label_html += render("cart-page-label-img", src=page_post.feature_image)
|
||||
label_html += f"<span>{title}</span>"
|
||||
nav_html = render("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return render(
|
||||
"menu-row",
|
||||
id="page-cart-row", level=2, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||
link_label_html=label_html, nav_html=nav_html, oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row (for orders)."""
|
||||
return render(
|
||||
"menu-row",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _orders_header_html(ctx: dict, list_url: str) -> str:
|
||||
"""Build the orders section header row."""
|
||||
return render(
|
||||
"menu-row",
|
||||
id="orders-row", level=2, colour="sky",
|
||||
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
|
||||
child_id="orders-header-child",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _badge_html(icon: str, count: int, label: str) -> str:
|
||||
"""Render a count badge."""
|
||||
s = "s" if count != 1 else ""
|
||||
return render("cart-badge", icon=icon, text=f"{count} {label}{s}")
|
||||
|
||||
|
||||
def _page_group_card_html(grp: Any, ctx: dict) -> str:
|
||||
"""Render a single page group card for cart overview."""
|
||||
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||
product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
|
||||
calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
|
||||
ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
|
||||
total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
|
||||
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||
|
||||
if not cart_items and not cal_entries and not tickets:
|
||||
return ""
|
||||
|
||||
# Count badges
|
||||
badges = ""
|
||||
if product_count > 0:
|
||||
badges += _badge_html("fa fa-box-open", product_count, "item")
|
||||
if calendar_count > 0:
|
||||
badges += _badge_html("fa fa-calendar", calendar_count, "booking")
|
||||
if ticket_count > 0:
|
||||
badges += _badge_html("fa fa-ticket", ticket_count, "ticket")
|
||||
badges_html = render("cart-badges-wrap", badges_html=badges)
|
||||
|
||||
if post:
|
||||
slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
|
||||
title = post.title if hasattr(post, "title") else post.get("title", "")
|
||||
feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
|
||||
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
||||
|
||||
if feature_image:
|
||||
img = render("cart-group-card-img", src=feature_image, alt=title)
|
||||
else:
|
||||
img = render("cart-group-card-placeholder")
|
||||
|
||||
mp_sub = ""
|
||||
if market_place:
|
||||
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
|
||||
mp_sub = render("cart-mp-subtitle", title=title)
|
||||
else:
|
||||
mp_name = ""
|
||||
display_title = mp_name or title
|
||||
|
||||
return render(
|
||||
"cart-group-card",
|
||||
href=cart_href, img_html=img, display_title=display_title,
|
||||
subtitle_html=mp_sub, badges_html=badges_html,
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
else:
|
||||
# Orphan items — use amber badges
|
||||
badges_amber = badges.replace("bg-stone-100", "bg-amber-100")
|
||||
badges_html_amber = render("cart-badges-wrap", badges_html=badges_amber)
|
||||
return render(
|
||||
"cart-orphan-card",
|
||||
badges_html=badges_html_amber,
|
||||
total=f"\u00a3{total:.2f}",
|
||||
)
|
||||
|
||||
|
||||
def _empty_cart_html() -> str:
|
||||
"""Empty cart state."""
|
||||
return render("cart-empty")
|
||||
|
||||
|
||||
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
|
||||
"""Cart overview main panel."""
|
||||
if not page_groups:
|
||||
return _empty_cart_html()
|
||||
|
||||
cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
|
||||
has_items = any(c for c in cards)
|
||||
if not has_items:
|
||||
return _empty_cart_html()
|
||||
|
||||
return render("cart-overview-panel", cards_html="".join(cards))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_item_html(item: Any, ctx: dict) -> str:
|
||||
"""Render a single product cart item."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
p = item.product if hasattr(item, "product") else item
|
||||
slug = p.slug if hasattr(p, "slug") else ""
|
||||
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||
symbol = "\u00a3" if currency == "GBP" else currency
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
|
||||
prod_url = market_product_url(slug)
|
||||
|
||||
if p.image:
|
||||
img = render("cart-item-img", src=p.image, alt=p.title)
|
||||
else:
|
||||
img = render("cart-item-no-img")
|
||||
|
||||
price_html = ""
|
||||
if unit_price:
|
||||
price_html = render("cart-item-price", text=f"{symbol}{unit_price:.2f}")
|
||||
if p.special_price and p.special_price != p.regular_price:
|
||||
price_html += render("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}")
|
||||
else:
|
||||
price_html = render("cart-item-no-price")
|
||||
|
||||
deleted_html = ""
|
||||
if getattr(item, "is_deleted", False):
|
||||
deleted_html = render("cart-item-deleted")
|
||||
|
||||
brand_html = ""
|
||||
if getattr(p, "brand", None):
|
||||
brand_html = render("cart-item-brand", brand=p.brand)
|
||||
|
||||
line_total_html = ""
|
||||
if unit_price:
|
||||
lt = unit_price * item.quantity
|
||||
line_total_html = render("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
|
||||
|
||||
return render(
|
||||
"cart-item",
|
||||
id=f"cart-item-{slug}", img_html=img, prod_url=prod_url, title=p.title,
|
||||
brand_html=brand_html, deleted_html=deleted_html, price_html=price_html,
|
||||
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
|
||||
qty=str(item.quantity), plus=str(item.quantity + 1),
|
||||
line_total_html=line_total_html,
|
||||
)
|
||||
|
||||
|
||||
def _calendar_entries_html(entries: list) -> str:
|
||||
"""Render calendar booking entries in cart."""
|
||||
if not entries:
|
||||
return ""
|
||||
items = ""
|
||||
for e in entries:
|
||||
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||
start = e.start_at if hasattr(e, "start_at") else ""
|
||||
end = getattr(e, "end_at", None)
|
||||
cost = getattr(e, "cost", 0) or 0
|
||||
end_str = f" \u2013 {end}" if end else ""
|
||||
items += render(
|
||||
"cart-cal-entry",
|
||||
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
|
||||
)
|
||||
return render("cart-cal-section", items_html=items)
|
||||
|
||||
|
||||
def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
|
||||
"""Render ticket groups in cart."""
|
||||
if not ticket_groups:
|
||||
return ""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
qty_url = url_for("cart_global.update_ticket_quantity")
|
||||
items = ""
|
||||
|
||||
for tg in ticket_groups:
|
||||
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||
|
||||
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||
if end_at:
|
||||
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
|
||||
tt_name_html = render("cart-ticket-type-name", name=tt_name) if tt_name else ""
|
||||
tt_hidden = render("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else ""
|
||||
|
||||
items += render(
|
||||
"cart-ticket-article",
|
||||
name=name, type_name_html=tt_name_html, date_str=date_str,
|
||||
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
|
||||
entry_id=str(entry_id), type_hidden_html=tt_hidden,
|
||||
minus=str(max(quantity - 1, 0)), qty=str(quantity),
|
||||
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
|
||||
)
|
||||
|
||||
return render("cart-tickets-section", items_html=items)
|
||||
|
||||
|
||||
def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
|
||||
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""Render the order summary sidebar."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g, url_for, request
|
||||
from shared.infrastructure.urls import login_url
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||
ticket_qty = len(tickets) if tickets else 0
|
||||
item_count = product_qty + ticket_qty
|
||||
|
||||
product_total = total_fn(cart) or 0
|
||||
cal_total = cal_total_fn(cal_entries) or 0
|
||||
tk_total = ticket_total_fn(tickets) or 0
|
||||
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||
|
||||
symbol = "\u00a3"
|
||||
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||
cur = cart[0].product.regular_price_currency
|
||||
symbol = "\u00a3" if cur == "GBP" else cur
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
page_post = ctx.get("page_post")
|
||||
|
||||
if user:
|
||||
if page_post:
|
||||
action = url_for("page_cart.page_checkout")
|
||||
else:
|
||||
action = url_for("cart_global.checkout")
|
||||
from shared.utils import route_prefix
|
||||
action = route_prefix() + action
|
||||
checkout_html = render(
|
||||
"cart-checkout-form",
|
||||
action=action, csrf=csrf, label=f" Checkout as {user.email}",
|
||||
)
|
||||
else:
|
||||
href = login_url(request.url)
|
||||
checkout_html = render("cart-checkout-signin", href=href)
|
||||
|
||||
return render(
|
||||
"cart-summary-panel",
|
||||
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
|
||||
checkout_html=checkout_html,
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
|
||||
tickets: list, ticket_groups: list,
|
||||
total_fn: Any, cal_total_fn: Any,
|
||||
ticket_total_fn: Any) -> str:
|
||||
"""Page cart main panel."""
|
||||
if not cart and not cal_entries and not tickets:
|
||||
return render("cart-page-empty")
|
||||
|
||||
items_html = "".join(_cart_item_html(item, ctx) for item in cart)
|
||||
cal_html = _calendar_entries_html(cal_entries)
|
||||
tickets_html = _ticket_groups_html(ticket_groups, ctx)
|
||||
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
|
||||
|
||||
return render(
|
||||
"cart-page-panel",
|
||||
items_html=items_html, cal_html=cal_html,
|
||||
tickets_html=tickets_html, summary_html=summary_html,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orders list (same pattern as orders service)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_row_html(order: Any, detail_url: str) -> str:
|
||||
"""Render a single order as desktop table row + mobile card."""
|
||||
status = order.status or "pending"
|
||||
sl = status.lower()
|
||||
pill = (
|
||||
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
|
||||
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
|
||||
else "border-stone-300 bg-stone-50 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
|
||||
|
||||
desktop = render(
|
||||
"cart-order-row-desktop",
|
||||
order_id=f"#{order.id}", created=created, desc=order.description or "",
|
||||
total=total, pill=pill_cls, status=status, detail_url=detail_url,
|
||||
)
|
||||
|
||||
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
|
||||
mobile = render(
|
||||
"cart-order-row-mobile",
|
||||
order_id=f"#{order.id}", pill=mobile_pill, status=status,
|
||||
created=created, total=total, detail_url=detail_url,
|
||||
)
|
||||
|
||||
return desktop + mobile
|
||||
|
||||
|
||||
def _orders_rows_html(orders: list, page: int, total_pages: int,
|
||||
url_for_fn: Any, qs_fn: Any) -> str:
|
||||
"""Render order rows + infinite scroll sentinel."""
|
||||
from shared.utils import route_prefix
|
||||
pfx = route_prefix()
|
||||
|
||||
parts = [
|
||||
_order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
|
||||
for o in orders
|
||||
]
|
||||
|
||||
if page < total_pages:
|
||||
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
|
||||
parts.append(render(
|
||||
"infinite-scroll",
|
||||
url=next_url, page=page, total_pages=total_pages,
|
||||
id_prefix="orders", colspan=5,
|
||||
))
|
||||
else:
|
||||
parts.append(render("cart-orders-end"))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
|
||||
"""Main panel for orders list."""
|
||||
if not orders:
|
||||
return render("cart-orders-empty")
|
||||
return render("cart-orders-table", rows_html=rows_html)
|
||||
|
||||
|
||||
def _orders_summary_html(ctx: dict) -> str:
|
||||
"""Filter section for orders list."""
|
||||
return render("cart-orders-filter", search_mobile_html=search_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _order_items_html(order: Any) -> str:
|
||||
"""Render order items list."""
|
||||
if not order or not order.items:
|
||||
return ""
|
||||
items = ""
|
||||
for item in order.items:
|
||||
prod_url = market_product_url(item.product_slug)
|
||||
if item.product_image:
|
||||
img = render(
|
||||
"cart-order-item-img",
|
||||
src=item.product_image, alt=item.product_title or "Product image",
|
||||
)
|
||||
else:
|
||||
img = render("cart-order-item-no-img")
|
||||
items += render(
|
||||
"cart-order-item",
|
||||
prod_url=prod_url, img_html=img,
|
||||
title=item.product_title or "Unknown product",
|
||||
product_id=f"Product ID: {item.product_id}",
|
||||
qty=f"Qty: {item.quantity}",
|
||||
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
|
||||
)
|
||||
return render("cart-order-items-panel", items_html=items)
|
||||
|
||||
|
||||
def _order_summary_html(order: Any) -> str:
|
||||
"""Order summary card."""
|
||||
return render(
|
||||
"order-summary-card",
|
||||
order_id=order.id,
|
||||
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||
description=order.description, status=order.status, currency=order.currency,
|
||||
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_calendar_items_html(calendar_entries: list | None) -> str:
|
||||
"""Render calendar bookings for an order."""
|
||||
if not calendar_entries:
|
||||
return ""
|
||||
items = ""
|
||||
for e in calendar_entries:
|
||||
st = e.state or ""
|
||||
pill = (
|
||||
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||
else "bg-stone-100 text-stone-700"
|
||||
)
|
||||
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
|
||||
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||
if e.end_at:
|
||||
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||
items += render(
|
||||
"cart-order-cal-entry",
|
||||
name=e.name, pill=pill_cls, status=st.capitalize(),
|
||||
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
|
||||
)
|
||||
return render("cart-order-cal-section", items_html=items)
|
||||
|
||||
|
||||
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
|
||||
"""Main panel for single order detail."""
|
||||
summary = _order_summary_html(order)
|
||||
return render(
|
||||
"cart-order-main",
|
||||
summary_html=summary, items_html=_order_items_html(order),
|
||||
cal_html=_order_calendar_items_html(calendar_entries),
|
||||
)
|
||||
|
||||
|
||||
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
|
||||
pay_url: str, csrf_token: str) -> str:
|
||||
"""Filter section for single order detail."""
|
||||
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||
status = order.status or "pending"
|
||||
|
||||
pay = ""
|
||||
if status != "paid":
|
||||
pay = render("cart-order-pay-btn", url=pay_url)
|
||||
|
||||
return render(
|
||||
"cart-order-filter",
|
||||
info=f"Placed {created} \u00b7 Status: {status}",
|
||||
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, pay_html=pay,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_overview_page(ctx: dict, page_groups: list) -> str:
|
||||
"""Full page: cart overview."""
|
||||
main = _overview_main_panel_html(page_groups, ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=main)
|
||||
|
||||
|
||||
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
|
||||
"""OOB response for cart overview."""
|
||||
main = _overview_main_panel_html(page_groups, ctx)
|
||||
oobs = root_header_html(ctx, oob=True)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Page cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_page_cart_page(ctx: dict, page_post: Any,
|
||||
cart: list, cal_entries: list, tickets: list,
|
||||
ticket_groups: list, total_fn: Any,
|
||||
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""Full page: page-specific cart."""
|
||||
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||
total_fn, cal_total_fn, ticket_total_fn)
|
||||
hdr = root_header_html(ctx)
|
||||
child = _cart_header_html(ctx)
|
||||
page_hdr = _page_cart_header_html(ctx, page_post)
|
||||
hdr += render(
|
||||
"cart-header-child-nested",
|
||||
outer_html=child, inner_html=page_hdr,
|
||||
)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=main)
|
||||
|
||||
|
||||
async def render_page_cart_oob(ctx: dict, page_post: Any,
|
||||
cart: list, cal_entries: list, tickets: list,
|
||||
ticket_groups: list, total_fn: Any,
|
||||
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""OOB response for page cart."""
|
||||
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||
total_fn, cal_total_fn, ticket_total_fn)
|
||||
oobs = (
|
||||
render("cart-header-child-oob", inner_html=_page_cart_header_html(ctx, page_post))
|
||||
+ _cart_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Orders list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_orders_page(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Full page: orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_html(orders, rows)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render(
|
||||
"cart-auth-header-child",
|
||||
auth_html=_auth_header_html(ctx),
|
||||
orders_html=_orders_header_html(ctx, list_url),
|
||||
)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
filter_html=_orders_summary_html(ctx),
|
||||
aside_html=search_desktop_html(ctx),
|
||||
content_html=main)
|
||||
|
||||
|
||||
async def render_orders_rows(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""Pagination: just the table rows."""
|
||||
return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
|
||||
|
||||
async def render_orders_oob(ctx: dict, orders: list, page: int,
|
||||
total_pages: int, search: str | None,
|
||||
search_count: int, url_for_fn: Any,
|
||||
qs_fn: Any) -> str:
|
||||
"""OOB response for orders list."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx["search"] = search
|
||||
ctx["search_count"] = search_count
|
||||
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||
|
||||
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||
main = _orders_main_panel_html(orders, rows)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ render(
|
||||
"cart-auth-header-child-oob",
|
||||
inner_html=_orders_header_html(ctx, list_url),
|
||||
)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
filter_html=_orders_summary_html(ctx),
|
||||
aside_html=search_desktop_html(ctx),
|
||||
content_html=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Single order detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_order_page(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""Full page: single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
main = _order_main_html(order, calendar_entries)
|
||||
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
order_row = render(
|
||||
"menu-row",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
)
|
||||
hdr += render(
|
||||
"cart-order-header-child",
|
||||
auth_html=_auth_header_html(ctx),
|
||||
orders_html=_orders_header_html(ctx, list_url),
|
||||
order_html=order_row,
|
||||
)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
|
||||
|
||||
|
||||
async def render_order_oob(ctx: dict, order: Any,
|
||||
calendar_entries: list | None,
|
||||
url_for_fn: Any) -> str:
|
||||
"""OOB response for single order detail."""
|
||||
from shared.utils import route_prefix
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
pfx = route_prefix()
|
||||
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||
|
||||
main = _order_main_html(order, calendar_entries)
|
||||
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||
|
||||
order_row_oob = render(
|
||||
"menu-row",
|
||||
id="order-row", level=3, colour="sky",
|
||||
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
|
||||
oob=True,
|
||||
)
|
||||
oobs = (
|
||||
render("cart-orders-header-child-oob", inner_html=order_row_oob)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Checkout error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _checkout_error_filter_html() -> str:
|
||||
return render("cart-checkout-error-filter")
|
||||
|
||||
|
||||
def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_html = ""
|
||||
if order:
|
||||
order_html = render("cart-checkout-error-order-id", order_id=f"#{order.id}")
|
||||
back_url = cart_url("/")
|
||||
return render(
|
||||
"cart-checkout-error-content",
|
||||
error_msg=err_msg, order_html=order_html, back_url=back_url,
|
||||
)
|
||||
|
||||
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
|
||||
"""Full page: checkout error."""
|
||||
hdr = root_header_html(ctx)
|
||||
filt = _checkout_error_filter_html()
|
||||
content = _checkout_error_content_html(error, order)
|
||||
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin (/<page_slug>/admin/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Build the page-level admin header row — delegates to shared helper."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _cart_admin_main_panel_html(ctx: dict) -> str:
|
||||
"""Admin overview panel — links to sub-admin pages."""
|
||||
from quart import url_for
|
||||
payments_href = url_for("page_admin.payments")
|
||||
return (
|
||||
'<div id="main-panel">'
|
||||
'<div class="flex items-center justify-between p-3 border-b">'
|
||||
'<span class="font-medium"><i class="fa fa-credit-card text-purple-600 mr-1"></i> Payments</span>'
|
||||
f'<a href="{payments_href}" class="text-sm underline">configure</a>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _cart_payments_main_panel_html(ctx: dict) -> str:
|
||||
"""Render SumUp payment config form."""
|
||||
from quart import url_for
|
||||
csrf_token = ctx.get("csrf_token")
|
||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||
page_config = ctx.get("page_config")
|
||||
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
|
||||
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
|
||||
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
|
||||
update_url = url_for("page_admin.update_sumup")
|
||||
|
||||
placeholder = "--------" if sumup_configured else "sup_sk_..."
|
||||
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
|
||||
|
||||
return render("cart-payments-panel",
|
||||
update_url=update_url, csrf=csrf,
|
||||
merchant_code=merchant_code, placeholder=placeholder,
|
||||
input_cls=input_cls, sumup_configured=sumup_configured,
|
||||
checkout_prefix=checkout_prefix)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart page admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_html(ctx)
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = await _post_header_html(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
|
||||
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_html(ctx)
|
||||
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart payments admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: payments config."""
|
||||
content = _cart_payments_main_panel_html(ctx)
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = await _post_header_html(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post, selected="payments")
|
||||
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: payments config."""
|
||||
content = _cart_payments_main_panel_html(ctx)
|
||||
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True, selected="payments")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
def render_cart_payments_panel(ctx: dict) -> str:
|
||||
"""Render the payments config panel for PUT response."""
|
||||
return _cart_payments_main_panel_html(ctx)
|
||||
26
cart/sexp/summary.sexpr
Normal file
26
cart/sexp/summary.sexpr
Normal file
@@ -0,0 +1,26 @@
|
||||
;; Cart summary / checkout components
|
||||
|
||||
(defcomp ~cart-checkout-form (&key action csrf label)
|
||||
(form :method "post" :action action :class "w-full"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") (raw! label))))
|
||||
|
||||
(defcomp ~cart-checkout-signin (&key href)
|
||||
(div :class "w-full flex"
|
||||
(a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
|
||||
(i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
|
||||
|
||||
(defcomp ~cart-summary-panel (&key item-count subtotal checkout-html)
|
||||
(aside :id "cart-summary" :class "lg:pl-2"
|
||||
(div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"
|
||||
(h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")
|
||||
(dl :class "space-y-2 text-xs sm:text-sm"
|
||||
(div :class "flex items-center justify-between"
|
||||
(dt :class "text-stone-600" "Items") (dd :class "text-stone-900" (raw! item-count)))
|
||||
(div :class "flex items-center justify-between"
|
||||
(dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" (raw! subtotal))))
|
||||
(div :class "flex flex-col items-center w-full"
|
||||
(h1 :class "text-5xl mt-2" "This is a test - it will not take actual money")
|
||||
(div "use dummy card number: 5555 5555 5555 4444"))
|
||||
(div :class "mt-4 sm:mt-5" (raw! checkout-html)))))
|
||||
42
cart/sexp/tickets.sexpr
Normal file
42
cart/sexp/tickets.sexpr
Normal file
@@ -0,0 +1,42 @@
|
||||
;; Cart ticket components
|
||||
|
||||
(defcomp ~cart-ticket-type-name (&key name)
|
||||
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! name)))
|
||||
|
||||
(defcomp ~cart-ticket-type-hidden (&key value)
|
||||
(input :type "hidden" :name "ticket_type_id" :value value))
|
||||
|
||||
(defcomp ~cart-ticket-article (&key name type-name-html date-str price qty-url csrf entry-id type-hidden-html minus qty plus line-total)
|
||||
(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
|
||||
(div :class "min-w-0"
|
||||
(h3 :class "text-sm sm:text-base font-semibold text-stone-900" (raw! name))
|
||||
(raw! type-name-html)
|
||||
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! date-str)))
|
||||
(div :class "text-left sm:text-right"
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! price))))
|
||||
(div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"
|
||||
(div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"
|
||||
(span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")
|
||||
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "entry_id" :value entry-id)
|
||||
(raw! type-hidden-html)
|
||||
(input :type "hidden" :name "count" :value minus)
|
||||
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))
|
||||
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty))
|
||||
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "entry_id" :value entry-id)
|
||||
(raw! type-hidden-html)
|
||||
(input :type "hidden" :name "count" :value plus)
|
||||
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
|
||||
(div :class "flex items-center justify-between sm:justify-end gap-3"
|
||||
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! line-total)))))))
|
||||
|
||||
(defcomp ~cart-tickets-section (&key items-html)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2"
|
||||
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")
|
||||
(div :class "space-y-3" (raw! items-html))))
|
||||
@@ -1,27 +0,0 @@
|
||||
<div id="cart-mini">
|
||||
{% if cart_count == 0 %}
|
||||
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||
<a
|
||||
href="{{ blog_url('/') }}"
|
||||
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||
>
|
||||
<img
|
||||
src="{{ blog_url('/static/img/logo.jpg') }}"
|
||||
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a
|
||||
href="{{ cart_url('/') }}"
|
||||
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||
>
|
||||
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
|
||||
<span
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
>
|
||||
{{ cart_count }}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
0
cart/tests/__init__.py
Normal file
0
cart/tests/__init__.py
Normal file
59
cart/tests/test_calendar_cart.py
Normal file
59
cart/tests/test_calendar_cart.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Unit tests for calendar/ticket total functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.calendar_cart import calendar_total, ticket_total
|
||||
|
||||
|
||||
def _entry(cost):
|
||||
return SimpleNamespace(cost=cost)
|
||||
|
||||
|
||||
def _ticket(price):
|
||||
return SimpleNamespace(price=price)
|
||||
|
||||
|
||||
class TestCalendarTotal:
|
||||
def test_empty(self):
|
||||
assert calendar_total([]) == 0
|
||||
|
||||
def test_single_entry(self):
|
||||
assert calendar_total([_entry(25.0)]) == Decimal("25.0")
|
||||
|
||||
def test_none_cost_excluded(self):
|
||||
result = calendar_total([_entry(None)])
|
||||
assert result == 0
|
||||
|
||||
def test_zero_cost(self):
|
||||
# cost=0 is falsy, so it produces Decimal(0) via the else branch
|
||||
result = calendar_total([_entry(0)])
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_multiple(self):
|
||||
result = calendar_total([_entry(10.0), _entry(20.0)])
|
||||
assert result == Decimal("30.0")
|
||||
|
||||
|
||||
class TestTicketTotal:
|
||||
def test_empty(self):
|
||||
assert ticket_total([]) == Decimal("0")
|
||||
|
||||
def test_single_ticket(self):
|
||||
assert ticket_total([_ticket(15.0)]) == Decimal("15.0")
|
||||
|
||||
def test_none_price_treated_as_zero(self):
|
||||
# ticket_total includes all tickets, None → Decimal(0)
|
||||
result = ticket_total([_ticket(None)])
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_multiple(self):
|
||||
result = ticket_total([_ticket(5.0), _ticket(10.0)])
|
||||
assert result == Decimal("15.0")
|
||||
|
||||
def test_mixed_with_none(self):
|
||||
result = ticket_total([_ticket(10.0), _ticket(None), _ticket(5.0)])
|
||||
assert result == Decimal("15.0")
|
||||
139
cart/tests/test_checkout.py
Normal file
139
cart/tests/test_checkout.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for cart checkout helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.checkout import (
|
||||
build_sumup_description,
|
||||
build_sumup_reference,
|
||||
build_webhook_url,
|
||||
validate_webhook_secret,
|
||||
)
|
||||
|
||||
|
||||
def _ci(title=None, qty=1):
|
||||
return SimpleNamespace(product_title=title, quantity=qty)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_sumup_description
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildSumupDescription:
|
||||
def test_empty_cart_no_tickets(self):
|
||||
result = build_sumup_description([], 1)
|
||||
assert "Order 1" in result
|
||||
assert "0 items" in result
|
||||
assert "order items" in result
|
||||
|
||||
def test_single_item(self):
|
||||
result = build_sumup_description([_ci("Widget", 1)], 42)
|
||||
assert "Order 42" in result
|
||||
assert "1 item)" in result
|
||||
assert "Widget" in result
|
||||
|
||||
def test_quantity_counted(self):
|
||||
result = build_sumup_description([_ci("Widget", 3)], 1)
|
||||
assert "3 items" in result
|
||||
|
||||
def test_three_titles(self):
|
||||
items = [_ci("A"), _ci("B"), _ci("C")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "A, B, C" in result
|
||||
assert "more" not in result
|
||||
|
||||
def test_four_titles_truncated(self):
|
||||
items = [_ci("A"), _ci("B"), _ci("C"), _ci("D")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "A, B, C" in result
|
||||
assert "+ 1 more" in result
|
||||
|
||||
def test_none_titles_excluded(self):
|
||||
items = [_ci(None), _ci("Visible")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "Visible" in result
|
||||
|
||||
def test_tickets_singular(self):
|
||||
result = build_sumup_description([], 1, ticket_count=1)
|
||||
assert "1 ticket" in result
|
||||
assert "tickets" not in result
|
||||
|
||||
def test_tickets_plural(self):
|
||||
result = build_sumup_description([], 1, ticket_count=3)
|
||||
assert "3 tickets" in result
|
||||
|
||||
def test_mixed_items_and_tickets(self):
|
||||
result = build_sumup_description([_ci("A", 2)], 1, ticket_count=1)
|
||||
assert "3 items" in result # 2 + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_sumup_reference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildSumupReference:
|
||||
def test_with_page_config(self):
|
||||
pc = SimpleNamespace(sumup_checkout_prefix="SHOP-")
|
||||
assert build_sumup_reference(42, pc) == "SHOP-42"
|
||||
|
||||
def test_without_page_config(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"checkout_reference_prefix": "RA-"}}):
|
||||
assert build_sumup_reference(99) == "RA-99"
|
||||
|
||||
def test_no_prefix(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert build_sumup_reference(1) == "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_webhook_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildWebhookUrl:
|
||||
def test_no_secret(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert build_webhook_url("https://x.com/hook") == "https://x.com/hook"
|
||||
|
||||
def test_with_secret_no_query(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "s3cret"}}):
|
||||
result = build_webhook_url("https://x.com/hook")
|
||||
assert "?token=s3cret" in result
|
||||
|
||||
def test_with_secret_existing_query(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "s3cret"}}):
|
||||
result = build_webhook_url("https://x.com/hook?a=1")
|
||||
assert "&token=s3cret" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_webhook_secret
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateWebhookSecret:
|
||||
def test_no_secret_configured(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert validate_webhook_secret(None) is True
|
||||
|
||||
def test_correct_token(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret("abc") is True
|
||||
|
||||
def test_wrong_token(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret("wrong") is False
|
||||
|
||||
def test_none_token_with_secret(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret(None) is False
|
||||
77
cart/tests/test_ticket_groups.py
Normal file
77
cart/tests/test_ticket_groups.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for ticket grouping logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.ticket_groups import group_tickets
|
||||
|
||||
|
||||
def _ticket(entry_id=1, entry_name="Event", ticket_type_id=None,
|
||||
ticket_type_name=None, price=10.0,
|
||||
entry_start_at=None, entry_end_at=None):
|
||||
return SimpleNamespace(
|
||||
entry_id=entry_id,
|
||||
entry_name=entry_name,
|
||||
entry_start_at=entry_start_at or datetime(2025, 6, 1, 10, 0),
|
||||
entry_end_at=entry_end_at,
|
||||
ticket_type_id=ticket_type_id,
|
||||
ticket_type_name=ticket_type_name,
|
||||
price=price,
|
||||
)
|
||||
|
||||
|
||||
class TestGroupTickets:
|
||||
def test_empty(self):
|
||||
assert group_tickets([]) == []
|
||||
|
||||
def test_single_ticket(self):
|
||||
result = group_tickets([_ticket()])
|
||||
assert len(result) == 1
|
||||
assert result[0]["quantity"] == 1
|
||||
assert result[0]["line_total"] == 10.0
|
||||
|
||||
def test_same_group_merged(self):
|
||||
tickets = [_ticket(entry_id=1), _ticket(entry_id=1)]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 1
|
||||
assert result[0]["quantity"] == 2
|
||||
assert result[0]["line_total"] == 20.0
|
||||
|
||||
def test_different_entries_separate(self):
|
||||
tickets = [_ticket(entry_id=1), _ticket(entry_id=2)]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_different_ticket_types_separate(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=1, ticket_type_id=1, ticket_type_name="Adult"),
|
||||
_ticket(entry_id=1, ticket_type_id=2, ticket_type_name="Child"),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_none_price(self):
|
||||
result = group_tickets([_ticket(price=None)])
|
||||
assert result[0]["line_total"] == 0.0
|
||||
|
||||
def test_ordering_preserved(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=2, entry_name="Second"),
|
||||
_ticket(entry_id=1, entry_name="First"),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert result[0]["entry_name"] == "Second"
|
||||
assert result[1]["entry_name"] == "First"
|
||||
|
||||
def test_metadata_from_first_ticket(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=1, entry_name="A", price=5.0),
|
||||
_ticket(entry_id=1, entry_name="B", price=10.0),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert result[0]["entry_name"] == "A" # from first
|
||||
assert result[0]["price"] == 5.0 # from first
|
||||
assert result[0]["line_total"] == 15.0 # accumulated
|
||||
47
cart/tests/test_total.py
Normal file
47
cart/tests/test_total.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for cart total calculations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.total import total
|
||||
|
||||
|
||||
def _item(special=None, regular=None, qty=1):
|
||||
return SimpleNamespace(
|
||||
product_special_price=special,
|
||||
product_regular_price=regular,
|
||||
quantity=qty,
|
||||
)
|
||||
|
||||
|
||||
class TestTotal:
|
||||
def test_empty_cart(self):
|
||||
assert total([]) == 0
|
||||
|
||||
def test_regular_price_only(self):
|
||||
result = total([_item(regular=10.0, qty=2)])
|
||||
assert result == Decimal("20.0")
|
||||
|
||||
def test_special_price_preferred(self):
|
||||
result = total([_item(special=5.0, regular=10.0, qty=1)])
|
||||
assert result == Decimal("5.0")
|
||||
|
||||
def test_none_prices_excluded(self):
|
||||
result = total([_item(special=None, regular=None, qty=1)])
|
||||
assert result == 0
|
||||
|
||||
def test_mixed_items(self):
|
||||
items = [
|
||||
_item(special=5.0, qty=2), # 10
|
||||
_item(regular=3.0, qty=3), # 9
|
||||
_item(), # excluded
|
||||
]
|
||||
result = total(items)
|
||||
assert result == Decimal("19.0")
|
||||
|
||||
def test_quantity_multiplication(self):
|
||||
result = total([_item(regular=7.5, qty=4)])
|
||||
assert result == Decimal("30.0")
|
||||
13
deploy.sh
13
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"
|
||||
APPS="blog market cart events federation account relations likes orders test"
|
||||
|
||||
usage() {
|
||||
echo "Usage: deploy.sh [app ...]"
|
||||
@@ -44,6 +44,17 @@ fi
|
||||
echo "Building: ${BUILD[*]}"
|
||||
echo ""
|
||||
|
||||
# --- Run unit tests before deploying ---
|
||||
echo "=== Running unit tests ==="
|
||||
docker build -f test/Dockerfile.unit -t rose-ash-test-unit:latest . -q
|
||||
if ! docker run --rm rose-ash-test-unit:latest; then
|
||||
echo ""
|
||||
echo "Unit tests FAILED — aborting deploy."
|
||||
exit 1
|
||||
fi
|
||||
echo "Unit tests passed."
|
||||
echo ""
|
||||
|
||||
for app in "${BUILD[@]}"; do
|
||||
echo "=== $app ==="
|
||||
docker build -f "$app/Dockerfile" -t "$REGISTRY/$app:latest" .
|
||||
|
||||
18
dev.sh
18
dev.sh
@@ -20,6 +20,24 @@ case "${1:-up}" in
|
||||
shift
|
||||
$COMPOSE logs -f "$@"
|
||||
;;
|
||||
test-run)
|
||||
# One-shot: all unit tests (headless, no dashboard)
|
||||
$COMPOSE run --rm test-unit python -m pytest \
|
||||
shared/ artdag/core/tests/ artdag/core/artdag/sexp/ \
|
||||
artdag/l1/tests/ artdag/l1/sexp_effects/ \
|
||||
-v --tb=short \
|
||||
--ignore=artdag/l1/tests/test_jax_primitives.py \
|
||||
--ignore=artdag/l1/tests/test_jax_pipeline_integration.py \
|
||||
-k "not gpu and not cuda"
|
||||
;;
|
||||
test-integration)
|
||||
# One-shot: integration tests (needs ffmpeg, heavier)
|
||||
$COMPOSE run --rm test-integration
|
||||
;;
|
||||
watch)
|
||||
# Auto-rerun unit tests on file changes (stays running)
|
||||
$COMPOSE up test-unit
|
||||
;;
|
||||
build)
|
||||
shift
|
||||
if [[ $# -eq 0 ]]; then
|
||||
|
||||
@@ -34,6 +34,7 @@ x-sibling-models: &sibling-models
|
||||
|
||||
services:
|
||||
blog:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8000"
|
||||
environment:
|
||||
@@ -44,6 +45,7 @@ services:
|
||||
- ./blog/alembic.ini:/app/blog/alembic.ini:ro
|
||||
- ./blog/alembic:/app/blog/alembic:ro
|
||||
- ./blog/app.py:/app/app.py
|
||||
- ./blog/sexp:/app/sexp
|
||||
- ./blog/bp:/app/bp
|
||||
- ./blog/services:/app/services
|
||||
- ./blog/templates:/app/templates
|
||||
@@ -69,6 +71,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
market:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8002:8000"
|
||||
environment:
|
||||
@@ -80,6 +83,7 @@ services:
|
||||
- ./market/alembic.ini:/app/market/alembic.ini:ro
|
||||
- ./market/alembic:/app/market/alembic:ro
|
||||
- ./market/app.py:/app/app.py
|
||||
- ./market/sexp:/app/sexp
|
||||
- ./market/bp:/app/bp
|
||||
- ./market/services:/app/services
|
||||
- ./market/templates:/app/templates
|
||||
@@ -105,6 +109,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
cart:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8003:8000"
|
||||
environment:
|
||||
@@ -115,6 +120,7 @@ services:
|
||||
- ./cart/alembic.ini:/app/cart/alembic.ini:ro
|
||||
- ./cart/alembic:/app/cart/alembic:ro
|
||||
- ./cart/app.py:/app/app.py
|
||||
- ./cart/sexp:/app/sexp
|
||||
- ./cart/bp:/app/bp
|
||||
- ./cart/services:/app/services
|
||||
- ./cart/templates:/app/templates
|
||||
@@ -140,6 +146,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
events:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8004:8000"
|
||||
environment:
|
||||
@@ -150,6 +157,7 @@ services:
|
||||
- ./events/alembic.ini:/app/events/alembic.ini:ro
|
||||
- ./events/alembic:/app/events/alembic:ro
|
||||
- ./events/app.py:/app/app.py
|
||||
- ./events/sexp:/app/sexp
|
||||
- ./events/bp:/app/bp
|
||||
- ./events/services:/app/services
|
||||
- ./events/templates:/app/templates
|
||||
@@ -175,6 +183,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
federation:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8005:8000"
|
||||
environment:
|
||||
@@ -185,6 +194,7 @@ services:
|
||||
- ./federation/alembic.ini:/app/federation/alembic.ini:ro
|
||||
- ./federation/alembic:/app/federation/alembic:ro
|
||||
- ./federation/app.py:/app/app.py
|
||||
- ./federation/sexp:/app/sexp
|
||||
- ./federation/bp:/app/bp
|
||||
- ./federation/services:/app/services
|
||||
- ./federation/templates:/app/templates
|
||||
@@ -210,6 +220,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
account:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8006:8000"
|
||||
environment:
|
||||
@@ -220,6 +231,7 @@ services:
|
||||
- ./account/alembic.ini:/app/account/alembic.ini:ro
|
||||
- ./account/alembic:/app/account/alembic:ro
|
||||
- ./account/app.py:/app/app.py
|
||||
- ./account/sexp:/app/sexp
|
||||
- ./account/bp:/app/bp
|
||||
- ./account/services:/app/services
|
||||
- ./account/templates:/app/templates
|
||||
@@ -245,6 +257,7 @@ services:
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
relations:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8008:8000"
|
||||
environment:
|
||||
@@ -275,6 +288,7 @@ services:
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
likes:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8009:8000"
|
||||
environment:
|
||||
@@ -305,6 +319,7 @@ services:
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
orders:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8010:8000"
|
||||
environment:
|
||||
@@ -315,6 +330,7 @@ services:
|
||||
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
|
||||
- ./orders/alembic:/app/orders/alembic:ro
|
||||
- ./orders/app.py:/app/app.py
|
||||
- ./orders/sexp:/app/sexp
|
||||
- ./orders/bp:/app/bp
|
||||
- ./orders/services:/app/services
|
||||
- ./orders/templates:/app/templates
|
||||
@@ -335,6 +351,68 @@ services:
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
test:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8011:8000"
|
||||
environment:
|
||||
<<: *dev-env
|
||||
volumes:
|
||||
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
|
||||
- ./shared:/app/shared
|
||||
- ./test/app.py:/app/app.py
|
||||
- ./test/sexp:/app/sexp
|
||||
- ./test/bp:/app/bp
|
||||
- ./test/services:/app/services
|
||||
- ./test/runner.py:/app/runner.py
|
||||
- ./test/path_setup.py:/app/path_setup.py
|
||||
- ./test/entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
# sibling service code + tests
|
||||
- ./blog:/app/blog:ro
|
||||
- ./market:/app/market:ro
|
||||
- ./cart:/app/cart:ro
|
||||
- ./events:/app/events:ro
|
||||
- ./federation:/app/federation:ro
|
||||
- ./account:/app/account:ro
|
||||
- ./relations:/app/relations:ro
|
||||
- ./likes:/app/likes:ro
|
||||
- ./orders:/app/orders:ro
|
||||
|
||||
test-unit:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: test/Dockerfile.unit
|
||||
volumes:
|
||||
- ./shared:/app/shared
|
||||
- ./artdag/core:/app/artdag/core
|
||||
- ./artdag/l1/tests:/app/artdag/l1/tests
|
||||
- ./artdag/l1/sexp_effects:/app/artdag/l1/sexp_effects
|
||||
- ./artdag/l1/app:/app/artdag/l1/app
|
||||
entrypoint: >
|
||||
python -m pytest_watch
|
||||
--runner "python -m pytest -v --tb=short"
|
||||
--
|
||||
shared/
|
||||
artdag/core/tests/
|
||||
artdag/core/artdag/sexp/
|
||||
artdag/l1/tests/
|
||||
artdag/l1/sexp_effects/
|
||||
--ignore=artdag/l1/tests/test_jax_primitives.py
|
||||
--ignore=artdag/l1/tests/test_jax_pipeline_integration.py
|
||||
-k "not gpu and not cuda"
|
||||
profiles:
|
||||
- test
|
||||
|
||||
test-integration:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: test/Dockerfile.integration
|
||||
volumes:
|
||||
- ./shared:/app/shared
|
||||
- ./artdag:/app/artdag
|
||||
profiles:
|
||||
- test
|
||||
|
||||
networks:
|
||||
appnet:
|
||||
driver: bridge
|
||||
|
||||
@@ -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_TEST: https://test.rose-ash.com
|
||||
APP_URL_ARTDAG: https://celery-artdag.rose-ash.com
|
||||
APP_URL_ARTDAG_L2: https://artdag.rose-ash.com
|
||||
INTERNAL_URL_BLOG: http://blog:8000
|
||||
@@ -46,6 +47,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_TEST: http://test:8000
|
||||
INTERNAL_URL_ARTDAG: http://l1-server:8100
|
||||
AP_DOMAIN: federation.rose-ash.com
|
||||
AP_DOMAIN_BLOG: blog.rose-ash.com
|
||||
@@ -201,6 +203,17 @@ services:
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
test:
|
||||
<<: *app-common
|
||||
image: registry.rose-ash.com:5000/test:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: test/Dockerfile
|
||||
environment:
|
||||
<<: *app-env
|
||||
REDIS_URL: redis://redis:6379/9
|
||||
WORKERS: "1"
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
|
||||
333
docs/ghost-removal-plan.md
Normal file
333
docs/ghost-removal-plan.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Ghost Removal Plan
|
||||
|
||||
**Replace Ghost CMS entirely with native infrastructure.**
|
||||
|
||||
---
|
||||
|
||||
## What Ghost Currently Provides
|
||||
|
||||
Ghost is deeply integrated across three major areas:
|
||||
|
||||
### 1. Content Management (blog service)
|
||||
- Post/page storage in Lexical JSON format
|
||||
- Author and tag entities with many-to-many relationships
|
||||
- WYSIWYG editing via Ghost Admin API
|
||||
- Media uploads (images, audio/video, files) via Ghost's upload endpoints
|
||||
- OEmbed lookups for embedded media
|
||||
- Content sync: Ghost → local DB via Content API + webhooks
|
||||
- ActivityPub publishing triggered after post sync
|
||||
|
||||
### 2. Membership & Subscriptions (account service)
|
||||
- **Members**: Ghost is the member store — users have `ghost_id`, synced bidirectionally
|
||||
- **Labels**: tagging/segmentation of members (M2M via `GhostLabel` / `UserLabel`)
|
||||
- **Newsletters**: newsletter entities with per-user subscription tracking (`GhostNewsletter` / `UserNewsletter` with `subscribed` flag)
|
||||
- **Tiers**: membership levels (free/paid) stored in `GhostTier`
|
||||
- **Subscriptions**: paid plans with Stripe integration — cadence, price, Stripe customer/subscription IDs stored in `GhostSubscription`
|
||||
- **Bidirectional sync**: Ghost → DB (`sync_all_membership_from_ghost`, `sync_single_member`) and DB → Ghost (`sync_member_to_ghost`)
|
||||
|
||||
### 3. Infrastructure
|
||||
- JWT token generation for Admin API (`ghost_admin_token.py`)
|
||||
- Webhook handlers for real-time sync (member, post, page, author, tag events)
|
||||
- Email campaign sending (newsletter selection on publish, `email_segment` parameter)
|
||||
- Stripe payment processing for paid subscriptions (handled entirely by Ghost)
|
||||
- Ghost Docker container (Node.js app alongside our Python stack)
|
||||
|
||||
### Environment Variables
|
||||
```
|
||||
GHOST_API_URL, GHOST_ADMIN_API_URL, GHOST_PUBLIC_URL
|
||||
GHOST_CONTENT_API_KEY, GHOST_ADMIN_API_KEY
|
||||
GHOST_WEBHOOK_SECRET
|
||||
```
|
||||
|
||||
### Ghost-Related Files
|
||||
```
|
||||
blog/bp/blog/ghost/ghost_sync.py # Content fetch & sync
|
||||
blog/bp/blog/ghost/ghost_posts.py # Post CRUD via Admin API
|
||||
blog/bp/blog/ghost/ghost_admin_token.py # JWT generation
|
||||
blog/bp/blog/ghost/lexical_validator.py # Lexical JSON validation
|
||||
blog/bp/blog/ghost/editor_api.py # Media upload proxy
|
||||
blog/bp/blog/ghost_db.py # Ghost DB client
|
||||
blog/bp/blog/web_hooks/routes.py # Webhook handlers
|
||||
shared/infrastructure/ghost_admin_token.py # JWT generation (shared copy)
|
||||
shared/models/ghost_content.py # Post, Author, Tag, junction tables
|
||||
shared/models/ghost_membership_entities.py # Label, Newsletter, Tier, Subscription
|
||||
account/services/ghost_membership.py # Membership sync service
|
||||
```
|
||||
|
||||
### Ghost-Related Database Tables
|
||||
```
|
||||
# Content
|
||||
posts (ghost_id, uuid, slug, title, status, lexical, html, ...)
|
||||
authors (ghost_id, slug, name, email, ...)
|
||||
tags (ghost_id, slug, name, ...)
|
||||
post_authors (post_id, author_id, sort_order)
|
||||
post_tags (post_id, tag_id, sort_order)
|
||||
|
||||
# Membership
|
||||
ghost_labels (ghost_id, name, slug)
|
||||
user_labels (user_id, label_id)
|
||||
ghost_newsletters (ghost_id, name, slug, description)
|
||||
user_newsletters (user_id, newsletter_id, subscribed)
|
||||
ghost_tiers (ghost_id, name, slug, type, visibility)
|
||||
ghost_subscriptions (ghost_id, user_id, status, cadence, price_amount,
|
||||
price_currency, stripe_customer_id, stripe_subscription_id,
|
||||
tier_id, raw)
|
||||
|
||||
# User model fields
|
||||
users.ghost_id, users.ghost_status, users.ghost_subscribed,
|
||||
users.ghost_note, users.ghost_raw, users.stripe_customer_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Problems
|
||||
|
||||
- **Two sources of truth** for content AND membership — constant sync overhead
|
||||
- Every edit round-trips through Ghost's API — we don't own the write path
|
||||
- Ghost sync is fragile (advisory locks, error recovery, partial sync states)
|
||||
- Lexical JSON is opaque — we validate but never truly control the format
|
||||
- Ghost is an entire Node.js application running alongside our Python stack
|
||||
- Stripe integration is locked inside Ghost — we can't customize payment flows
|
||||
- Newsletter/email is Ghost-native — no control over templates, scheduling, deliverability
|
||||
- Membership tiers are Ghost concepts that don't map cleanly to our cooperative model
|
||||
|
||||
---
|
||||
|
||||
## Target State
|
||||
|
||||
Everything Ghost does is handled natively by our services:
|
||||
|
||||
| Ghost Feature | Replacement |
|
||||
|---|---|
|
||||
| Post/page content | Sexp in `posts.body_sexp` column |
|
||||
| Lexical editor | WYSIWYG editor saving sexp directly to DB |
|
||||
| Media uploads | Direct upload to our storage (S3/local) — blog service endpoint |
|
||||
| Authors | Already in our DB — just drop `ghost_id` column |
|
||||
| Tags | Already in our DB — just drop `ghost_id` column |
|
||||
| Members | Already our `users` table — drop Ghost sync, Ghost fields |
|
||||
| Labels | Rename `ghost_labels` → `labels`, drop `ghost_id` |
|
||||
| Newsletters | Native newsletter service (see Phase 7 below) |
|
||||
| Tiers | Native membership tiers on `account` service |
|
||||
| Subscriptions | Direct Stripe integration on `orders` service (already has SumUp) |
|
||||
| Email sending | Transactional email service (Postmark/SES/SMTP) |
|
||||
| Webhooks | Not needed — we own the write path |
|
||||
| Ghost Docker container | Removed entirely |
|
||||
|
||||
---
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Lexical → Sexp Converter
|
||||
|
||||
Write a one-time conversion script that transforms Lexical JSON into equivalent sexp.
|
||||
|
||||
| Lexical Node | S-expression |
|
||||
|---|---|
|
||||
| `paragraph` | `(p ...)` |
|
||||
| `heading` (level 1-6) | `(h1 ...)` ... `(h6 ...)` |
|
||||
| `text` (plain) | `"string"` |
|
||||
| `text` (bold) | `(strong "string")` |
|
||||
| `text` (italic) | `(em "string")` |
|
||||
| `text` (bold+italic) | `(strong (em "string"))` |
|
||||
| `text` (code) | `(code "string")` |
|
||||
| `link` | `(a :href "url" "text")` |
|
||||
| `list` (bullet) | `(ul (li ...) ...)` |
|
||||
| `list` (number) | `(ol (li ...) ...)` |
|
||||
| `quote` | `(blockquote ...)` |
|
||||
| `image` | `(use "image" :src "url" :alt "text" :caption "text")` |
|
||||
| `code-block` | `(pre (code :class "language-x" "..."))` |
|
||||
| `divider` | `(hr)` |
|
||||
| `embed` | `(use "embed" :url "..." :type "...")` |
|
||||
|
||||
Run against all existing posts, verify round-trip fidelity by rendering both versions and comparing HTML output.
|
||||
|
||||
### Phase 2: Schema Changes — Content
|
||||
|
||||
- Add `body_sexp` text column to `Post` model (or repurpose `lexical` column)
|
||||
- Keep all existing metadata columns (title, slug, status, published_at, feature_image, etc.)
|
||||
- Drop `ghost_id` from `Post`, `Author`, `Tag` tables (after full migration)
|
||||
- Drop `mobiledoc` column (legacy Ghost format, unused)
|
||||
|
||||
### Phase 3: Editor Integration
|
||||
|
||||
Update the WYSIWYG editor to save sexp instead of Lexical JSON:
|
||||
|
||||
- Editor toolbar actions produce sexp nodes
|
||||
- Save endpoint writes directly to our DB (no Ghost Admin API call)
|
||||
- Preview renders via the same sexp pipeline used for the public view
|
||||
- Draft/publish workflow stays the same — just a `status` column update
|
||||
|
||||
### Phase 4: Media Uploads
|
||||
|
||||
Replace Ghost's upload proxy with native endpoints on the blog service:
|
||||
|
||||
- `POST /admin/upload/image/` — accept image, store to S3/local, return URL
|
||||
- `POST /admin/upload/media/` — audio/video
|
||||
- `POST /admin/upload/file/` — generic files
|
||||
- `GET /admin/oembed/?url=...` — OEmbed lookup (call providers directly)
|
||||
|
||||
The editor already posts to proxy endpoints in `editor_api.py` — just retarget them to store directly rather than forwarding to Ghost.
|
||||
|
||||
### Phase 5: Rendering Pipeline
|
||||
|
||||
Update `post_data()` and related functions:
|
||||
|
||||
- Parse `body_sexp` through the sexp evaluator
|
||||
- Render to HTML via the existing `shared/sexp/html.py` pipeline
|
||||
- Components referenced in post content (`use "image-gallery"`, etc.) resolve from the component registry
|
||||
- Context variables (author data, related posts, etc.) passed as environment bindings
|
||||
|
||||
### Phase 6: Membership Decoupling
|
||||
|
||||
Migrate membership from Ghost to native account service:
|
||||
|
||||
**Labels → native labels:**
|
||||
- Rename `ghost_labels` → `labels`, drop `ghost_id` column
|
||||
- `user_labels` stays as-is
|
||||
- Admin UI manages labels directly (no Ghost sync)
|
||||
|
||||
**Tiers → native membership tiers:**
|
||||
- Rename `ghost_tiers` → `membership_tiers`, drop `ghost_id`
|
||||
- Add tier management to account admin UI
|
||||
- Tier assignment logic moves from Ghost webhook handler to account service
|
||||
|
||||
**User model cleanup:**
|
||||
- Drop: `ghost_id`, `ghost_status`, `ghost_subscribed`, `ghost_note`, `ghost_raw`
|
||||
- Keep: `stripe_customer_id` (needed for direct Stripe integration)
|
||||
- Add: `membership_tier_id` FK, `membership_status` enum (free/active/cancelled)
|
||||
|
||||
### Phase 7: Newsletter System
|
||||
|
||||
Replace Ghost's newsletter infrastructure with a native implementation:
|
||||
|
||||
**Newsletter model (replaces `ghost_newsletters`):**
|
||||
```
|
||||
newsletters (id, name, slug, description, from_email, reply_to, template_sexp, created_at)
|
||||
user_newsletters (user_id, newsletter_id, subscribed, subscribed_at, unsubscribed_at)
|
||||
```
|
||||
|
||||
**Email sending:**
|
||||
- Integrate a transactional email provider (Postmark, AWS SES, or direct SMTP)
|
||||
- Newsletter templates as sexp — rendered to HTML email via the same pipeline
|
||||
- Send endpoint on account or blog service: select newsletter, select segment (by label/tier), queue sends
|
||||
- Unsubscribe handling: tokenized unsubscribe links, one-click List-Unsubscribe header
|
||||
|
||||
**Post → email campaign:**
|
||||
- On publish, optionally select newsletter + segment (replaces Ghost's `?newsletter=slug&email_segment=...`)
|
||||
- Render post body sexp to email-safe HTML (inline styles, table layout for email clients)
|
||||
- Queue via background task (Celery or async worker)
|
||||
|
||||
**What we gain over Ghost:**
|
||||
- Email templates are sexp — same format as everything else
|
||||
- Full control over deliverability (SPF/DKIM/DMARC on our domain)
|
||||
- Segment by any user attribute, not just Ghost's limited filter syntax
|
||||
- Send analytics stored in our DB
|
||||
|
||||
### Phase 8: Subscription & Payment
|
||||
|
||||
Replace Ghost's Stripe integration with direct Stripe on the orders service:
|
||||
|
||||
**Current state:** Orders service already handles SumUp payments for marketplace/events. Adding Stripe for recurring subscriptions follows the same pattern.
|
||||
|
||||
**Implementation:**
|
||||
- Stripe Checkout for subscription creation (redirect flow, PCI compliant)
|
||||
- Stripe Webhooks for subscription lifecycle (created, updated, cancelled, payment_failed)
|
||||
- `subscriptions` table (replaces `ghost_subscriptions`):
|
||||
```
|
||||
subscriptions (id, user_id, tier_id, stripe_subscription_id, stripe_customer_id,
|
||||
status, cadence, price_amount, price_currency,
|
||||
current_period_start, current_period_end, cancelled_at)
|
||||
```
|
||||
- Customer portal: link to Stripe's hosted portal for card updates/cancellation
|
||||
- Webhook handler on orders service (same pattern as SumUp webhooks)
|
||||
|
||||
**What we gain:**
|
||||
- Unified payment handling (SumUp for one-off, Stripe for recurring)
|
||||
- Custom subscription logic (cooperative membership models, sliding scale, etc.)
|
||||
- Direct access to Stripe customer data without Ghost intermediary
|
||||
|
||||
### Phase 9: Remove Ghost
|
||||
|
||||
Delete all Ghost integration code:
|
||||
|
||||
| File/Directory | Action |
|
||||
|---|---|
|
||||
| `blog/bp/blog/ghost/` | Delete entire directory |
|
||||
| `blog/bp/blog/ghost_db.py` | Delete |
|
||||
| `blog/bp/blog/web_hooks/` | Delete |
|
||||
| `shared/infrastructure/ghost_admin_token.py` | Delete |
|
||||
| `account/services/ghost_membership.py` | Delete |
|
||||
| Ghost Docker service | Remove from docker-compose |
|
||||
| Ghost env vars | Remove all `GHOST_*` variables |
|
||||
| Ghost webhook blueprint registration | Remove from blog routes |
|
||||
| Startup sync (`sync_all_content_from_ghost`) | Remove from blog init |
|
||||
| Startup sync (`sync_all_membership_from_ghost`) | Remove from account init |
|
||||
| Advisory lock `900001` | Remove from blog init |
|
||||
|
||||
Rename models:
|
||||
- `ghost_content.py` → `content.py`
|
||||
- `ghost_membership_entities.py` → `membership.py`
|
||||
- Drop all `ghost_id` columns via Alembic migration
|
||||
|
||||
### Phase 10: Content-Addressable Caching (ties into sexpr.js)
|
||||
|
||||
Once posts are sexp and the JS client runtime exists:
|
||||
|
||||
- Hash post body → content address
|
||||
- Client caches post bodies in localStorage keyed by hash
|
||||
- Server sends manifest of slug → hash mappings
|
||||
- Unchanged posts served entirely from client cache
|
||||
- Only the data envelope (metadata, component params) travels on repeat visits
|
||||
|
||||
---
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
- `Post` model and all its metadata fields (minus ghost-specific ones)
|
||||
- URL structure (`/slug/`)
|
||||
- Tag, author, and tag group systems
|
||||
- Draft/publish workflow
|
||||
- Admin edit UI (updated to save sexp instead of Lexical)
|
||||
- RSS feeds (rendered from sexp → HTML)
|
||||
- Search indexing (extract text content from sexp)
|
||||
- ActivityPub federation (triggered on publish, same as now)
|
||||
- Alembic migrations (add/modify/drop columns)
|
||||
- OAuth2 auth system (already independent of Ghost)
|
||||
|
||||
---
|
||||
|
||||
## Ordering & Dependencies
|
||||
|
||||
```
|
||||
Phase 1-2 (Content schema) ──→ Phase 3 (Editor) ──→ Phase 5 (Rendering)
|
||||
Phase 4 (Uploads) ──┘
|
||||
Phase 6 (Membership) ──→ Phase 8 (Payments)
|
||||
Phase 7 (Newsletters) ── independent, needs email provider choice
|
||||
Phase 9 (Remove Ghost) ── after all above complete
|
||||
Phase 10 (Content-addressed) ── after sexpr.js runtime exists
|
||||
```
|
||||
|
||||
Phases 1-5 (content) and Phases 6-8 (membership/payments) can proceed in parallel — they touch different services.
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
- **Data safety**: Run Lexical → sexp converter in dry-run mode first, diff HTML output for every post
|
||||
- **Rollback**: Keep `lexical` column and Ghost running during transition, feature flag to switch renderers
|
||||
- **Editor UX**: Editor remains WYSIWYG — authors never see sexp syntax
|
||||
- **SEO continuity**: URLs don't change, HTML output structurally identical
|
||||
- **Email deliverability**: Set up SPF/DKIM/DMARC before sending first newsletter from our domain
|
||||
- **Payment migration**: Run Ghost Stripe and direct Stripe in parallel during transition, migrate active subscriptions via Stripe API (change the subscription's application)
|
||||
- **Membership data**: One-time migration script to clean User model fields, verified against Ghost export
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Stable sexp parser + evaluator (already built: `shared/sexp/`)
|
||||
- Component registry with post-relevant components: image, embed, gallery, code-block
|
||||
- Editor sexp serialization (new work)
|
||||
- Email provider account (Postmark/SES/SMTP)
|
||||
- Stripe account with recurring billing enabled (may already exist via Ghost)
|
||||
- Optional: sexpr.js client runtime for content-addressable caching (see `sexpr-js-runtime-plan.md`)
|
||||
240
docs/masterplan-sprint.md
Normal file
240
docs/masterplan-sprint.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Rose Ash: Fortnightly Sprints
|
||||
|
||||
**Fit the task to the timescale, not vice versa.**
|
||||
|
||||
The golden rule of project management. Don't take a 20-week plan and cram it into 2 weeks — that's denial. Ask instead: what's the highest-value thing you can *finish* — not start, finish — in two weeks?
|
||||
|
||||
Each sprint ships one or two deliverables. Both complete, both deployed, both making everything after easier. The master plan is still the map. You walk it two steps at a time.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 1: Sexp Content + Sexp Wire (Weeks 1-2)
|
||||
|
||||
### Week 1: Posts become sexp
|
||||
|
||||
Ghost still runs for membership and newsletters. Don't touch that. Just the content path.
|
||||
|
||||
**Deliverable: every post is sexp in the database, rendered through the sexp evaluator, editable through the existing editor.**
|
||||
|
||||
- [ ] Lexical → sexp converter script (one-time migration)
|
||||
- [ ] Add `body_sexp` column to Post model (Alembic migration)
|
||||
- [ ] Run converter against all posts
|
||||
- [ ] Diff HTML output: Lexical render vs sexp render for every post
|
||||
- [ ] Fix conversion gaps (embeds, cards, special blocks)
|
||||
- [ ] Switch rendering pipeline: `post_data()` reads `body_sexp`
|
||||
- [ ] Editor save endpoint writes sexp directly to DB
|
||||
- [ ] Native media upload endpoints (image, audio, files) — retarget from Ghost proxy
|
||||
- [ ] Native OEmbed lookup endpoint
|
||||
- [ ] Deploy, verify every page renders correctly
|
||||
|
||||
### Week 2: Fragment endpoints return sexp
|
||||
|
||||
HTTP+HMAC stays as transport. The change is what travels over it.
|
||||
|
||||
**Deliverable: `fetch_fragment` returns sexp trees instead of HTML strings. Callers render at the boundary.**
|
||||
|
||||
- [ ] Relations service returns raw sexp (already renders from sexp — just stop calling `to_html()`)
|
||||
- [ ] Blog renders relations sexp to HTML at the route level
|
||||
- [ ] Events renders relations sexp to HTML at the route level
|
||||
- [ ] Market renders relations sexp to HTML at the route level
|
||||
- [ ] Callers can now filter, reorder, transform fragments before rendering
|
||||
- [ ] Measure: is this faster, slower, or same as HTML fragments?
|
||||
- [ ] Unit tests for sexp fragment round-trip (parse → filter → render)
|
||||
|
||||
### Sprint 1 outcome
|
||||
|
||||
Posts are sexp. Internal fragments are sexp. Ghost still runs but only for membership/newsletters — the content path is clean. Everything that follows builds on this.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2: Kill Ghost + New Relations (Weeks 3-4)
|
||||
|
||||
### Week 3: Membership decoupling
|
||||
|
||||
**Deliverable: all Ghost membership/newsletter infrastructure replaced with native equivalents.**
|
||||
|
||||
- [ ] Rename `ghost_labels` → `labels`, drop `ghost_id`
|
||||
- [ ] Rename `ghost_tiers` → `membership_tiers`, drop `ghost_id`
|
||||
- [ ] Clean User model (drop `ghost_id`, `ghost_status`, `ghost_subscribed`, `ghost_note`, `ghost_raw`)
|
||||
- [ ] Add `membership_tier_id`, `membership_status` to User
|
||||
- [ ] Native `subscriptions` table replacing `ghost_subscriptions`
|
||||
- [ ] Wire Stripe directly on orders service (Checkout + Webhooks)
|
||||
- [ ] Native newsletter model + `user_newsletters`
|
||||
- [ ] Email sending via SMTP/SES
|
||||
- [ ] Newsletter templates as sexp → email-safe HTML
|
||||
- [ ] Post → email campaign workflow
|
||||
|
||||
### Week 4: Delete Ghost + add entity relations
|
||||
|
||||
**Deliverable: Ghost is gone. New entity relations are live.**
|
||||
|
||||
- [ ] Delete `blog/bp/blog/ghost/` directory
|
||||
- [ ] Delete `ghost_db.py`, `web_hooks/`, `ghost_admin_token.py`, `ghost_membership.py`
|
||||
- [ ] Remove Ghost Docker service, all `GHOST_*` env vars
|
||||
- [ ] Alembic migration: drop all `ghost_id` columns
|
||||
- [ ] Rename model files: `ghost_content.py` → `content.py`, `ghost_membership_entities.py` → `membership.py`
|
||||
- [ ] Add new `defrelation`s: `post->post`, `market->product`, `calendar->calendar_entry`, `page->marketplace`
|
||||
- [ ] Migrate `CalendarEntryPost` junction → `ContainerRelation`
|
||||
- [ ] Verify: entire platform runs with zero Ghost code
|
||||
|
||||
### Sprint 2 outcome
|
||||
|
||||
Ghost is dead. Membership is native. New entity relations work. The platform is self-contained.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3: Cart Split + Sexp Pages (Weeks 5-6)
|
||||
|
||||
### Week 5: Cart microservices split
|
||||
|
||||
**Deliverable: cart is thin CRUD. Likes, orders, and PageConfig are in their proper homes.**
|
||||
|
||||
- [ ] Scaffold likes service (port 8009)
|
||||
- [ ] Move PageConfig to blog service
|
||||
- [ ] Orders service owns Order/OrderItem, checkout, Stripe/SumUp webhooks
|
||||
- [ ] Cart becomes CartItem CRUD + checkout delegation
|
||||
- [ ] Fragment cleanup: move remaining domain templates out of `shared/templates/`
|
||||
|
||||
### Week 6: Sexp page layouts
|
||||
|
||||
**Deliverable: page layouts are sexp components, not Jinja template inheritance.**
|
||||
|
||||
- [ ] Base page layout as sexp (head, nav, main, footer)
|
||||
- [ ] Blog post page layout
|
||||
- [ ] Market product page layout
|
||||
- [ ] Event calendar page layout
|
||||
- [ ] Component composition replaces `{% extends "base.html" %}`
|
||||
- [ ] Content negotiation: same route returns HTML or sexp based on `Accept` header
|
||||
|
||||
### Sprint 3 outcome
|
||||
|
||||
Services are properly bounded. Pages are sexp top to bottom. The `Accept: application/sexp` header works — the door to client-side sexp is open.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4: Internal Protocol (Weeks 7-8)
|
||||
|
||||
### Week 7: Python sexp client/server library
|
||||
|
||||
**Deliverable: working `SexpConnection` class, Quart handler, drop-in `fetch_sexp()`.**
|
||||
|
||||
- [ ] Wire format: length-prefixed sexp over Unix sockets
|
||||
- [ ] `SexpConnection`: connect, send sexp, receive sexp, handle streams
|
||||
- [ ] Quart integration: handler accepts sexp requests, returns sexp responses
|
||||
- [ ] `fetch_sexp()` — unified replacement for `fetch_data()` + `fetch_fragment()`
|
||||
- [ ] Error model: `(err :code 404 :message "not found")`
|
||||
- [ ] Unit tests for protocol library
|
||||
|
||||
### Week 8: Internal mesh migration
|
||||
|
||||
**Deliverable: all inter-service communication runs on native sexp protocol.**
|
||||
|
||||
- [ ] Blog ↔ relations on sexp
|
||||
- [ ] Events ↔ relations on sexp
|
||||
- [ ] Market ↔ relations on sexp
|
||||
- [ ] Cart ↔ orders on sexp
|
||||
- [ ] Account ↔ all services on sexp
|
||||
- [ ] Benchmark: latency/throughput vs HTTP+HMAC
|
||||
- [ ] HTTP kept as fallback for external/third-party calls
|
||||
|
||||
### Sprint 4 outcome
|
||||
|
||||
The protocol is real. Running in production. Carrying every inter-service call. Battle-tested on real traffic.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 5: sexpr.js (Weeks 9-10)
|
||||
|
||||
### Week 9: Core runtime
|
||||
|
||||
**Deliverable: sexpr.js parses, renders, and mutates the DOM from sexp.**
|
||||
|
||||
- [ ] Sexp parser in JS (<5KB gzipped)
|
||||
- [ ] DOM renderer: sexp tree → DOM nodes
|
||||
- [ ] `swap!`, `batch!`, `class!` mutation primitives
|
||||
- [ ] `request!` — fetch sexp from server, apply mutations
|
||||
- [ ] Component system: `defcomp`, slots
|
||||
|
||||
### Week 10: Integration + caching
|
||||
|
||||
**Deliverable: sexpr.js on every rose-ash page, with content-addressed caching.**
|
||||
|
||||
- [ ] `<script src="sexpr.js">` on every page — progressive enhancement
|
||||
- [ ] Partial page updates via sexp mutations (replaces full reload for nav, cart, likes)
|
||||
- [ ] Content-addressed caching: hash component/post body, cache in localStorage
|
||||
- [ ] Server sends hash manifest — unchanged content served from cache
|
||||
- [ ] DevTools: sexp inspector in browser console
|
||||
|
||||
### Sprint 5 outcome
|
||||
|
||||
The client speaks sexp. Pages load faster. Navigation doesn't reload. Components cache locally. Tier 1 is live.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 6: Federation + Stability (Weeks 11-12)
|
||||
|
||||
### Week 11: AP over sexp
|
||||
|
||||
**Deliverable: two rose-ash instances federate using sexp activities.**
|
||||
|
||||
- [ ] Sexp-formatted activities (Create, Follow, Like, Accept)
|
||||
- [ ] Federation test: two instances in Docker, full activity flow
|
||||
- [ ] Identity verification over sexp (RSA signatures in sexp envelope)
|
||||
- [ ] Cross-instance content rendering (sexp posts display correctly on remote instance)
|
||||
|
||||
### Week 12: Harden + deploy
|
||||
|
||||
**Deliverable: everything stable, tested, and deployed.**
|
||||
|
||||
- [ ] Unit test coverage for all new code (protocol, converter, newsletter, likes)
|
||||
- [ ] Deploy Tier 0 scalability (DB split, PgBouncer, Hypercorn workers)
|
||||
- [ ] Bug sweep: end-to-end test every user flow
|
||||
- [ ] Performance baseline: measure page load, inter-service latency, cache hit rates
|
||||
- [ ] Documentation: update CLAUDE.md with new architecture
|
||||
|
||||
### Sprint 6 outcome
|
||||
|
||||
Platform is stable, federated, performant, and documented. The protocol has been tested across network boundaries.
|
||||
|
||||
---
|
||||
|
||||
## After Sprint 6: The 10%
|
||||
|
||||
These are real projects, not sprint tasks. Build them when they're needed:
|
||||
|
||||
- **Rust client library + native client** — when you want Tier 2 performance
|
||||
- **Browser extension** — when sexpr.js proves the concept and you want Tier 1.5
|
||||
- **Client-as-node / IPFS / GPU mesh** — when the cooperative network has multiple members with hardware
|
||||
- **Multi-cooperative commerce** — when a second co-op actually exists and wants to federate
|
||||
- **Scalability Tiers 1-3** — when traffic hits Tier 0 limits
|
||||
|
||||
---
|
||||
|
||||
## The Rhythm
|
||||
|
||||
```
|
||||
Each sprint:
|
||||
Monday-Thursday: Build. AI generates, you steer and test.
|
||||
Friday: Deploy to dev, verify, fix what broke.
|
||||
Weekend: Rest or think about next sprint.
|
||||
|
||||
Each week:
|
||||
One deliverable. Finished. Deployed. Working.
|
||||
```
|
||||
|
||||
**12 weeks, 12 deliverables, each one complete.** Not 20 weeks of partial progress. Not 2 weeks of rushed everything. The right amount of work for the time available.
|
||||
|
||||
---
|
||||
|
||||
## Sprint Schedule
|
||||
|
||||
```
|
||||
Sprint 1 (W1-2): Sexp content + sexp wire ← content path is clean
|
||||
Sprint 2 (W3-4): Kill Ghost + new relations ← platform is self-contained
|
||||
Sprint 3 (W5-6): Cart split + sexp pages ← services bounded, pages are sexp
|
||||
Sprint 4 (W7-8): Internal sexp protocol ← protocol is real and running
|
||||
Sprint 5 (W9-10): sexpr.js client runtime ← client speaks sexp
|
||||
Sprint 6 (W11-12): Federation + stability ← federated and stable
|
||||
```
|
||||
|
||||
Each sprint builds on the last. Each is valuable on its own. If you stop after any sprint, you've shipped something real.
|
||||
466
docs/masterplan.md
Normal file
466
docs/masterplan.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Rose Ash Master Plan
|
||||
|
||||
**From cooperative web platform to federated sexp protocol — scheduled, ordered, and realistic.**
|
||||
|
||||
---
|
||||
|
||||
## What's Done
|
||||
|
||||
The foundation is solid. These are complete and in production:
|
||||
|
||||
| Area | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Sexp core (parser, evaluator, primitives) | Complete | 199 tests passing |
|
||||
| Sexp HTML renderer | Complete | HSX-style, escaping, components |
|
||||
| Sexp async resolver | Complete | Tree walker, parallel I/O |
|
||||
| Sexp Jinja bridge | Complete | `sexp()` global in all apps |
|
||||
| Sexp component templates | Complete | `.sexpr` files in all 8 services |
|
||||
| Relation registry | Complete | `defrelation` form, 4 relations defined |
|
||||
| Relation API | Complete | `relate`, `unrelate`, `can-relate` actions |
|
||||
| Relation container-nav | Complete | Generic fragment, sexp-rendered |
|
||||
| Relation caller migration (Phase F) | Complete | All callers on `relate`/`unrelate` |
|
||||
| Decoupling Phases 1-3 | Complete | Models extracted, generic containers |
|
||||
| Fragment infrastructure | Complete | Client, Redis cache, all apps serving |
|
||||
| Link-card unification | Complete | Sexp component replaces 5 Jinja templates |
|
||||
|
||||
---
|
||||
|
||||
## What's Planned
|
||||
|
||||
Everything below is pending, grouped into tracks that can run in parallel where noted.
|
||||
|
||||
---
|
||||
|
||||
## Track 1: Platform Stability (Immediate)
|
||||
|
||||
*Bug fixes, test coverage, and operational reliability. Do this first — everything else builds on a stable base.*
|
||||
|
||||
### 1.1 — Unit Test Coverage
|
||||
|
||||
**When:** Now (ongoing, alongside all other work)
|
||||
|
||||
Expand from current 199 sexp tests to cover pure-logic modules:
|
||||
|
||||
- [ ] DTOs and contracts (`shared/contracts/`)
|
||||
- [ ] URL utilities, HTTP signatures, HMAC auth
|
||||
- [ ] Jinja filters and template helpers
|
||||
- [ ] Calendar date helpers
|
||||
- [ ] Config freeze/readonly enforcement
|
||||
- [ ] Activity bus serialisation
|
||||
- [ ] Sexp relation registry edge cases
|
||||
|
||||
Wire into `test-unit` Docker container. Run on every push.
|
||||
|
||||
### 1.2 — Scalability Tier 0
|
||||
|
||||
**When:** First available window (hours of work, 10x capacity)
|
||||
|
||||
- [ ] Deploy per-service database split
|
||||
- [ ] Add PgBouncer connection pooling
|
||||
- [ ] Separate auth Redis from cache Redis
|
||||
- [ ] Tune Hypercorn worker count per service
|
||||
- [ ] Add health check endpoints
|
||||
|
||||
### 1.3 — Bug Sweep
|
||||
|
||||
**When:** Before each major feature phase
|
||||
|
||||
- [ ] Audit remaining `attach-child`/`detach-child` calls (should be zero)
|
||||
- [ ] Fix any broken fragment rendering from relations migration
|
||||
- [ ] Verify all container-nav renders correctly across blog, events, market
|
||||
- [ ] Test cart checkout flow end-to-end
|
||||
- [ ] Validate ActivityPub federation still works (follow, like, boost, create)
|
||||
|
||||
---
|
||||
|
||||
## Track 2: Complete Decoupling (Weeks 1-3)
|
||||
|
||||
*Finish what's started. Clean separation enables everything that follows.*
|
||||
|
||||
### 2.1 — Cart Microservices Split
|
||||
|
||||
**When:** Week 1-2
|
||||
|
||||
The cart service currently owns too much. Split into focused services:
|
||||
|
||||
- [ ] Scaffold likes service (internal, port 8009) — unified like/favourite tracking
|
||||
- [ ] Migrate `PageConfig` to blog service (page owner)
|
||||
- [ ] Extract order history and checkout to orders service
|
||||
- [ ] Cart becomes thin CartItem CRUD + checkout delegation
|
||||
|
||||
### 2.2 — Fragment Composition Phases 6-8
|
||||
|
||||
**When:** Week 2-3 (parallel with cart split)
|
||||
|
||||
- [ ] Account widget fragments (login state, profile mini)
|
||||
- [ ] Template migration (move all domain templates out of `shared/templates/`)
|
||||
- [ ] Delete shared template inheritance (apps own their layouts fully)
|
||||
- [ ] Remove old widget system
|
||||
|
||||
### 2.3 — Decoupling Phase 5: Event-Driven Workflows
|
||||
|
||||
**When:** Week 3
|
||||
|
||||
- [ ] Order creation triggers domain events (not direct service calls)
|
||||
- [ ] Login/signup events propagate via activity bus
|
||||
- [ ] Replace remaining cross-service direct DB access with events
|
||||
|
||||
---
|
||||
|
||||
## Track 3: New Entities & Relations (Weeks 2-5)
|
||||
|
||||
*Extend the relation system with new entity types. Best done after decoupling is clean, before Ghost removal changes the content model.*
|
||||
|
||||
### 3.1 — Define New Relations
|
||||
|
||||
Add to the relation registry:
|
||||
|
||||
```scheme
|
||||
;; Content relations
|
||||
(defrelation :post->post
|
||||
:from "post" :to "post" :cardinality :many-to-many
|
||||
:nav :inline :nav-icon "fa fa-link" :nav-label "related")
|
||||
|
||||
(defrelation :post->tag
|
||||
:from "post" :to "tag" :cardinality :many-to-many
|
||||
:nav :hidden)
|
||||
|
||||
(defrelation :post->author
|
||||
:from "post" :to "author" :cardinality :many-to-many
|
||||
:nav :hidden)
|
||||
|
||||
;; Commerce relations
|
||||
(defrelation :market->product
|
||||
:from "market" :to "product" :cardinality :one-to-many
|
||||
:inverse :product->market
|
||||
:nav :submenu :nav-icon "fa fa-box" :nav-label "products")
|
||||
|
||||
(defrelation :page->marketplace
|
||||
:from "page" :to "marketplace" :cardinality :one-to-one
|
||||
:inverse :marketplace->page
|
||||
:nav :submenu :nav-icon "fa fa-store" :nav-label "marketplace")
|
||||
|
||||
;; Event relations
|
||||
(defrelation :calendar->calendar_entry
|
||||
:from "calendar" :to "calendar_entry" :cardinality :one-to-many
|
||||
:inverse :calendar_entry->calendar
|
||||
:nav :submenu :nav-icon "fa fa-calendar-day" :nav-label "entries")
|
||||
|
||||
(defrelation :calendar_entry->ticket_type
|
||||
:from "calendar_entry" :to "ticket_type" :cardinality :one-to-many
|
||||
:nav :hidden)
|
||||
|
||||
;; Federation relations
|
||||
(defrelation :user->ap_follower
|
||||
:from "user" :to "ap_follower" :cardinality :one-to-many
|
||||
:nav :hidden)
|
||||
|
||||
;; Cooperative governance (future)
|
||||
(defrelation :page->proposal
|
||||
:from "page" :to "proposal" :cardinality :one-to-many
|
||||
:nav :submenu :nav-icon "fa fa-vote-yea" :nav-label "proposals")
|
||||
```
|
||||
|
||||
### 3.2 — CalendarEntryPost Junction Migration
|
||||
|
||||
**When:** Week 3
|
||||
|
||||
The deferred work from Phase F — migrate the `CalendarEntryPost` junction table to use the relation system:
|
||||
|
||||
- [ ] Migrate existing junction rows to `ContainerRelation` with `relation_type = "post->calendar_entry"`
|
||||
- [ ] Update events service toggle/query endpoints
|
||||
- [ ] Remove old junction table
|
||||
|
||||
### 3.3 — Post-Tag and Post-Author via Relations
|
||||
|
||||
**When:** Week 4 (after Ghost removal Phase 2 changes the schema)
|
||||
|
||||
Currently `post_tags` and `post_authors` are Ghost-synced junction tables. Once Ghost is removed:
|
||||
|
||||
- [ ] Migrate to `ContainerRelation` or keep as dedicated junction tables (simpler for many-to-many with sort_order)
|
||||
- [ ] Decision: relations for discovery/navigation, dedicated tables for core content model
|
||||
|
||||
### 3.4 — Product-Market Relations
|
||||
|
||||
**When:** Week 4-5
|
||||
|
||||
- [ ] Products currently linked to markets via `market_id` FK
|
||||
- [ ] Add relation for cross-market product listing (a product can appear in multiple markets)
|
||||
- [ ] Marketplace pages as relation containers
|
||||
|
||||
---
|
||||
|
||||
## Track 4: Ghost Removal (Weeks 3-8)
|
||||
|
||||
*The big migration. Depends on stable sexp core (done) and clean decoupling (Track 2). Content phases and membership phases can run in parallel.*
|
||||
|
||||
### 4.1 — Content Migration (Weeks 3-5)
|
||||
|
||||
**Phase 1: Lexical → Sexp Converter**
|
||||
- [ ] Write one-time conversion script (Lexical JSON → sexp)
|
||||
- [ ] Dry-run against all existing posts
|
||||
- [ ] Diff HTML output (Lexical render vs sexp render) for every post
|
||||
- [ ] Fix any conversion gaps (embeds, cards, special blocks)
|
||||
|
||||
**Phase 2: Schema Changes**
|
||||
- [ ] Add `body_sexp` text column to Post model
|
||||
- [ ] Run converter, populate `body_sexp` for all posts
|
||||
- [ ] Keep `lexical` column during transition (rollback safety)
|
||||
|
||||
**Phase 3: Editor Integration**
|
||||
- [ ] Update WYSIWYG editor to save sexp directly to DB
|
||||
- [ ] Save endpoint writes to our DB (no Ghost Admin API)
|
||||
- [ ] Preview renders via sexp pipeline
|
||||
|
||||
**Phase 4: Media Uploads**
|
||||
- [ ] Native upload endpoints on blog service (image, audio/video, files)
|
||||
- [ ] OEmbed lookup endpoint (call providers directly)
|
||||
- [ ] Retarget editor upload calls to native endpoints
|
||||
|
||||
**Phase 5: Rendering Pipeline**
|
||||
- [ ] `post_data()` reads `body_sexp` instead of `html`
|
||||
- [ ] Render through sexp evaluator + HTML renderer
|
||||
- [ ] Components in post content resolve from registry
|
||||
|
||||
### 4.2 — Membership Migration (Weeks 5-7)
|
||||
|
||||
**Phase 6: Membership Decoupling**
|
||||
- [ ] Rename `ghost_labels` → `labels`, drop `ghost_id`
|
||||
- [ ] Rename `ghost_tiers` → `membership_tiers`, drop `ghost_id`
|
||||
- [ ] Clean User model: drop `ghost_id`, `ghost_status`, `ghost_subscribed`, `ghost_note`, `ghost_raw`
|
||||
- [ ] Add `membership_tier_id` FK, `membership_status` enum
|
||||
|
||||
**Phase 7: Newsletter System**
|
||||
- [ ] Native newsletter model (replaces `ghost_newsletters`)
|
||||
- [ ] Integrate transactional email provider (Postmark/SES/SMTP)
|
||||
- [ ] Newsletter templates as sexp (rendered to email-safe HTML)
|
||||
- [ ] Unsubscribe handling (tokenised links, List-Unsubscribe header)
|
||||
- [ ] Post → email campaign workflow
|
||||
|
||||
**Phase 8: Subscription & Payment**
|
||||
- [ ] Stripe Checkout for subscription creation
|
||||
- [ ] Stripe Webhooks for subscription lifecycle
|
||||
- [ ] Native `subscriptions` table (replaces `ghost_subscriptions`)
|
||||
- [ ] Customer portal via Stripe hosted portal
|
||||
- [ ] Unified payment handling (SumUp one-off + Stripe recurring)
|
||||
|
||||
### 4.3 — Ghost Deletion (Week 8)
|
||||
|
||||
**Phase 9: Remove Ghost**
|
||||
- [ ] Delete `blog/bp/blog/ghost/` directory
|
||||
- [ ] Delete `blog/bp/blog/ghost_db.py`
|
||||
- [ ] Delete `blog/bp/blog/web_hooks/`
|
||||
- [ ] Delete `shared/infrastructure/ghost_admin_token.py`
|
||||
- [ ] Delete `account/services/ghost_membership.py`
|
||||
- [ ] Remove Ghost Docker service
|
||||
- [ ] Remove all `GHOST_*` env vars
|
||||
- [ ] Rename `ghost_content.py` → `content.py`
|
||||
- [ ] Rename `ghost_membership_entities.py` → `membership.py`
|
||||
- [ ] Alembic migration to drop all `ghost_id` columns
|
||||
|
||||
---
|
||||
|
||||
## Track 5: Sexp Page Architecture (Weeks 4-7)
|
||||
|
||||
*Convert remaining Jinja templates to sexp. Can run in parallel with Ghost removal membership phases.*
|
||||
|
||||
### 5.1 — Page Layouts as Sexp (Phase 5 of sexp-architecture)
|
||||
|
||||
**When:** Week 4-5
|
||||
|
||||
- [ ] Convert base page layout to sexp (head, nav, main, footer)
|
||||
- [ ] Convert blog post page layout
|
||||
- [ ] Convert market product page layout
|
||||
- [ ] Convert event calendar page layout
|
||||
- [ ] Component composition replaces template inheritance
|
||||
|
||||
### 5.2 — Routes as Sexp (Phase 6 of sexp-architecture)
|
||||
|
||||
**When:** Week 6-7
|
||||
|
||||
- [ ] `defroute` form for declaring routes as sexp
|
||||
- [ ] Route dispatch from sexp expressions
|
||||
- [ ] Middleware chain as sexp pipeline
|
||||
- [ ] Content negotiation: same route serves HTML or sexp based on `Accept` header
|
||||
|
||||
---
|
||||
|
||||
## Track 6: Sexp Internal Protocol (Weeks 6-10)
|
||||
|
||||
*Replace HTTP+HMAC between services with native sexp protocol. This is the protocol playground.*
|
||||
|
||||
### 6.1 — Wire Format (Week 6)
|
||||
|
||||
- [ ] Define framing: length-prefixed sexp over Unix sockets (internal) and TCP/TLS (external)
|
||||
- [ ] Lock the spec: `:key value` attrs, `#t/#f` booleans, `()` empty list — no alternatives
|
||||
- [ ] Error model: `(err :code 404 :message "not found")`
|
||||
|
||||
### 6.2 — Python Client + Server Library (Weeks 7-8)
|
||||
|
||||
- [ ] `SexpConnection` class: connect, send, receive, stream
|
||||
- [ ] Quart integration: handler that accepts sexp requests, returns sexp responses
|
||||
- [ ] Drop-in replacements: `fetch_sexp()` replacing `fetch_data()` + `fetch_fragment()`
|
||||
- [ ] HMAC auth over sexp (same signing, different envelope)
|
||||
|
||||
### 6.3 — Internal Mesh Migration (Weeks 9-10)
|
||||
|
||||
- [ ] Blog ↔ relations speaking sexp natively
|
||||
- [ ] Events ↔ relations speaking sexp
|
||||
- [ ] Market ↔ relations speaking sexp
|
||||
- [ ] Measure: latency, throughput, error rates vs HTTP+JSON
|
||||
- [ ] All services on sexp internally, HTTP externally
|
||||
|
||||
### 6.4 — ActivityPub over Sexp (Week 10)
|
||||
|
||||
- [ ] Federation between two rose-ash instances using sexp protocol
|
||||
- [ ] Activities as sexp (Create, Follow, Like, Accept)
|
||||
- [ ] Identity verification over sexp (RSA signatures in sexp envelope)
|
||||
- [ ] Stress test: latency, reconnection, error recovery over real network
|
||||
|
||||
---
|
||||
|
||||
## Track 7: Client-Side Sexp (Weeks 8-14)
|
||||
|
||||
*Depends on stable internal protocol and sexp page architecture.*
|
||||
|
||||
### 7.1 — sexpr.js Core Runtime (Weeks 8-10)
|
||||
|
||||
- [ ] Parser + serialiser (<5KB gzipped)
|
||||
- [ ] DOM renderer (sexp tree → DOM nodes)
|
||||
- [ ] Mutation engine: `swap!`, `batch!`, `class!`, `request!`
|
||||
- [ ] Component system: `defcomp`, slots, content-addressed caching
|
||||
|
||||
### 7.2 — sexpr.js Hypermedia (Weeks 10-12)
|
||||
|
||||
- [ ] Form handling (sexp forms → HTTP POST or sexp verb)
|
||||
- [ ] Navigation (client-side routing, partial page updates)
|
||||
- [ ] Streaming (bidirectional sexp over WebSocket, then QUIC)
|
||||
- [ ] DevTools: sexp inspector, component browser, REPL
|
||||
|
||||
### 7.3 — Content-Addressed Caching (Week 12)
|
||||
|
||||
**Ghost Removal Phase 10**
|
||||
- [ ] Hash post body → content address
|
||||
- [ ] Client caches in localStorage keyed by SHA3 hash
|
||||
- [ ] Server sends manifest (slug → hash)
|
||||
- [ ] Unchanged content served entirely from client cache
|
||||
|
||||
### 7.4 — Browser Extension (Weeks 12-14)
|
||||
|
||||
- [ ] Chrome/Firefox extension intercepts `Accept` header, requests sexp
|
||||
- [ ] Extension runs sexpr.js, renders directly to DOM
|
||||
- [ ] Bypasses HTML serialisation entirely (Tier 1 client)
|
||||
- [ ] Performance target: ~80ms page load
|
||||
|
||||
---
|
||||
|
||||
## Track 8: Native Client & Federation (Weeks 14-20)
|
||||
|
||||
*The long-term vision. Depends on everything above being stable.*
|
||||
|
||||
### 8.1 — Rust Client Library (Weeks 14-16)
|
||||
|
||||
- [ ] Sexp parser + serialiser in Rust
|
||||
- [ ] QUIC transport (quinn library)
|
||||
- [ ] Connection management, multiplexing, backpressure
|
||||
- [ ] Auth: OAuth bearer tokens over sexp
|
||||
|
||||
### 8.2 — Rust Native Client (Weeks 16-18)
|
||||
|
||||
- [ ] TUI or GUI rendering of sexp pages
|
||||
- [ ] Full Tier 2 client: ~20ms page load
|
||||
- [ ] Platform accessibility APIs (AccessKit)
|
||||
- [ ] Content-addressed local cache (SQLite or filesystem)
|
||||
|
||||
### 8.3 — Client-as-Node (Weeks 18-20)
|
||||
|
||||
- [ ] Rust client is an AP instance with inbox/outbox
|
||||
- [ ] IPFS node for content persistence when offline
|
||||
- [ ] Posts queue when recipient is offline, deliver on reconnect
|
||||
- [ ] GPU sharing: artdag workers on member desktops
|
||||
- [ ] Cooperative compute mesh: relay server as lightweight matchmaker
|
||||
|
||||
### 8.4 — Multi-Instance Federation (Week 20)
|
||||
|
||||
- [ ] Second rose-ash instance for another cooperative
|
||||
- [ ] Full federation over sexp protocol
|
||||
- [ ] Cross-instance commerce (browse, cart, checkout with federated identity)
|
||||
- [ ] Cross-instance governance (propose, vote, ratify across co-ops)
|
||||
|
||||
---
|
||||
|
||||
## Track 9: Scalability (As Needed)
|
||||
|
||||
*Apply each tier when traffic demands it, not before.*
|
||||
|
||||
### Tier 1 (when hitting Tier 0 limits)
|
||||
- [ ] Concurrent AP delivery
|
||||
- [ ] Fragment circuit breaker
|
||||
- [ ] Read replicas
|
||||
- [ ] Data caching layer
|
||||
|
||||
### Tier 2 (when hitting Tier 1 limits)
|
||||
- [ ] Edge-side fragment composition (Nginx SSI)
|
||||
- [ ] Redis Streams event delivery
|
||||
- [ ] CDN for static + cached content
|
||||
- [ ] Horizontal scaling with replicas
|
||||
|
||||
### Tier 3 (when hitting Tier 2 limits)
|
||||
- [ ] Dedicated AP delivery service
|
||||
- [ ] Domain health tracking
|
||||
- [ ] Table partitioning
|
||||
- [ ] DTO caching layer
|
||||
|
||||
---
|
||||
|
||||
## Schedule Overview
|
||||
|
||||
```
|
||||
Week 1-2: Track 1 (stability) + Track 2.1 (cart split)
|
||||
Week 2-3: Track 2.2-2.3 (fragments, events) + Track 3.1 (new relations)
|
||||
Week 3-5: Track 4.1 (Ghost content migration) + Track 3.2-3.3 (junction migrations)
|
||||
Week 4-5: Track 5.1 (sexp page layouts)
|
||||
Week 5-7: Track 4.2 (Ghost membership) + Track 5.2 (sexp routes)
|
||||
Week 6-8: Track 6.1-6.2 (sexp protocol wire format + library)
|
||||
Week 8: Track 4.3 (delete Ghost)
|
||||
Week 8-10: Track 6.3-6.4 (internal mesh + AP federation) + Track 7.1 (sexpr.js core)
|
||||
Week 10-12: Track 7.2-7.3 (sexpr.js hypermedia + caching)
|
||||
Week 12-14: Track 7.4 (browser extension)
|
||||
Week 14-18: Track 8.1-8.2 (Rust client)
|
||||
Week 18-20: Track 8.3-8.4 (client-as-node + multi-instance federation)
|
||||
```
|
||||
|
||||
**Tracks 1 (stability) and 9 (scalability) run continuously as needed.**
|
||||
|
||||
---
|
||||
|
||||
## Critical Path
|
||||
|
||||
The longest dependency chain:
|
||||
|
||||
```
|
||||
Decoupling (done) → Cart split (W1-2) → Ghost content (W3-5) → Sexp pages (W4-5)
|
||||
→ Sexp routes (W6-7) → Internal protocol (W6-10) → sexpr.js (W8-14)
|
||||
→ Rust client (W14-18) → Client-as-node (W18-20)
|
||||
```
|
||||
|
||||
**Ghost removal is the linchpin.** Everything before it enables it. Everything after it depends on it. Content must be sexp before the protocol can serve sexp to clients.
|
||||
|
||||
---
|
||||
|
||||
## When to Add New Features vs Fix Bugs
|
||||
|
||||
**Rule: stabilise before extending.**
|
||||
|
||||
- **Before each Track**: run the bug sweep (Track 1.3). Fix what's broken.
|
||||
- **New entities and relations** (Track 3): best done in Weeks 2-5, after decoupling is clean but before Ghost removal changes the content model. The relation registry makes adding new entity types trivial — just add a `defrelation` and the nav/UI auto-generates.
|
||||
- **New features on existing entities**: during or after the Track that touches them. Don't add features to Ghost-backed content — wait for sexp content.
|
||||
- **Performance work**: only when measured. Don't optimise before Track 1.2 (Tier 0 scalability).
|
||||
|
||||
---
|
||||
|
||||
## One Developer Advantage
|
||||
|
||||
This plan looks like a year of committee work. For a single developer with AI tools, it's ~20 weeks of focused building. No coordination overhead, no design reviews, no waiting for other teams. Each track produces running code, not specifications. The protocol grows from working infrastructure, not from RFCs.
|
||||
|
||||
The Tier 0 strategy means rose-ash works at every stage. No big bang migration. No "it'll work when everything's done." Every week ships something that makes the platform better, and the protocol emerges from the practice.
|
||||
473
docs/relation-system-plan.md
Normal file
473
docs/relation-system-plan.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# Flexible Entity Relationship System
|
||||
|
||||
## Context
|
||||
|
||||
The rose-ash platform has 9 microservices with decoupled entities (posts, pages, markets, calendars, entries, products, etc.) connected via a generic `ContainerRelation` table. The current system is limited:
|
||||
|
||||
- **Parent-child only** — no cardinality, no relation types, no many-to-many
|
||||
- **Feature flags gate everything** — `PageConfig.features["market"]=true` must be set before creating a market on a page
|
||||
- **Navigation is disconnected** — `rebuild_navigation()` is a no-op; MenuNode is manually managed
|
||||
- **Per-domain boilerplate** — each service has its own container-nav fragment handler, its own create-and-attach flow
|
||||
- **No relation semantics** — you can't distinguish *how* two entities are connected, only *that* they are
|
||||
|
||||
The s-expression engine (phases 1-7 complete) gives us a declarative language to define relations and auto-generate UI. Building this now means the remaining sexp work gets designed around the relation model.
|
||||
|
||||
**Goal**: A registry-driven system where relation types are declared in s-expressions, cardinality is enforced, navigation is auto-generated, and attach/detach is generic.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **Relations is a graph store, not an orchestrator.** Domain services create their own entities, then call `relate` on the relations service. Relations validates cardinality and stores the link. It never calls out to domain services to create entities.
|
||||
|
||||
2. **All entity connections live in ContainerRelation.** Including many-to-many (e.g. post↔calendar_entry). Single source of truth. Domain-specific junction tables (like `calendar_entry_posts`) migrate here.
|
||||
|
||||
## Architecture: Three Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ RELATION REGISTRY (shared/sexp/relations.py) │
|
||||
│ defrelation declarations — loaded at startup by all │
|
||||
│ services from shared code. Pure data, no DB. │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ RELATION STORAGE (relations service, db_relations) │
|
||||
│ container_relations table + relation_type column. │
|
||||
│ Graph store. Validates against registry. │
|
||||
│ Domain services create entities, then call relate. │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ RELATION CONSUMERS (all services) │
|
||||
│ Nav auto-generation, UI components, fragment rendering │
|
||||
│ Read registry to know how to display/manage relations │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase A: Relation Registry
|
||||
|
||||
### A.1 — `defrelation` s-expression form
|
||||
|
||||
New special form for the evaluator, alongside `defcomp`:
|
||||
|
||||
```scheme
|
||||
(defrelation :page->market
|
||||
:from "page"
|
||||
:to "market"
|
||||
:cardinality :one-to-many
|
||||
:inverse :market->page
|
||||
:nav :submenu
|
||||
:nav-icon "fa fa-shopping-bag"
|
||||
:nav-label "markets")
|
||||
|
||||
(defrelation :page->calendar
|
||||
:from "page"
|
||||
:to "calendar"
|
||||
:cardinality :one-to-many
|
||||
:inverse :calendar->page
|
||||
:nav :submenu
|
||||
:nav-icon "fa fa-calendar"
|
||||
:nav-label "calendars")
|
||||
|
||||
(defrelation :post->calendar_entry
|
||||
:from "post"
|
||||
:to "calendar_entry"
|
||||
:cardinality :many-to-many
|
||||
:inverse :calendar_entry->post
|
||||
:nav :inline
|
||||
:nav-icon "fa fa-file-alt"
|
||||
:nav-label "events")
|
||||
|
||||
(defrelation :page->menu_node
|
||||
:from "page"
|
||||
:to "menu_node"
|
||||
:cardinality :one-to-one
|
||||
:nav :hidden)
|
||||
```
|
||||
|
||||
### A.2 — RelationDef type
|
||||
|
||||
**File: `shared/sexp/types.py`** — add alongside `Component`, `Lambda`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class RelationDef:
|
||||
name: str # "page->market"
|
||||
from_type: str # "page"
|
||||
to_type: str # "market"
|
||||
cardinality: str # "one-to-one" | "one-to-many" | "many-to-many"
|
||||
inverse: str | None # "market->page"
|
||||
nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden"
|
||||
nav_icon: str | None # "fa fa-shopping-bag"
|
||||
nav_label: str | None # "markets" — display label for nav sections
|
||||
```
|
||||
|
||||
### A.3 — Registry module
|
||||
|
||||
**New file: `shared/sexp/relations.py`**
|
||||
|
||||
```python
|
||||
_RELATION_REGISTRY: dict[str, RelationDef] = {}
|
||||
|
||||
def load_relation_registry() -> None:
|
||||
"""Parse defrelation s-expressions, populate registry."""
|
||||
for source in [_PAGE_MARKET, _PAGE_CALENDAR, _POST_ENTRY, _PAGE_MENU_NODE]:
|
||||
register_relations(source)
|
||||
|
||||
def get_relation(name: str) -> RelationDef | None: ...
|
||||
def relations_from(entity_type: str) -> list[RelationDef]: ...
|
||||
def relations_to(entity_type: str) -> list[RelationDef]: ...
|
||||
```
|
||||
|
||||
Called from `create_base_app()` at startup alongside `load_shared_components()`.
|
||||
|
||||
### A.4 — `defrelation` in the evaluator
|
||||
|
||||
**File: `shared/sexp/evaluator.py`** — add to `_SPECIAL_FORMS`:
|
||||
|
||||
Parses keyword args from the s-expression, creates a `RelationDef`, stores it in `_RELATION_REGISTRY` and the current env.
|
||||
|
||||
### A.5 — Replaces PageConfig feature flags
|
||||
|
||||
- The existence of `defrelation :page->market` means pages *can* have markets. No feature flag needed.
|
||||
- Admin UI queries `relations_from("page")` to show create buttons.
|
||||
- `PageConfig` survives only for SumUp payment credentials. The `features` JSON column is deprecated.
|
||||
|
||||
### A.6 — Tests
|
||||
|
||||
**New file: `shared/sexp/tests/test_relations.py`**
|
||||
- Parse `defrelation`, verify `RelationDef` fields
|
||||
- Registry lookup: `get_relation()`, `relations_from()`, `relations_to()`
|
||||
- Cardinality values validated
|
||||
- Inverse relation lookup
|
||||
|
||||
### Files touched
|
||||
- `shared/sexp/types.py` — add `RelationDef`
|
||||
- `shared/sexp/evaluator.py` — add `defrelation` special form
|
||||
- `shared/sexp/relations.py` — NEW: registry + definitions
|
||||
- `shared/sexp/tests/test_relations.py` — NEW: registry tests
|
||||
- `shared/infrastructure/factory.py` — call `load_relation_registry()` at startup
|
||||
|
||||
---
|
||||
|
||||
## Phase B: Schema Evolution
|
||||
|
||||
### B.1 — Add `relation_type` column
|
||||
|
||||
**File: `shared/models/container_relation.py`**
|
||||
|
||||
```python
|
||||
relation_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
```
|
||||
|
||||
New composite index: `(relation_type, parent_type, parent_id)` for filtered child queries.
|
||||
|
||||
### B.2 — Alembic migration
|
||||
|
||||
**File: `relations/alembic/versions/xxx_add_relation_type.py`**
|
||||
|
||||
1. Add nullable `relation_type` column
|
||||
2. Add indexes
|
||||
3. Backfill existing rows:
|
||||
- `(page, *, market, *)` → `relation_type = "page->market"`
|
||||
- `(page, *, calendar, *)` → `relation_type = "page->calendar"`
|
||||
- `(page, *, menu_node, *)` → `relation_type = "page->menu_node"`
|
||||
|
||||
### B.3 — Add `get_parents()` query
|
||||
|
||||
**File: `shared/services/relationships.py`**
|
||||
|
||||
Inverse of `get_children()` — query by `(child_type, child_id)` with optional `relation_type` filter. Uses existing `ix_container_relations_child` index.
|
||||
|
||||
### B.4 — Update `attach_child()` / `detach_child()`
|
||||
|
||||
Add optional `relation_type` parameter. When provided:
|
||||
- Stored in the relation row
|
||||
- Included in emitted activity `object_data`
|
||||
- Used for cardinality enforcement (Phase C)
|
||||
|
||||
Existing callers without `relation_type` continue to work (backward compatible).
|
||||
|
||||
### Files touched
|
||||
- `shared/models/container_relation.py` — add column + index
|
||||
- `shared/services/relationships.py` — add `relation_type` param, add `get_parents()`
|
||||
- `relations/alembic/versions/` — NEW migration
|
||||
|
||||
---
|
||||
|
||||
## Phase C: Generic Relate/Unrelate API
|
||||
|
||||
### C.1 — New action endpoints on relations service
|
||||
|
||||
**File: `relations/bp/actions/routes.py`**
|
||||
|
||||
**`relate`** — validates against registry, enforces cardinality, delegates to `attach_child()`:
|
||||
```json
|
||||
POST /internal/actions/relate
|
||||
{
|
||||
"relation_type": "page->market",
|
||||
"from_id": 42,
|
||||
"to_id": 7,
|
||||
"label": "Farm Shop",
|
||||
"metadata": {"slug": "farm-shop"}
|
||||
}
|
||||
```
|
||||
|
||||
**`unrelate`** — validates, delegates to `detach_child()`:
|
||||
```json
|
||||
POST /internal/actions/unrelate
|
||||
{
|
||||
"relation_type": "page->market",
|
||||
"from_id": 42,
|
||||
"to_id": 7
|
||||
}
|
||||
```
|
||||
|
||||
**`can-relate`** — pre-flight check (cardinality, registry validation) without creating anything:
|
||||
```json
|
||||
POST /internal/actions/can-relate
|
||||
{
|
||||
"relation_type": "page->market",
|
||||
"from_id": 42
|
||||
}
|
||||
→ {"allowed": true} or {"allowed": false, "reason": "one-to-one already exists"}
|
||||
```
|
||||
|
||||
Domain services call `can-relate` *before* creating the entity to avoid orphans on cardinality failure.
|
||||
|
||||
### C.2 — Typical domain flow
|
||||
|
||||
```
|
||||
1. Domain service receives "create market on page 42" request
|
||||
2. call_action("relations", "can-relate", {relation_type: "page->market", from_id: 42})
|
||||
3. If not allowed → return error to user (no entity created)
|
||||
4. Create the entity locally (market service creates MarketPlace row)
|
||||
5. call_action("relations", "relate", {relation_type: "page->market", from_id: 42, to_id: 7, ...})
|
||||
```
|
||||
|
||||
### C.3 — Cardinality enforcement
|
||||
|
||||
In the `relate` and `can-relate` handlers:
|
||||
|
||||
- **one-to-one**: Check no active relation of this type exists for `from_id`. Reject if found.
|
||||
- **one-to-many**: No limit (current behavior).
|
||||
- **many-to-many**: No limit in either direction.
|
||||
|
||||
### C.4 — Enhanced activity emission
|
||||
|
||||
Activities now include `relation_type` in `object_data`:
|
||||
```python
|
||||
object_data={
|
||||
"relation_type": "page->market",
|
||||
"parent_type": "page",
|
||||
"parent_id": 42,
|
||||
"child_type": "market",
|
||||
"child_id": 7,
|
||||
}
|
||||
```
|
||||
|
||||
### C.5 — Existing endpoints stay as aliases
|
||||
|
||||
`attach-child` and `detach-child` remain during transition. They infer `relation_type` from `(parent_type, child_type)` when not provided.
|
||||
|
||||
### Files touched
|
||||
- `relations/bp/actions/routes.py` — add `relate`, `unrelate`, `can-relate`
|
||||
- `shared/services/relationships.py` — cardinality check in `attach_child()`
|
||||
|
||||
---
|
||||
|
||||
## Phase D: Navigation Auto-Generation
|
||||
|
||||
### D.1 — Generic container-nav fragment
|
||||
|
||||
**New handler: `relations/bp/fragments/routes.py`**
|
||||
|
||||
The relations service becomes a fragment provider. A single generic handler replaces per-service container-nav handlers in market and events:
|
||||
|
||||
```python
|
||||
async def _container_nav_handler():
|
||||
container_type = request.args["container_type"]
|
||||
container_id = int(request.args["container_id"])
|
||||
post_slug = request.args.get("post_slug", "")
|
||||
|
||||
nav_defs = [d for d in relations_from(container_type) if d.nav != "hidden"]
|
||||
parts = []
|
||||
|
||||
for defn in nav_defs:
|
||||
children = await get_children(
|
||||
g.s, container_type, container_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=defn.name,
|
||||
)
|
||||
for child in children:
|
||||
parts.append(render_sexp(
|
||||
'(~relation-nav :href href :name name :icon icon :nav-class nc)',
|
||||
href=_build_href(defn, child, post_slug, ctx),
|
||||
name=child.label or "",
|
||||
icon=defn.nav_icon or "",
|
||||
nc=nav_class,
|
||||
))
|
||||
|
||||
return "\n".join(parts)
|
||||
```
|
||||
|
||||
### D.2 — Nav hints
|
||||
|
||||
| `nav` value | Behavior |
|
||||
|-------------|----------|
|
||||
| `submenu` | Clickable item in container-nav header/sidebar |
|
||||
| `tab` | Tab in a tabbed interface |
|
||||
| `badge` | Count badge on parent |
|
||||
| `inline` | Inline within parent content |
|
||||
| `hidden` | Not shown in navigation |
|
||||
|
||||
### D.3 — `rebuild_navigation()` becomes real
|
||||
|
||||
**File: `shared/services/navigation.py`**
|
||||
|
||||
For now: invalidate nav caches when relations change. The top-level MenuNode entries (depth=0, main nav bar) remain manually managed. Child navigation (markets, calendars under a page) is generated dynamically from the relation registry via the generic container-nav fragment.
|
||||
|
||||
Future: `rebuild_navigation()` could materialize the full nav tree from the relation graph for performance.
|
||||
|
||||
### D.4 — Href computation
|
||||
|
||||
The `ContainerRelation.label` column stores the display name. Add a `metadata` JSON column for additional denormalized data (slug, icon) needed to compute hrefs without fetching from the owner service:
|
||||
|
||||
```python
|
||||
metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# e.g., {"slug": "farm-shop", "icon": "..."}
|
||||
```
|
||||
|
||||
The `relate` action populates this from the caller's payload.
|
||||
|
||||
### Files touched
|
||||
- `relations/bp/fragments/routes.py` — NEW: generic container-nav
|
||||
- `shared/services/navigation.py` — cache invalidation
|
||||
- `shared/models/container_relation.py` — add `metadata` JSON column
|
||||
- `relations/alembic/versions/` — migration for metadata column
|
||||
- Blog, market, events fragment routes — deprecate per-service container-nav
|
||||
|
||||
---
|
||||
|
||||
## Phase E: S-Expression UI Components
|
||||
|
||||
### E.1 — `~relation-nav` component
|
||||
|
||||
**File: `shared/sexp/components.py`** — replaces `~market-link-nav`, `~calendar-link-nav`:
|
||||
|
||||
```scheme
|
||||
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
|
||||
(a :href href :class (or nav-class "...")
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(div name)))
|
||||
```
|
||||
|
||||
### E.2 — `~relation-attach` component
|
||||
|
||||
Generic "add child" button driven by registry:
|
||||
|
||||
```scheme
|
||||
(defcomp ~relation-attach (&key create-url label icon)
|
||||
(a :href create-url
|
||||
:hx-get create-url
|
||||
:hx-target "#main-panel"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100"
|
||||
(when icon (i :class icon))
|
||||
(span (or label "Add"))))
|
||||
```
|
||||
|
||||
### E.3 — `~relation-detach` component
|
||||
|
||||
Confirm-and-remove button:
|
||||
|
||||
```scheme
|
||||
(defcomp ~relation-detach (&key detach-url name)
|
||||
(button :hx-delete detach-url
|
||||
:hx-confirm (str "Remove " name "?")
|
||||
:class "text-red-500 hover:text-red-700 text-sm"
|
||||
(i :class "fa fa-times")))
|
||||
```
|
||||
|
||||
### E.4 — Keep specializations where needed
|
||||
|
||||
`~calendar-entry-nav` has date display logic — keep it as a specialization. The generic handler can delegate to specific components based on `to_type` when richer rendering is needed.
|
||||
|
||||
### Files touched
|
||||
- `shared/sexp/components.py` — add `~relation-nav`, `~relation-attach`, `~relation-detach`
|
||||
|
||||
---
|
||||
|
||||
## Phase F: Migration of Existing Relations
|
||||
|
||||
### F.1 — Migration order
|
||||
|
||||
1. Add `relation_type` column + backfill (Phase B)
|
||||
2. Deploy registry + new endpoints (Phases A, C)
|
||||
3. Switch callers one-by-one to use `can-relate` + `relate`:
|
||||
- `shared/services/market_impl.py` — market creation: check can-relate, create, relate
|
||||
- `events/bp/calendars/services/calendars.py` — calendar creation
|
||||
- `blog/bp/menu_items/services/menu_items.py` — menu node creation
|
||||
4. Deploy generic container-nav fragment (Phase D)
|
||||
5. Switch fragment consumers from per-service to generic
|
||||
6. Deprecate per-service container-nav handlers
|
||||
7. Deprecate `PageConfig.features` column
|
||||
|
||||
### F.2 — CalendarEntryPost migration (many-to-many)
|
||||
|
||||
The `calendar_entry_posts` junction table in db_events is a domain-specific many-to-many. Strategy:
|
||||
|
||||
- Add `defrelation :post->calendar_entry` with `:cardinality :many-to-many`
|
||||
- Migrate existing rows to `ContainerRelation` with `relation_type="post->calendar_entry"`
|
||||
- Update `shared/services/entry_associations.py` to use `relate`/`unrelate`
|
||||
- Eventually drop `calendar_entry_posts` table
|
||||
|
||||
### F.3 — PageConfig simplification
|
||||
|
||||
- Remove `features` JSON column (after all feature-flag checks are replaced by registry lookups)
|
||||
- Keep `PageConfig` for SumUp credentials only
|
||||
- Consider renaming to `PaymentConfig`
|
||||
|
||||
### Files touched (incremental)
|
||||
- `shared/services/market_impl.py` — use `relate`
|
||||
- `events/bp/calendars/services/calendars.py` — use `relate`
|
||||
- `blog/bp/menu_items/services/menu_items.py` — use `relate`
|
||||
- `shared/services/entry_associations.py` — use `relate`/`unrelate`
|
||||
- `blog/bp/post/services/markets.py` — simplify (no feature flag check)
|
||||
- `market/bp/fragments/routes.py` — deprecate container-nav
|
||||
- `events/bp/fragments/routes.py` — deprecate container-nav
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Unit tests
|
||||
- `shared/sexp/tests/test_relations.py` — registry parsing, lookup, cardinality rules
|
||||
- `shared/sexp/tests/test_evaluator.py` — `defrelation` form evaluation
|
||||
- `shared/sexp/tests/test_components.py` — `~relation-nav`, `~relation-attach` rendering
|
||||
|
||||
### Integration tests
|
||||
- Relations service: `relate` with valid/invalid types, cardinality enforcement
|
||||
- `can-relate` pre-flight: returns allowed/denied correctly
|
||||
- `unrelate`: soft-deletes, emits Remove activity
|
||||
- Generic container-nav fragment: returns correct HTML for various relation types
|
||||
|
||||
### Manual testing
|
||||
- Browse site with Playwright after each phase
|
||||
- Verify nav renders correctly
|
||||
- Test create/delete flows for markets and calendars
|
||||
- Check activity bus still fires on relation changes
|
||||
|
||||
---
|
||||
|
||||
## Phase Order & Dependencies
|
||||
|
||||
```
|
||||
Phase A (Registry) ──────┐
|
||||
├──▶ Phase C (API) ──▶ Phase D (Nav) ──▶ Phase F (Migration)
|
||||
Phase B (Schema) ───────┘ ▲
|
||||
│
|
||||
Phase E (UI Components)
|
||||
```
|
||||
|
||||
A and B can run in parallel. C needs both. D and E need C. F is incremental, runs alongside D and E.
|
||||
@@ -598,3 +598,121 @@ rose-ash/
|
||||
**Intelligence** (Phases 10-11): Federation makes s-expressions portable across instances. The LLM makes s-expressions accessible to non-programmers — natural language in, rendered pages out. The system learns from its own data, continuously improving the quality of generated s-expressions.
|
||||
|
||||
Each phase is independently deployable. The end state: a platform where the application logic is expressed in a small, composable, content-addressed language that humans author, LLMs generate, resolvers execute, IPFS stores, and ActivityPub federates.
|
||||
|
||||
---
|
||||
|
||||
## Progress Log
|
||||
|
||||
### Phase 1: S-Expression Core Library — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/`):
|
||||
- `types.py` — Symbol, Keyword, Lambda (callable closure), Component (defcomp), NIL singleton
|
||||
- `parser.py` — Tokenizer + parse/parse_all/serialize. Supports lists, vectors, maps, symbols (~component, <>fragment), keywords, strings, numbers, comments, &key/&rest
|
||||
- `env.py` — Lexical environment with parent-chain scoping
|
||||
- `evaluator.py` — Full evaluator with special forms (if, when, cond, case, and, or, let/let*, lambda/fn, define, defcomp, begin/do, quote, ->, set!) and higher-order forms (map, map-indexed, filter, reduce, some, every?, for-each)
|
||||
- `primitives.py` — 60+ pure builtins: arithmetic, comparison, predicates, strings (str, concat, upper, lower, join, split, starts-with?, ends-with?), collections (list, dict, get, first, last, rest, nth, cons, append, keys, vals, merge, assoc, dissoc, into, range, chunk-every, zip-pairs)
|
||||
- `__init__.py` — Public API
|
||||
|
||||
**Tests** (`shared/sexp/tests/`):
|
||||
- `test_parser.py` — 28 tests (atoms, lists, maps, vectors, comments, errors, serialization, roundtrip)
|
||||
- `test_evaluator.py` — 81 tests (literals, arithmetic, comparison, predicates, special forms, lambda/closures, collections, higher-order, strings, defcomp, dict literals, set!)
|
||||
- **109 tests, all passing**
|
||||
|
||||
**Source material ported from:** `artdag/core/artdag/sexp/parser.py` and `evaluator.py`. Stripped DAG-specific types (Binding), replaced Lambda dataclass with callable closure, added defcomp/Component, added web-oriented string primitives, added &key/&rest support in parser.
|
||||
|
||||
### Phase 2: HTML Renderer — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/html.py`):
|
||||
- HSX-style renderer: s-expression AST → HTML string
|
||||
- ~100 HTML tags recognised (sections, headings, grouping, text, embedded, table, forms, interactive, template)
|
||||
- 14 void elements (br, img, input, meta, link, etc.) — no closing tag
|
||||
- 23 boolean attributes (disabled, checked, required, hidden, etc.)
|
||||
- Text and attribute escaping (XSS prevention: &, <, >, ")
|
||||
- `raw!` for trusted unescaped HTML
|
||||
- `<>` fragment rendering (no wrapper element)
|
||||
- Render-aware special forms: `if`, `when`, `cond`, `let`/`let*`, `begin`/`do`, `map`, `map-indexed`, `filter`, `for-each`, `define`, `defcomp` — these call `_render` on result branches so HTML tags inside control flow work correctly
|
||||
- `_render_component()` — render-aware component calling (vs evaluator's `_call_component` which only evaluates)
|
||||
- `_render_lambda_call()` — lambda bodies containing HTML tags are rendered directly
|
||||
- `_RawHTML` marker type — pre-rendered children pass through without double-escaping
|
||||
- Component children rendered to HTML string and wrapped as `_RawHTML` for safe embedding
|
||||
|
||||
**Key architectural decision:** The renderer maintains a parallel set of special form handlers (`_RENDER_FORMS`) that mirror the evaluator's special forms but call `_render` on results instead of `_eval`. This is necessary because the evaluator doesn't know about HTML tags — `_eval((p "Hello"))` fails with "Undefined symbol: p". The renderer intercepts these forms before they reach the evaluator.
|
||||
|
||||
**Dispatch order in `_render_list`:**
|
||||
1. `raw!` → unescaped HTML
|
||||
2. `<>` → fragment
|
||||
3. `_RENDER_FORMS` (checked before HTML_TAGS because `map` is both a render form and an HTML tag)
|
||||
4. `HTML_TAGS` → element rendering
|
||||
5. `~prefix` → component rendering
|
||||
6. Fallthrough → `_eval` then `_render`
|
||||
|
||||
**Tests** (`shared/sexp/tests/test_html.py`):
|
||||
- 63 tests: escaping (4), atoms (8), elements (6), attributes (8), boolean attrs (4), void elements (7), fragments (3), raw! (3), components (4), expressions with control flow (8), full pages (3), edge cases (5)
|
||||
- **172 total tests across all 3 files, all passing**
|
||||
|
||||
### Phase 3: Async Resolver — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/`):
|
||||
- `resolver.py` — Async tree walker: collects I/O nodes from parsed tree, executes them in parallel via `asyncio.gather()`, substitutes results back, renders to HTML. Multi-pass resolution (up to 5 depth) for cases where resolved values contain further I/O. Graceful degradation: failed I/O nodes substitute empty string instead of crashing.
|
||||
- `primitives_io.py` — I/O primitive registry and handlers:
|
||||
- `(frag "service" "type" :key val ...)` → wraps `fetch_fragment`
|
||||
- `(query "service" "query-name" :key val ...)` → wraps `fetch_data`
|
||||
- `(action "service" "action-name" :key val ...)` → wraps `call_action`
|
||||
- `(current-user)` → user dict from `RequestContext`
|
||||
- `(htmx-request?)` → boolean from `RequestContext`
|
||||
- `RequestContext` — per-request state (user, is_htmx, extras) passed to I/O handlers
|
||||
|
||||
**Resolution strategy:**
|
||||
1. Parse s-expression tree
|
||||
2. Walk tree, collect all I/O nodes (frag, query, action, current-user, htmx-request?)
|
||||
3. Parse each node's positional args + keyword kwargs, evaluating expressions
|
||||
4. Dispatch all I/O in parallel via `asyncio.gather(return_exceptions=True)`
|
||||
5. Substitute results back into tree (fragments wrapped as `_RawHTML` to prevent escaping)
|
||||
6. Repeat up to 5 passes if resolved values introduce new I/O nodes
|
||||
7. Render fully-resolved tree to HTML via Phase 2 renderer
|
||||
|
||||
**Design decisions:**
|
||||
- I/O handlers use deferred imports (inside functions) so `shared.sexp` doesn't depend on infrastructure at import time — only when actually executing I/O
|
||||
- Tests mock at the `execute_io` boundary (patching `shared.sexp.resolver.execute_io`) rather than patching infrastructure imports, keeping tests self-contained with no external dependencies
|
||||
- Fragment results wrapped as `_RawHTML` since they're already-rendered HTML
|
||||
- Identity-based substitution (`id(expr)`) maps I/O nodes back to their tree position
|
||||
|
||||
**Tests** (`shared/sexp/tests/test_resolver.py`):
|
||||
- 27 tests: passthrough rendering (4), I/O collection (8), fragment resolution (3), query resolution (2), parallel I/O (1), request context (4), error handling (2), mixed content (3)
|
||||
- **199 total tests across all 4 files, all passing**
|
||||
|
||||
### Phase 4: Jinja Bridge — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/`):
|
||||
- `jinja_bridge.py` — Two-way bridge between Jinja and s-expressions:
|
||||
- `sexp(source, **kwargs)` — sync render for Jinja templates: `{{ sexp('(~card :title "Hi")') | safe }}`
|
||||
- `sexp_async(source, **kwargs)` — async render with I/O resolution
|
||||
- `register_components(sexp_source)` — load component definitions at startup
|
||||
- `get_component_env()` — access the shared component registry
|
||||
- `setup_sexp_bridge(app)` — register `sexp` and `sexp_async` as Jinja globals
|
||||
- `_get_request_context()` — auto-builds RequestContext from Quart request
|
||||
|
||||
**Integration point**: Call `setup_sexp_bridge(app)` after `setup_jinja(app)` in app factories. Components registered via `register_components()` are available globally across all templates.
|
||||
|
||||
**First migration target: `~link-card`** — unified component replacing 5 separate Jinja templates (`blog/fragments/link_card.html`, `market/fragments/link_card.html`, `events/fragments/link_card.html`, `federation/fragments/link_card.html`, `artdag/l1/app/templates/fragments/link_card.html`). The s-expression component handles image/no-image, brand, and is usable from both Jinja templates and s-expression trees.
|
||||
|
||||
**Tests** (`shared/sexp/tests/test_jinja_bridge.py`):
|
||||
- 19 tests: sexp() rendering (5), component registration (6), link-card migration (5), sexp_async (3)
|
||||
- **218 total tests across all 5 files, all passing**
|
||||
|
||||
### Test Infrastructure — COMPLETE
|
||||
|
||||
**Delivered:**
|
||||
- `test/Dockerfile.unit` — Tier 1: all unit tests (shared + artdag core + L1), pure Python, fast
|
||||
- `test/Dockerfile.integration` — Tier 2: integration tests needing ffmpeg/media pipeline
|
||||
- `docker-compose.dev.yml` — `test-unit` (watch mode) and `test-integration` services, `profiles: [test]`
|
||||
- `dev.sh` — `./dev.sh watch` (auto-rerun on save), `./dev.sh test` (one-shot), `./dev.sh test-integration`
|
||||
- `deploy.sh` — Unit test gate: tests must pass before any images are pushed
|
||||
|
||||
110
docs/sexpr-ai-integration.md
Normal file
110
docs/sexpr-ai-integration.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# S-expressions and AI-Driven Systems
|
||||
|
||||
**Why sexp on the wire is a natural fit for AI agents and LLM-driven interfaces.**
|
||||
|
||||
---
|
||||
|
||||
## LLMs Are Better at Sexp Than HTML
|
||||
|
||||
An LLM generating UI output produces fewer tokens, makes fewer syntax errors, and hallucinates less structure with s-expressions than with HTML:
|
||||
|
||||
```html
|
||||
<div class="card">
|
||||
<h2>Weekend Markets</h2>
|
||||
<p>Three new vendors joining.</p>
|
||||
<a href="/markets/saturday/" class="btn btn-primary">View Details</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
```scheme
|
||||
(div :class "card"
|
||||
(h2 "Weekend Markets")
|
||||
(p "Three new vendors joining.")
|
||||
(a :href "/markets/saturday/" :class "btn btn-primary" "View Details"))
|
||||
```
|
||||
|
||||
Half the tokens. No closing tags to get wrong. No attribute quoting rules to mess up. The structure is unambiguous — every open paren has exactly one close paren. LLMs already handle Lisp-family syntax well because it's regular and context-free.
|
||||
|
||||
---
|
||||
|
||||
## Trivially Parseable by Both Machines and Models
|
||||
|
||||
An AI agent receiving a server response as sexp can parse it, reason about it, modify it, and send it back — all without an HTML parser. The AST *is* the wire format. An agent can:
|
||||
|
||||
- **Read a page**: parse the sexp, extract structured data directly from the tree
|
||||
- **Modify a page**: splice nodes, change attributes, insert components — all tree operations
|
||||
- **Generate a page**: produce valid output with minimal syntax overhead
|
||||
- **Diff two pages**: structural comparison on the AST, not string diffing
|
||||
|
||||
With HTML, the agent has to generate a string, hope it's valid, and the receiver has to parse it back into a tree. With sexp, the tree *is* the string.
|
||||
|
||||
---
|
||||
|
||||
## Components as Tool Calls
|
||||
|
||||
An LLM generating `(use "product-card" :name "Sourdough" :price "3.50")` is structurally identical to an LLM making a tool call. The component registry *is* a tool registry. The parameters *are* tool parameters.
|
||||
|
||||
You could expose your component library to an AI agent as tools and it would produce valid UI as naturally as it calls functions. The agent doesn't need to know HTML structure — it just needs to know the component name and its parameters.
|
||||
|
||||
---
|
||||
|
||||
## Mutations as Agent Actions
|
||||
|
||||
```scheme
|
||||
(swap! "#cart" :append (use "cart-item" :name "Bread"))
|
||||
```
|
||||
|
||||
This is an action with a target, an operation, and a payload. An AI agent orchestrating a UI isn't generating HTML blobs — it's issuing commands in a format it already understands.
|
||||
|
||||
The `request!`, `swap!`, `batch!` primitives from the sexpr.js runtime plan are already shaped like agent tool calls:
|
||||
|
||||
```scheme
|
||||
(batch!
|
||||
(swap! "#notifications" :append
|
||||
(div :class "toast" "Order confirmed"))
|
||||
(class! "#order-btn" :remove "loading")
|
||||
(swap! "#cart-count" :inner "0"))
|
||||
```
|
||||
|
||||
An agent producing this is doing exactly what it does when it calls tools — specifying actions with structured parameters. The only difference is that the actions target a DOM instead of an API.
|
||||
|
||||
---
|
||||
|
||||
## Content-Addressed Components Enable AI Caching
|
||||
|
||||
If an agent has seen `(use "product-card" ...)` before and knows its hash hasn't changed, it doesn't need to re-interpret the component definition. It knows the schema — name, price, image — and can generate invocations without seeing the template.
|
||||
|
||||
This is analogous to how tool definitions are cached in an agent's context. The component registry becomes a stable vocabulary that the agent learns once and reuses across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Practical Example: AI Page Builder on Rose-Ash
|
||||
|
||||
Imagine an AI assistant that helps users build pages on rose-ash. Today it would need to generate HTML or manipulate a Lexical JSON document. With sexp content, it just produces:
|
||||
|
||||
```scheme
|
||||
(section
|
||||
(h2 "Our Markets")
|
||||
(each markets (lambda (m)
|
||||
(use "vendor-card" :name (get m "name") :stall (get m "stall"))))
|
||||
(use "calendar-widget" :calendar-id 42))
|
||||
```
|
||||
|
||||
That's a page with embedded components, data binding, and iteration — and the AI produced it with minimal tokens, no closing tag errors, and using the actual component library as its vocabulary. The server evaluates it exactly as written. The client caches and renders it. The same string works for SSR, client rendering, AI generation, and API responses.
|
||||
|
||||
---
|
||||
|
||||
## The Deeper Point
|
||||
|
||||
HTML was designed for documents viewed by humans in browsers. S-expressions are designed for trees manipulated by programs. As more of the web becomes program-to-program (APIs, agents, server-driven UI, real-time mutations), the wire format should match.
|
||||
|
||||
Rose-ash already builds its server-side rendering on sexp. Extending that to the wire completes the picture — one format that serves:
|
||||
|
||||
- **Server rendering** (sexp → HTML for SEO/first paint)
|
||||
- **Client rendering** (sexp → DOM via sexpr.js)
|
||||
- **API responses** (sexp as structured data)
|
||||
- **AI generation** (agents produce sexp as tool output)
|
||||
- **Content storage** (posts, components, layouts all sexp)
|
||||
- **Real-time mutations** (sexp commands over WebSocket)
|
||||
|
||||
One syntax. Every boundary.
|
||||
75
docs/sexpr-internal-protocol-first.md
Normal file
75
docs/sexpr-internal-protocol-first.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Internal Protocol First: Building sexpr:// on the Microservice Mesh
|
||||
|
||||
**The internal mesh is the testing ground, playground, and proving ground for the public protocol. Build it internally, battle-test it on real traffic, then open the door.**
|
||||
|
||||
---
|
||||
|
||||
## Why Internal First
|
||||
|
||||
You control both sides. Blog, market, events, relations — they're all your code. You don't need browser vendors or standards bodies. You write the client library in Python (or Rust), you write the server handler in Quart, and you iterate until it works.
|
||||
|
||||
---
|
||||
|
||||
## The Traffic Is Real
|
||||
|
||||
Hundreds of inter-service calls per page load — `fetch_data`, `fetch_fragment`, `call_action`, `send_internal_activity`. That's real load, real error cases, real latency requirements. Not toy examples.
|
||||
|
||||
---
|
||||
|
||||
## Replace HTTP Internally First
|
||||
|
||||
Right now services talk over HMAC-signed HTTP. Replace that transport with `sexpr://` over QUIC or even Unix sockets and you've got a working protocol implementation carrying production traffic. The public HTTP interface stays untouched — the protocol lives behind the load balancer.
|
||||
|
||||
---
|
||||
|
||||
## The Verbs Already Exist
|
||||
|
||||
The current inter-service API maps directly to the open verb system:
|
||||
|
||||
| Current | sexpr:// equivalent |
|
||||
|---|---|
|
||||
| `fetch_data(service, query, params)` | `(GET "/internal/data/{query}" :params ...)` |
|
||||
| `call_action(service, action, payload)` | `({action} "/internal/" :body ...)` |
|
||||
| `fetch_fragment(service, fragment, params)` | `(GET "/internal/fragments/{fragment}" :params ...)` |
|
||||
| `send_internal_activity(service, activity)` | `(deliver :to service :activity ...)` |
|
||||
|
||||
You're not inventing traffic patterns — you're giving the existing ones a proper wire format. `call_action("relations", "relate", payload)` is literally `(relate ...)` with the ceremony stripped away.
|
||||
|
||||
---
|
||||
|
||||
## When It's Solid, Open the Door
|
||||
|
||||
The same protocol library that blog uses to talk to relations, a Rust client uses to talk to blog. The public interface is just the internal protocol with auth and TLS on top. No separate "public API" to maintain.
|
||||
|
||||
```
|
||||
Internal (today): blog ──HTTP+HMAC──▶ relations
|
||||
Internal (sexpr://): blog ──sexpr://──▶ relations (same protocol)
|
||||
Public (sexpr://): rust-client ──sexpr://+TLS──▶ blog (same protocol + auth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You're Actually Building
|
||||
|
||||
A real protocol stack — not a spec document, but running code:
|
||||
|
||||
- **Client library** (Python first, Rust later) — connect, send sexp, receive sexp, handle streams
|
||||
- **Server handler** (Quart integration) — accept connections, route by verb+path, return sexp
|
||||
- **Connection management** — multiplexing, keepalive, backpressure
|
||||
- **Error model** — `(err :code 404 :message "not found")` instead of HTTP status codes
|
||||
- **Streaming** — bidirectional sexp streams for real-time (replace WebSocket + SSE)
|
||||
- **Auth** — HMAC for internal, OAuth bearer for public (same envelope, different `:auth` value)
|
||||
|
||||
All battle-tested on your own infrastructure before anyone else ever sees it.
|
||||
|
||||
---
|
||||
|
||||
## Development Sequence
|
||||
|
||||
1. **Define the wire format** — framing (length-prefixed sexp over TCP/QUIC/Unix socket)
|
||||
2. **Build Python client+server** — drop-in replacement for `fetch_data`/`call_action`/`fetch_fragment`
|
||||
3. **Run internal mesh on sexpr://** — blog ↔ relations ↔ events ↔ market all speaking sexp natively
|
||||
4. **Measure** — latency, throughput, error rates vs current HTTP+JSON
|
||||
5. **Build Rust client library** — same protocol, compiled and fast
|
||||
6. **Open public routes** — `Accept: application/sexp` serves the same trees internally and externally
|
||||
7. **Rust native client** — full Tier 2 client speaking the protocol that's been running in production for months
|
||||
757
docs/sexpr-js-runtime-plan.md
Normal file
757
docs/sexpr-js-runtime-plan.md
Normal file
@@ -0,0 +1,757 @@
|
||||
# sexpr.js — Development Plan
|
||||
|
||||
**An isomorphic S-expression runtime for the web.**
|
||||
**Code is data is DOM.**
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
Replace HTML as the document/wire format with S-expressions. A single JavaScript library runs on both server (Node/Deno/Bun) and client (browser). The server composes and sends S-expressions over HTTP or WebSocket. The client parses them and renders/mutates the DOM. Because S-expressions are homoiconic, hypermedia controls (fetching, swapping, transitions) are native to the format — not bolted on as special attributes.
|
||||
|
||||
The framework is not a Lisp. It is a document runtime that happens to use S-expression syntax because that syntax makes documents and commands interchangeable.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ sexpr.js (shared) │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────────────┐ │
|
||||
│ │ Parser / │ │ Component │ │ Mutation │ │
|
||||
│ │ Serializer │ │ Registry │ │ Engine │ │
|
||||
│ └───────────┘ └───────────┘ └────────────────┘ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────────────┐ │
|
||||
│ │ Style │ │ Event │ │ VTree Diff │ │
|
||||
│ │ Compiler │ │ System │ │ & Patch │ │
|
||||
│ └───────────┘ └───────────┘ └────────────────┘ │
|
||||
└─────────────┬───────────────────────────┬───────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌─────────▼─────────┐
|
||||
│ Server Adapter │ │ Client Adapter │
|
||||
│ │ │ │
|
||||
│ • renderToStr() │ │ • renderToDOM() │
|
||||
│ • diff on AST │ │ • mount/hydrate │
|
||||
│ • HTTP handler │ │ • WebSocket recv │
|
||||
│ • WS push │ │ • event dispatch │
|
||||
│ • SSR bootstrap │ │ • service worker │
|
||||
└──────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
The core is environment-agnostic. Thin adapters provide DOM APIs (client) or string serialization (server). Both sides share the parser, component system, style compiler, and mutation engine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Runtime (Weeks 1–4)
|
||||
|
||||
The foundation. A single ES module that works in any JS environment.
|
||||
|
||||
### 1.1 Parser & Serializer
|
||||
|
||||
**Parser** — tokenizer + recursive descent, producing an AST of plain JS objects.
|
||||
|
||||
- Atoms: strings (`"hello"`), numbers (`42`, `3.14`), booleans (`#t`, `#f`), symbols (`div`, `my-component`), keywords (`:class`, `:on-click`)
|
||||
- Lists: `(tag :attr "val" children...)`
|
||||
- Comments: `; line comment` and `#| block comment |#`
|
||||
- Quasiquote / unquote: `` ` `` and `,` for template interpolation on the server
|
||||
- Streaming parser variant for large documents (SAX-style)
|
||||
|
||||
**Serializer** — AST back to S-expression string. Round-trip fidelity. Pretty-printer with configurable indentation.
|
||||
|
||||
**Deliverables:**
|
||||
- `parse(string) → AST`
|
||||
- `serialize(AST) → string`
|
||||
- `prettyPrint(AST, opts) → string`
|
||||
- Streaming: `createParser()` returning a push-based parser
|
||||
- Comprehensive test suite (edge cases: nested strings, escapes, unicode, deeply nested structures)
|
||||
- Benchmark: parse speed vs JSON.parse for equivalent data
|
||||
|
||||
### 1.2 AST Representation
|
||||
|
||||
The AST should be cheap to construct, diff, and serialize. Plain objects, not classes:
|
||||
|
||||
```javascript
|
||||
// Atoms
|
||||
{ type: 'symbol', value: 'div' }
|
||||
{ type: 'keyword', value: 'class' }
|
||||
{ type: 'string', value: 'hello' }
|
||||
{ type: 'number', value: 42 }
|
||||
{ type: 'boolean', value: true }
|
||||
|
||||
// List (the fundamental structure)
|
||||
[head, ...rest] // plain arrays — cheap, diffable, JSON-compatible
|
||||
|
||||
// Element sugar (derived during render, not stored)
|
||||
// (div :class "box" (p "hi")) →
|
||||
// [sym('div'), kw('class'), str('box'), [sym('p'), str('hi')]]
|
||||
```
|
||||
|
||||
**Design decision:** ASTs are plain arrays and objects. No custom classes. This means they serialize to JSON trivially — enabling WebSocket transmission, IndexedDB caching, and worker postMessage without structured clone overhead.
|
||||
|
||||
### 1.3 Element Rendering
|
||||
|
||||
The core render function: AST → target output.
|
||||
|
||||
**Shared logic** (environment-agnostic):
|
||||
- Parse keyword attributes from element expressions
|
||||
- Resolve component references
|
||||
- Evaluate special forms (`if`, `each`, `list`, `let`, `slot`)
|
||||
- Compile inline styles
|
||||
|
||||
**Client adapter** — `renderToDOM(ast, env) → Node`:
|
||||
- Creates real DOM nodes via `document.createElement`
|
||||
- Handles SVG namespace detection
|
||||
- Registers event handlers
|
||||
- Returns live DOM node
|
||||
|
||||
**Server adapter** — `renderToString(ast, env) → string`:
|
||||
- Produces HTML string for initial page load (SEO, fast first paint)
|
||||
- Inserts hydration markers so the client can attach without full re-render
|
||||
- Escapes text content for safety
|
||||
|
||||
### 1.4 Style System
|
||||
|
||||
Styles as S-expressions, compiled to CSS strings. Isomorphic: the same style expressions produce CSS on server (injected into `<style>` tags in HTML) and client (injected into document).
|
||||
|
||||
```scheme
|
||||
(style ".card"
|
||||
:background "#1a1a2e"
|
||||
:border-radius "8px"
|
||||
:padding "1.5rem"
|
||||
:hover (:background "#2a2a3e") ; nested pseudo-classes
|
||||
:media "(max-width: 600px)"
|
||||
(:padding "1rem"))
|
||||
|
||||
(style "@keyframes fade-in"
|
||||
(from :opacity "0")
|
||||
(to :opacity "1"))
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Nested selectors (like Sass)
|
||||
- `@media`, `@keyframes`, `@container` as nested S-expressions
|
||||
- CSS variables as regular properties
|
||||
- Optional: scoped styles per component (auto-prefix class names)
|
||||
- Output: raw CSS string or `<style>` DOM node
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Hypermedia Engine (Weeks 5–8)
|
||||
|
||||
The mutation layer. This is where homoiconicity pays off.
|
||||
|
||||
### 2.1 Mutation Primitives
|
||||
|
||||
Since commands and content share the same syntax, the server can send either — or both — in a single response:
|
||||
|
||||
```scheme
|
||||
;; Content (renders to DOM)
|
||||
(div :class "card" (p "Hello"))
|
||||
|
||||
;; Command (mutates existing DOM)
|
||||
(swap! "#card-1" :inner (p "Updated"))
|
||||
|
||||
;; Compound (multiple mutations atomically)
|
||||
(batch!
|
||||
(swap! "#notifications" :append
|
||||
(div :class "toast" "Saved!"))
|
||||
(class! "#save-btn" :remove "loading")
|
||||
(transition! "#toast" :type "slide-in"))
|
||||
```
|
||||
|
||||
**Full primitive set:**
|
||||
|
||||
| Primitive | Purpose |
|
||||
|---|---|
|
||||
| `swap!` | Replace/insert content (`:inner`, `:outer`, `:before`, `:after`, `:prepend`, `:append`, `:delete`, `:morph`) |
|
||||
| `batch!` | Execute multiple mutations atomically |
|
||||
| `class!` | Add/remove/toggle CSS classes |
|
||||
| `attr!` | Set/remove attributes |
|
||||
| `style!` | Inline style manipulation |
|
||||
| `transition!` | CSS transitions and animations |
|
||||
| `wait!` | Delay between batched mutations |
|
||||
| `dispatch!` | Fire custom events |
|
||||
|
||||
### 2.2 Request/Response Cycle
|
||||
|
||||
The equivalent of HTMX's `hx-get`, `hx-post`, etc. — but as native S-expressions:
|
||||
|
||||
```scheme
|
||||
(request!
|
||||
:method "POST"
|
||||
:url "/api/todos"
|
||||
:target "#todo-list"
|
||||
:swap inner
|
||||
:include "#todo-form" ; serialize form data
|
||||
:indicator "#spinner" ; show during request
|
||||
:confirm "Are you sure?" ; browser confirm dialog
|
||||
:timeout 5000
|
||||
:retry 3
|
||||
:on-error (swap! "#errors" :inner
|
||||
(p :class "error" "Request failed")))
|
||||
```
|
||||
|
||||
**Client-side implementation:**
|
||||
1. Serialize form data (if `:include` specified)
|
||||
2. Show indicator
|
||||
3. `fetch()` with `Accept: text/x-sexpr` header
|
||||
4. Parse response as S-expression
|
||||
5. If response is a mutation command → execute it
|
||||
6. If response is content → wrap in `swap!` using `:target` and `:swap`
|
||||
7. Hide indicator
|
||||
8. Handle errors
|
||||
|
||||
**Server-side helpers:**
|
||||
```javascript
|
||||
// Server constructs response using the same library
|
||||
const { s, sym, kw, str } = require('sexpr');
|
||||
|
||||
app.post('/api/todos', (req, res) => {
|
||||
const todo = createTodo(req.body);
|
||||
res.type('text/x-sexpr').send(
|
||||
s.serialize(
|
||||
s.batch(
|
||||
s.swap('#todo-list', 'append', todoComponent(todo)),
|
||||
s.swap('#todo-count', 'inner', s.text(`${count} remaining`)),
|
||||
s.swap('#todo-input', 'attr', { value: '' })
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 WebSocket Channel
|
||||
|
||||
For real-time updates. The server pushes S-expression mutations over WebSocket:
|
||||
|
||||
```scheme
|
||||
;; Server → Client (push)
|
||||
(batch!
|
||||
(swap! "#user-42-status" :inner
|
||||
(span :class "online" "Online"))
|
||||
(swap! "#chat-messages" :append
|
||||
(div :class "message"
|
||||
(strong "Alice") " just joined")))
|
||||
```
|
||||
|
||||
**Protocol:**
|
||||
- Content-type negotiation: `text/x-sexpr` over HTTP, raw S-expr strings over WS
|
||||
- Client reconnects automatically with exponential backoff
|
||||
- Server can send any mutation at any time — the client just evaluates it
|
||||
- Optional: message IDs for acknowledgment, ordering guarantees
|
||||
|
||||
### 2.4 Event Binding
|
||||
|
||||
Declarative event binding that works both inline and as post-render setup:
|
||||
|
||||
```scheme
|
||||
;; Inline (in element definition)
|
||||
(button :on-click (request! :method "POST" :url "/api/like"
|
||||
:target "#like-count" :swap inner)
|
||||
"Like")
|
||||
|
||||
;; Declarative (standalone, for progressive enhancement)
|
||||
(on-event! "#search-input" "input"
|
||||
:debounce 300
|
||||
(request! :method "GET"
|
||||
:url (concat "/api/search?q=" (value event.target))
|
||||
:target "#results" :swap inner))
|
||||
|
||||
;; Keyboard shortcuts
|
||||
(on-event! "body" "keydown"
|
||||
:filter (= event.key "Escape")
|
||||
(class! "#modal" :remove "open"))
|
||||
```
|
||||
|
||||
**Event modifiers** (inspired by Vue/Svelte):
|
||||
- `:debounce 300` — debounce in ms
|
||||
- `:throttle 500` — throttle in ms
|
||||
- `:once` — fire once then unbind
|
||||
- `:prevent` — preventDefault
|
||||
- `:stop` — stopPropagation
|
||||
- `:filter (expr)` — conditional guard
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Component System (Weeks 9–12)
|
||||
|
||||
### 3.1 Component Definition & Instantiation
|
||||
|
||||
Components are parameterized S-expression templates. Not classes. Not functions. Data.
|
||||
|
||||
```scheme
|
||||
(component "todo-item" (id text done)
|
||||
(style ".todo-item" :display "flex" :align-items "center" :gap "0.75rem")
|
||||
(style ".todo-item.done .text" :text-decoration "line-through" :opacity "0.5")
|
||||
|
||||
(li :class (if done "todo-item done" "todo-item") :id (concat "todo-" id)
|
||||
(span :class "check"
|
||||
:on-click (request! :method "POST"
|
||||
:url (concat "/api/todos/" id "/toggle")
|
||||
:target (concat "#todo-" id) :swap outer)
|
||||
(if done "◉" "○"))
|
||||
(span :class "text" text)
|
||||
(button :class "delete"
|
||||
:on-click (request! :method "DELETE"
|
||||
:url (concat "/api/todos/" id)
|
||||
:target (concat "#todo-" id) :swap delete)
|
||||
"×")))
|
||||
|
||||
;; Usage
|
||||
(use "todo-item" :id "1" :text "Buy milk" :done #f)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Lexically scoped parameters
|
||||
- Styles co-located with component (auto-scoped or global, configurable)
|
||||
- Slots for content projection: `(slot)` for default, `(slot "header")` for named
|
||||
- Isomorphic: same component definition renders on server (to string) or client (to DOM)
|
||||
|
||||
### 3.2 Slots & Composition
|
||||
|
||||
```scheme
|
||||
(component "card" (title)
|
||||
(div :class "card"
|
||||
(div :class "card-header" (h3 title))
|
||||
(div :class "card-body" (slot)) ; default slot
|
||||
(div :class "card-footer" (slot "footer")))) ; named slot
|
||||
|
||||
(use "card" :title "My Card"
|
||||
(p "This goes in the default slot")
|
||||
(template :slot "footer"
|
||||
(button "OK") (button "Cancel")))
|
||||
```
|
||||
|
||||
### 3.3 Layouts & Pages
|
||||
|
||||
Higher-level composition for full pages:
|
||||
|
||||
```scheme
|
||||
(layout "main" ()
|
||||
(style "body" :font-family "var(--font)" :margin "0")
|
||||
(style ".layout" :display "grid" :grid-template-rows "auto 1fr auto" :min-height "100vh")
|
||||
|
||||
(div :class "layout"
|
||||
(header (use "nav-bar"))
|
||||
(main (slot))
|
||||
(footer (use "site-footer"))))
|
||||
|
||||
;; A page uses a layout
|
||||
(page "/about"
|
||||
:layout "main"
|
||||
:title "About Us"
|
||||
(section
|
||||
(h1 "About")
|
||||
(p "We replaced HTML with S-expressions.")))
|
||||
```
|
||||
|
||||
### 3.4 Server-Side Component Registry
|
||||
|
||||
On the server, components are registered globally and can be shared between routes:
|
||||
|
||||
```javascript
|
||||
const { registry, component, page, serve } = require('sexpr/server');
|
||||
|
||||
// Register components (can also load from .sexpr files)
|
||||
registry.loadDir('./components');
|
||||
|
||||
// Or inline
|
||||
registry.define('greeting', ['name'],
|
||||
s`(div :class "greeting" (h1 "Hello, " name))`
|
||||
);
|
||||
|
||||
// Route handler returns S-expressions
|
||||
app.get('/about', (req, res) => {
|
||||
res.sexpr(
|
||||
page('/about', { layout: 'main', title: 'About' },
|
||||
s`(section (h1 "About") (p "Hello world"))`)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Virtual Tree & Diffing (Weeks 13–16)
|
||||
|
||||
### 4.1 VTree Representation
|
||||
|
||||
The parsed AST already *is* the virtual tree — no separate representation needed. This is a direct benefit of homoiconicity. While React needs `createElement()` to build a virtual DOM from JSX, our AST is the VDOM.
|
||||
|
||||
```
|
||||
S-expression string → parse() → AST ≡ VTree
|
||||
│
|
||||
renderToDOM() │ diff()
|
||||
▼
|
||||
DOM / Patches
|
||||
```
|
||||
|
||||
### 4.2 Diff Algorithm
|
||||
|
||||
O(n) same-level comparison, similar to React's reconciliation but operating on S-expression ASTs:
|
||||
|
||||
```javascript
|
||||
// Both sides can call this
|
||||
const patches = diff(oldTree, newTree);
|
||||
|
||||
// Client applies to DOM
|
||||
applyPatches(rootNode, patches);
|
||||
|
||||
// Server serializes as mutation commands
|
||||
const mutations = patchesToSexpr(patches);
|
||||
// → (batch! (swap! "#el-3" :inner (p "new")) (attr! "#el-7" :set :class "active"))
|
||||
```
|
||||
|
||||
**Patch types:**
|
||||
- `REPLACE` — replace entire node
|
||||
- `PROPS` — update attributes
|
||||
- `TEXT` — update text content
|
||||
- `INSERT` — insert child at index
|
||||
- `REMOVE` — remove child at index
|
||||
- `REORDER` — reorder children (using `:key` hints)
|
||||
|
||||
**Key insight:** Because patches are also S-expressions, the server can compute a diff and send it as a `batch!` of mutations. The client doesn't need to diff at all — it just executes the mutations. This means the server does the expensive work and the client stays thin.
|
||||
|
||||
### 4.3 Keyed Reconciliation
|
||||
|
||||
For efficient list updates:
|
||||
|
||||
```scheme
|
||||
(each todos (lambda (t)
|
||||
(use "todo-item" :key (get t "id") :text (get t "text") :done (get t "done"))))
|
||||
```
|
||||
|
||||
The `:key` attribute enables the diff algorithm to match nodes across re-renders, minimizing DOM operations for list insertions, deletions, and reorderings.
|
||||
|
||||
### 4.4 Hydration
|
||||
|
||||
Server sends pre-rendered HTML (for SEO and fast first paint). Client attaches to existing DOM without re-rendering:
|
||||
|
||||
1. Server renders S-expression → HTML string with hydration markers
|
||||
2. Browser displays HTML immediately (fast first contentful paint)
|
||||
3. Client JS loads, parses the original S-expression source (embedded in a `<script type="text/x-sexpr">` tag)
|
||||
4. Client walks the existing DOM and attaches event handlers without rebuilding it
|
||||
5. Subsequent updates go through the normal S-expression channel
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Developer Experience (Weeks 17–20)
|
||||
|
||||
### 5.1 CLI Tool
|
||||
|
||||
```bash
|
||||
npx sexpr init my-app # scaffold project
|
||||
npx sexpr dev # dev server with hot reload
|
||||
npx sexpr build # production build
|
||||
npx sexpr serve # production server
|
||||
```
|
||||
|
||||
**Project structure:**
|
||||
```
|
||||
my-app/
|
||||
components/
|
||||
nav-bar.sexpr
|
||||
todo-item.sexpr
|
||||
card.sexpr
|
||||
pages/
|
||||
index.sexpr
|
||||
about.sexpr
|
||||
layouts/
|
||||
main.sexpr
|
||||
styles/
|
||||
theme.sexpr
|
||||
server.js
|
||||
sexpr.config.js
|
||||
```
|
||||
|
||||
### 5.2 `.sexpr` File Format
|
||||
|
||||
Single-file components with co-located styles, markup, and metadata:
|
||||
|
||||
```scheme
|
||||
; components/todo-item.sexpr
|
||||
|
||||
(meta
|
||||
:name "todo-item"
|
||||
:params (id text done)
|
||||
:description "A single todo list item with toggle and delete")
|
||||
|
||||
(style ".todo-item"
|
||||
:display "flex"
|
||||
:align-items "center"
|
||||
:gap "0.75rem")
|
||||
|
||||
(li :class (if done "todo-item done" "todo-item")
|
||||
:id (concat "todo-" id)
|
||||
:key id
|
||||
(span :class "check"
|
||||
:on-click (request! :method "POST"
|
||||
:url (concat "/api/todos/" id "/toggle")
|
||||
:target (concat "#todo-" id) :swap outer)
|
||||
(if done "◉" "○"))
|
||||
(span :class "text" text)
|
||||
(button :class "delete"
|
||||
:on-click (request! :method "DELETE"
|
||||
:url (concat "/api/todos/" id)
|
||||
:target (concat "#todo-" id) :swap delete)
|
||||
"×"))
|
||||
```
|
||||
|
||||
### 5.3 DevTools
|
||||
|
||||
**Browser extension** (or embedded panel):
|
||||
- AST inspector: visualize the S-expression tree alongside the DOM
|
||||
- Mutation log: every `swap!`, `class!`, `batch!` logged with timestamp
|
||||
- Network tab: S-expression request/response viewer (not raw text)
|
||||
- Component tree: hierarchical view of instantiated components
|
||||
- Time-travel: replay mutations forward/backward
|
||||
|
||||
**Server-side:**
|
||||
- Request logger showing S-expression responses
|
||||
- Component dependency graph
|
||||
- Hot reload: file watcher on `.sexpr` files, push updates via WebSocket
|
||||
|
||||
### 5.4 Editor Support
|
||||
|
||||
- **VS Code extension**: syntax highlighting for `.sexpr` files, bracket matching (parentheses), auto-indentation, component name completion, attribute completion for HTML tags
|
||||
- **Tree-sitter grammar**: for Neovim, Helix, Zed, etc.
|
||||
- **Prettier plugin**: auto-format `.sexpr` files
|
||||
- **LSP server**: go-to-definition for components, find-references, rename symbol
|
||||
|
||||
### 5.5 Error Handling
|
||||
|
||||
- **Parse errors**: line/column reporting with context (show the offending line)
|
||||
- **Render errors**: error boundaries like React — a component crash renders a fallback, not a blank page
|
||||
- **Network errors**: `:on-error` handler in `request!`, plus global error handler
|
||||
- **Dev mode**: verbose errors with suggestions; production mode: compact
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Ecosystem & Production (Weeks 21–28)
|
||||
|
||||
### 6.1 Middleware & Plugins
|
||||
|
||||
**Server middleware** (Express/Koa/Hono compatible):
|
||||
|
||||
```javascript
|
||||
const { sexprMiddleware } = require('sexpr/server');
|
||||
|
||||
app.use(sexprMiddleware({
|
||||
componentsDir: './components',
|
||||
layoutsDir: './layouts',
|
||||
hotReload: process.env.NODE_ENV !== 'production'
|
||||
}));
|
||||
|
||||
// Route handlers return S-expressions directly
|
||||
app.get('/', (req, res) => {
|
||||
res.sexpr(homePage(req.user));
|
||||
});
|
||||
```
|
||||
|
||||
**Plugin system** for extending the runtime:
|
||||
|
||||
```javascript
|
||||
// A plugin that adds a (markdown "...") special form
|
||||
sexpr.plugin('markdown', {
|
||||
transform(ast) { /* convert markdown to sexpr AST */ },
|
||||
serverRender(ast) { /* render to HTML string */ },
|
||||
clientRender(ast) { /* render to DOM */ }
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Router
|
||||
|
||||
Client-side navigation without full page reloads:
|
||||
|
||||
```scheme
|
||||
(router
|
||||
(route "/" (use "home-page"))
|
||||
(route "/about" (use "about-page"))
|
||||
(route "/todos/:id" (use "todo-detail" :id params.id))
|
||||
(route "*" (use "not-found")))
|
||||
```
|
||||
|
||||
- Intercepts `<a>` clicks for internal links
|
||||
- `pushState` / `popState` navigation
|
||||
- Server-side: same route definitions, used for SSR
|
||||
- Prefetch: `(link :href "/about" :prefetch #t "About")`
|
||||
|
||||
### 6.3 Forms
|
||||
|
||||
Declarative form handling:
|
||||
|
||||
```scheme
|
||||
(form :action "/api/users" :method "POST"
|
||||
:target "#result" :swap inner
|
||||
:validate #t ; client-side validation
|
||||
:reset-on-success #t
|
||||
|
||||
(input :type "text" :name "username"
|
||||
:required #t
|
||||
:minlength 3
|
||||
:pattern "[a-zA-Z0-9]+"
|
||||
:error-message "Alphanumeric, 3+ chars")
|
||||
|
||||
(input :type "email" :name "email" :required #t)
|
||||
|
||||
(button :type "submit" "Create User"))
|
||||
```
|
||||
|
||||
The server validates identically — same validation rules expressed as S-expressions run on both sides.
|
||||
|
||||
### 6.4 Content-Type & MIME
|
||||
|
||||
Register `text/x-sexpr` as a proper MIME type:
|
||||
|
||||
- HTTP responses: `Content-Type: text/x-sexpr; charset=utf-8`
|
||||
- `Accept` header negotiation: client sends `Accept: text/x-sexpr, text/html;q=0.9`
|
||||
- Fallback: if client doesn't accept `text/x-sexpr`, server renders to HTML (graceful degradation)
|
||||
- File extension: `.sexpr`
|
||||
|
||||
### 6.5 Caching & Service Worker
|
||||
|
||||
- **Service worker**: caches S-expression responses, serves offline
|
||||
- **Incremental cache**: cache individual components, not whole pages
|
||||
- **ETag/304**: standard HTTP caching works because responses are text
|
||||
- **Compression**: S-expressions compress well with gzip/brotli (repetitive keywords)
|
||||
|
||||
### 6.6 Security
|
||||
|
||||
- **No `eval()`**: S-expressions are parsed, not evaluated as code. The runtime only understands the defined special forms.
|
||||
- **XSS prevention**: text content is always escaped when rendered to DOM (via `textContent`, not `innerHTML`). The `raw-html` escape hatch requires explicit opt-in.
|
||||
- **CSP compatible**: no inline scripts generated. Event handlers are registered via JS, not `onclick` attributes (move away from the v1 prototype approach).
|
||||
- **CSRF**: standard token-based approach, with `(meta :csrf-token "...")` in the page head.
|
||||
|
||||
### 6.7 Accessibility
|
||||
|
||||
- S-expressions map 1:1 to semantic HTML elements — `(nav ...)`, `(main ...)`, `(article ...)`, `(aside ...)` all render to their HTML equivalents
|
||||
- ARIA attributes work naturally: `:aria-label "Close" :aria-expanded #f :role "dialog"`
|
||||
- Focus management primitives: `(focus! "#element")`, `(trap-focus! "#modal")`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Testing & Documentation (Weeks 25–28, overlapping)
|
||||
|
||||
### 7.1 Testing Utilities
|
||||
|
||||
```javascript
|
||||
const { render, fireEvent, waitForMutation } = require('sexpr/test');
|
||||
|
||||
test('todo toggle', async () => {
|
||||
const tree = render('(use "todo-item" :id "1" :text "Buy milk" :done #f)');
|
||||
|
||||
expect(tree.query('.todo-item')).not.toHaveClass('done');
|
||||
|
||||
fireEvent.click(tree.query('.check'));
|
||||
await waitForMutation('#todo-1');
|
||||
|
||||
expect(tree.query('.todo-item')).toHaveClass('done');
|
||||
});
|
||||
```
|
||||
|
||||
- `render()` works in Node (using JSDOM) or browser
|
||||
- Snapshot testing: compare AST snapshots, not HTML string snapshots
|
||||
- Mutation assertions: `expectMutation(swap!(...))` to test server responses
|
||||
|
||||
### 7.2 Documentation Site
|
||||
|
||||
Built with sexpr.js itself (dogfooding). Includes:
|
||||
- Tutorial: build a todo app from scratch
|
||||
- API reference: every special form, mutation primitive, configuration option
|
||||
- Cookbook: common patterns (modals, infinite scroll, real-time chat, auth flows)
|
||||
- Interactive playground: edit S-expressions, see live DOM output
|
||||
- Migration guide: "coming from HTMX" and "coming from React"
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Why Not JSON?
|
||||
|
||||
JSON could represent the same tree structure. But:
|
||||
- S-expressions are more compact for deeply nested structures (no commas, colons, or quotes on keys)
|
||||
- The syntax is its own DSL — `:keywords`, symbols, and lists feel natural for document description
|
||||
- Comments are supported (JSON has none)
|
||||
- Human-writeable: developers will author `.sexpr` files directly
|
||||
- Cultural signal: this is a Lisp-inspired project, and the syntax communicates the homoiconicity thesis immediately
|
||||
|
||||
### Why Not Compile to WebAssembly?
|
||||
|
||||
Tempting for parser performance, but:
|
||||
- JS engines already optimize parsing hot paths well
|
||||
- WASM has overhead for DOM interaction (must cross the JS boundary anyway)
|
||||
- Staying in pure JS means the library works everywhere JS does with zero build step
|
||||
- Future option: WASM parser module for very large documents
|
||||
|
||||
### Module Format
|
||||
|
||||
- ES modules (`.mjs`) as primary
|
||||
- CommonJS (`.cjs`) build for older Node.js
|
||||
- UMD build for `<script>` tag usage (the "drop it in and it works" story)
|
||||
- TypeScript type definitions (`.d.ts`) shipped alongside
|
||||
|
||||
### Bundle Size Target
|
||||
|
||||
- Core parser + renderer: **< 5KB** gzipped
|
||||
- With mutation engine: **< 8KB** gzipped
|
||||
- Full framework (router, forms, devtools hook): **< 15KB** gzipped
|
||||
|
||||
For comparison: HTMX is ~14KB, Alpine.js is ~15KB, Preact is ~3KB.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Target | Deliverable |
|
||||
|---|---|---|
|
||||
| **M1: Parser** | Week 2 | `parse()`, `serialize()`, full test suite, benchmarks |
|
||||
| **M2: Client renderer** | Week 4 | `renderToDOM()`, styles, events — the v1 prototype, cleaned up |
|
||||
| **M3: Server renderer** | Week 6 | `renderToString()`, Express middleware, SSR bootstrap |
|
||||
| **M4: Mutations** | Week 8 | `swap!`, `batch!`, `request!`, `class!` — full hypermedia engine |
|
||||
| **M5: WebSocket** | Week 10 | Real-time server push, reconnection, protocol spec |
|
||||
| **M6: Components** | Week 12 | Component system, slots, `.sexpr` file format, registry |
|
||||
| **M7: Diffing** | Week 16 | VTree diff, keyed reconciliation, hydration |
|
||||
| **M8: CLI & DX** | Week 20 | `sexpr init/dev/build`, hot reload, VS Code extension |
|
||||
| **M9: Ecosystem** | Week 24 | Router, forms, plugins, service worker caching |
|
||||
| **M10: Launch** | Week 28 | Docs site, npm publish, example apps, announcement |
|
||||
|
||||
---
|
||||
|
||||
## Example Apps (for launch)
|
||||
|
||||
1. **Todo MVC** — the classic benchmark, fully server-driven
|
||||
2. **Real-time chat** — WebSocket mutations, presence indicators
|
||||
3. **Dashboard** — data tables, charts, polling, search
|
||||
4. **Blog** — SSR, routing, SEO, markdown integration
|
||||
5. **E-commerce product page** — forms, validation, cart mutations
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Macro system?** Should the server support `defmacro`-style macros for transforming S-expressions before rendering? This adds power but also complexity and potential security concerns.
|
||||
|
||||
2. **TypeScript integration?** Should component params be typed? Could generate TS interfaces from `(meta :params ...)` declarations.
|
||||
|
||||
3. **Compilation?** An optional ahead-of-time compiler could pre-parse `.sexpr` files into JS AST constructors, eliminating parse time at runtime. Worth the complexity?
|
||||
|
||||
4. **CSS-in-sexpr or external stylesheets?** The current approach co-locates styles. Should there also be a way to import `.css` files directly, or should all styling go through the S-expression syntax?
|
||||
|
||||
5. **Interop with existing HTML?** Can you embed an S-expression island inside an existing HTML page (like Astro islands)? Useful for incremental adoption.
|
||||
|
||||
6. **Binary wire format?** A compact binary encoding of the AST (like MessagePack for S-expressions) could reduce bandwidth for large pages. Worth the complexity vs. gzip?
|
||||
|
||||
---
|
||||
|
||||
## Name Candidates
|
||||
|
||||
- **sexpr.js** — direct, memorable, says what it is
|
||||
- **sdom** — S-expression DOM
|
||||
- **paren** — the defining character
|
||||
- **lispr** — Lisp + render
|
||||
- **homoDOM** — homoiconic DOM
|
||||
|
||||
---
|
||||
|
||||
*The document is the program. The program is the document.*
|
||||
74
docs/sexpr-microservice-wire-format.md
Normal file
74
docs/sexpr-microservice-wire-format.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# S-expressions as Microservice Wire Format
|
||||
|
||||
**The strongest near-term application: structured inter-service communication, even when final output is still rendered HTML.**
|
||||
|
||||
---
|
||||
|
||||
## The Current Problem
|
||||
|
||||
Rose-ash services communicate two ways, both lossy:
|
||||
|
||||
### `fetch_data()` — returns dicts
|
||||
|
||||
The receiver has to know what keys to expect. There's no schema, no composability. It's just "here's a bag of stuff, good luck."
|
||||
|
||||
### `fetch_fragment()` — returns HTML strings
|
||||
|
||||
The receiving service can't inspect, filter, or transform them. It just jams the string into a template. If events returns a calendar nav fragment, blog can't reorder the items or filter by date. It's take-it-or-leave-it.
|
||||
|
||||
---
|
||||
|
||||
## Sexp Between Services
|
||||
|
||||
Sexp gives you **structured data that's also renderable**. The relations service could return:
|
||||
|
||||
```scheme
|
||||
(nav :class "container-nav"
|
||||
(relation :type "page->calendar" :label "Saturday Market" :href "/events/saturday/")
|
||||
(relation :type "page->market" :label "Craft Stalls" :href "/markets/crafts/"))
|
||||
```
|
||||
|
||||
The receiving service can:
|
||||
|
||||
- **Render it straight to HTML** — same as today's fragments
|
||||
- **Filter it** — `exclude: page->calendar`
|
||||
- **Reorder, group, or transform it** — tree operations, not string manipulation
|
||||
- **Pass it through to the client as-is** — if they speak sexp
|
||||
- **Cache it by content hash** — deterministic, structural equality
|
||||
|
||||
All without parsing HTML or knowing the internal structure of the sending service. The tree *is* the API response *and* the renderable output.
|
||||
|
||||
---
|
||||
|
||||
## One Format, Two Purposes
|
||||
|
||||
Today there's a split between "data endpoints" (`fetch_data` → dicts) and "fragment endpoints" (`fetch_fragment` → HTML strings). They serve different needs:
|
||||
|
||||
| Need | Current | With Sexp |
|
||||
|---|---|---|
|
||||
| Structured data for logic | `fetch_data()` → dict | Same sexp tree |
|
||||
| Renderable HTML for templates | `fetch_fragment()` → HTML string | Same sexp tree, rendered at the boundary |
|
||||
| Filtering/transforming cross-service output | Not possible (HTML is opaque) | Tree operations on sexp |
|
||||
| Caching | Key-based (URL + params) | Content-addressed (hash the tree) |
|
||||
| Schema/validation | None (hope the keys are right) | Tree structure is self-describing |
|
||||
|
||||
The unification eliminates an entire class of bugs — where `fetch_data` and `fetch_fragment` for the same resource return subtly inconsistent results because they're maintained as separate code paths.
|
||||
|
||||
---
|
||||
|
||||
## Nothing Changes for the User
|
||||
|
||||
The final output to the browser is still plain HTML. The improvement is entirely in how services talk to each other — less brittle, more composable, zero extra infrastructure. Sexp is evaluated to HTML at the outermost boundary (the route handler), exactly as it is today with the sexp template engine.
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
This can be adopted incrementally, one service boundary at a time:
|
||||
|
||||
1. **Relations service already returns sexp** — `container-nav` fragment is rendered from sexp templates
|
||||
2. **Next**: have fragment endpoints return raw sexp instead of pre-rendered HTML, let the caller render
|
||||
3. **Then**: unify `fetch_data` and `fetch_fragment` into a single `fetch_sexp` that returns structured trees
|
||||
4. **Finally**: callers that want data extract it from the tree; callers that want HTML render the tree
|
||||
|
||||
Each step is backwards-compatible. Services can serve both HTML fragments and sexp simultaneously during transition.
|
||||
86
docs/sexpr-risks-and-pitfalls.md
Normal file
86
docs/sexpr-risks-and-pitfalls.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# S-expression Protocol: Risks and Pitfalls
|
||||
|
||||
**Bear traps, historical precedents, and honest assessment of what could go wrong.**
|
||||
|
||||
---
|
||||
|
||||
## Adoption Chicken-and-Egg
|
||||
|
||||
No one builds clients for a protocol no one serves. No one serves a protocol no one has clients for. HTTP won despite technically inferior alternatives because it was *there*. The Tier 0 strategy (sexp rendered to HTML by the server) is the right answer — you don't need anyone to adopt anything on day one. But the jump from Tier 0 to Tier 1/2 requires a critical mass of sites serving sexp, and that's historically where alternative protocols die.
|
||||
|
||||
---
|
||||
|
||||
## Security Surface Area
|
||||
|
||||
Evaluating arbitrary sexp from a remote server is `eval()` with s-expressions. Sandboxing matters enormously. What can a component do? Can it access localStorage? Make network requests? Read other components' state? HTML's security model (same-origin policy, CSP, CORS) took 20 years of CVEs to get to where it is. You'd need an equivalent — and you'd need it from day one, not after the first exploit. The "components are functions" model is powerful but "functions from strangers" is the oldest trap in computing.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
HTML's semantic elements (`<nav>`, `<main>`, `<article>`, `<button>`) have decades of screenreader support baked in. A sexp `(nav ...)` that renders to DOM inherits this — but a Tier 2 Rust client rendering natively doesn't. You'd need to build an accessibility layer from scratch, or you create a fast web that blind users can't use.
|
||||
|
||||
---
|
||||
|
||||
## Tooling Desert
|
||||
|
||||
View Source, DevTools, Lighthouse, network inspectors, CSS debuggers — the entire web development toolchain assumes HTML/CSS/JS. A sexp protocol starts with zero tooling. Developers won't adopt what they can't debug. You'd need at minimum: a sexp inspector, a component browser, a network viewer that understands sexp streams, and a REPL. Building the protocol is 10% of the work; building the toolchain is 90%.
|
||||
|
||||
---
|
||||
|
||||
## The Lisp Curse
|
||||
|
||||
S-expressions are so flexible that two implementations inevitably diverge. Is it `:key value` or `(key value)`? Are booleans `#t/#f` or `true/false`? Is the empty list `()` or `nil`? Every Lisp dialect makes different choices. Without a brutally strict spec locked down early, you get fragmentation — which is exactly what killed previous "simple protocol" attempts (XMPP, Atom, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Performance Isn't Free
|
||||
|
||||
"Parsing sexp is faster than parsing HTML" is true for a naive comparison. But browsers have spent billions of dollars optimising HTML parsing — speculative parsing, streaming tokenisation, GPU-accelerated layout. A sexp evaluator written in a weekend is slower than Chrome's HTML parser in practice, even if it's simpler in theory. The Rust client would need serious engineering to match perceived performance.
|
||||
|
||||
---
|
||||
|
||||
## Content-Addressed Caching Breaks on Dynamic Content
|
||||
|
||||
Hashing works beautifully for static components and blog posts. It falls apart for personalised content, A/B tests, time-sensitive data, or anything with user state. You'd need a clear boundary between "cacheable structure" and "dynamic bindings" — and that boundary is exactly where complexity creeps back in.
|
||||
|
||||
---
|
||||
|
||||
## The Worse Is Better Problem
|
||||
|
||||
HTTP+HTML is objectively a mess. It's also the most successful application protocol in history. Worse is better. Developers know it, frameworks handle the ugly parts, and the ecosystem is vast. A cleaner protocol has to overcome "good enough" — the most lethal competitor in technology.
|
||||
|
||||
---
|
||||
|
||||
## Legal and Regulatory Assumptions
|
||||
|
||||
Cookie consent, GDPR right to erasure, accessibility mandates (WCAG), eIDAS digital signatures — all of these are written assuming HTTP+HTML. A new protocol potentially falls outside existing frameworks, which is either a feature (no cookie banners!) or a bear trap (non-compliant by default).
|
||||
|
||||
---
|
||||
|
||||
## Federation Is Hard
|
||||
|
||||
ActivityPub exists and adoption is still tiny after 8 years. The problem isn't the protocol — it's spam, moderation, identity, key management, and social dynamics. Sexp doesn't solve any of those. A beautiful protocol that inherits AP's unsolved problems is still stuck on those problems.
|
||||
|
||||
---
|
||||
|
||||
## Honest Summary
|
||||
|
||||
The *idea* is sound and the architecture is elegant. The risk isn't technical — it's that history is littered with technically superior protocols that lost to worse-but-established ones. The Tier 0 on-ramp (server renders HTML, sexp is internal) is the best defence against this, because it means rose-ash doesn't depend on protocol adoption to work. The protocol can grow organically from one working site rather than needing an ecosystem to bootstrap.
|
||||
|
||||
---
|
||||
|
||||
## Mitigation Strategy
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Adoption chicken-and-egg | Tier 0 works today on any browser — no adoption needed to start |
|
||||
| Security | Define capability model before Tier 1 ships; components are pure functions by default |
|
||||
| Accessibility | Tier 0/1 render to semantic HTML+DOM; Tier 2 Rust client uses platform a11y APIs (e.g. AccessKit) |
|
||||
| Tooling | Build inspector and REPL as first Tier 1 deliverables, not afterthoughts |
|
||||
| Lisp Curse | Lock the spec early: `:key value` attrs, `#t/#f` booleans, `()` empty list — no alternatives |
|
||||
| Performance | Tier 2 Rust client competes on parsing; Tier 0/1 lean on browser's optimised DOM |
|
||||
| Dynamic caching | Separate envelope (cacheable structure) from bindings (dynamic data) at the protocol level |
|
||||
| Worse Is Better | Don't compete with HTTP — run on top of it (Tier 0/1) and beside it (Tier 2) |
|
||||
| Legal/regulatory | Tier 0 is standard HTTP — fully compliant; Tier 2 inherits HTTP compliance via QUIC |
|
||||
| Federation | Solve spam/moderation at the application layer, not the protocol layer |
|
||||
935
docs/sexpr-unified-protocol.md
Normal file
935
docs/sexpr-unified-protocol.md
Normal file
@@ -0,0 +1,935 @@
|
||||
# The Sexp Protocol
|
||||
|
||||
**A unified protocol for documents, applications, and federation.**
|
||||
**One format. Every boundary.**
|
||||
|
||||
---
|
||||
|
||||
## Core Insight
|
||||
|
||||
The sexp protocol replaces HTTP, HTML, JSON-LD, WebSocket, and ActivityPub's inbox model with a single concept: **peers exchange s-expressions on a bidirectional stream.**
|
||||
|
||||
There is no distinction between:
|
||||
- A client requesting a page (what HTTP does)
|
||||
- A server pushing a real-time update (what WebSocket does)
|
||||
- A peer delivering a federated activity (what AP inbox POST does)
|
||||
- A server sending a DOM mutation (what HTMX does)
|
||||
|
||||
They are all sexp expressions sent between two peers:
|
||||
|
||||
```scheme
|
||||
;; Browsing (request → response)
|
||||
(GET "/markets/")
|
||||
(ok (page :title "Markets" ...))
|
||||
|
||||
;; Real-time push (server → client, unsolicited)
|
||||
(push! (swap! "#feed" :prepend (use "post-card" :title "New post")))
|
||||
|
||||
;; Federation delivery (peer → peer)
|
||||
(Create :actor "alice@rose-ash.com"
|
||||
(Note :id post-123 (p "Hello from the fediverse!")))
|
||||
|
||||
;; Client action (client → server)
|
||||
(POST "/like/" :body (:post-id 123))
|
||||
|
||||
;; Mutation response (server → client)
|
||||
(push! (swap! "#like-count-123" :inner "43"))
|
||||
```
|
||||
|
||||
Same parser. Same stream. Same connection. The "type" of interaction is determined by the head symbol of the expression, not by the protocol layer.
|
||||
|
||||
---
|
||||
|
||||
## Why Replace HTTP and JSON-LD
|
||||
|
||||
### HTTP's Problems
|
||||
|
||||
HTTP is a request-response protocol with text headers and a body. Strip away the historical baggage and what you actually need is:
|
||||
|
||||
```
|
||||
Peer A sends: verb + target + metadata + optional body
|
||||
Peer B sends: status + metadata + body
|
||||
```
|
||||
|
||||
That's just an s-expression. But HTTP adds:
|
||||
- **Header/body split** — two parsing phases, different formats
|
||||
- **Rigid request-response** — to work around this, we bolted on WebSocket (separate protocol, upgrade handshake), SSE (chunked encoding hack), HTTP/2 push (failed, removed), HTTP/3 QUIC streams (complex, still one-request-one-response per stream)
|
||||
- **Overcomplicated caching** — `Cache-Control`, `ETag`, `If-None-Match`, `If-Modified-Since`, `Vary`, `Age`, `Expires`, `Last-Modified`, `s-maxage`, `stale-while-revalidate` — a dozen headers with complex interaction rules
|
||||
- **Arbitrary status codes** — memorised numbers with no semantic meaning in the format
|
||||
- **Form encoding mess** — `application/x-www-form-urlencoded` or `multipart/form-data` with boundary strings, MIME parts, content-disposition headers
|
||||
|
||||
### JSON-LD's Problems
|
||||
|
||||
JSON-LD was chosen for ActivityPub because of its semantic web lineage, but in practice nobody uses the linked data features — servers just pattern-match on `type` fields and ignore the `@context`. The format is:
|
||||
|
||||
- **Verbose** — deeply nested objects with string keys, quoted values, commas, colons
|
||||
- **Ambiguous** — compaction/expansion rules mean the same activity can have many valid JSON representations
|
||||
- **Lossy** — post content is flattened to an HTML string inside a JSON string; structure destroyed
|
||||
- **Hostile to signing** — JSON-LD canonicalization is fragile and implementation-dependent
|
||||
- **Hostile to AI** — agents must parse JSON, then parse embedded HTML, then reason about structure
|
||||
|
||||
### The Sexp Solution
|
||||
|
||||
One format that is:
|
||||
- **Compact** — half the size of equivalent JSON-LD, no closing tags like HTML
|
||||
- **Unambiguous** — one canonical serialization, deterministic for signing
|
||||
- **Structural** — content *is* the tree, not a string embedded in a string
|
||||
- **Parseable** — trivial recursive descent, no backtracking, nanoseconds per node
|
||||
- **Bidirectional** — requests, responses, pushes, and activities all use the same syntax
|
||||
- **AI-native** — agents parse, generate, and reason about sexp as naturally as tool calls
|
||||
|
||||
---
|
||||
|
||||
## Protocol Specification
|
||||
|
||||
### Transport
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Application: sexp expressions │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Session: bidirectional sexp stream │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Transport: QUIC (or TCP+TLS fallback) │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Network: IP │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
QUIC is ideal — multiplexed streams, built-in TLS, connection migration. Each sexp expression gets its own QUIC stream. Requests and pushes multiplex without head-of-line blocking. Connection survives network changes (mobile → wifi).
|
||||
|
||||
**Framing:** Length-prefixed sexp expressions over QUIC streams. The parser knows when an expression ends (balanced parens), but length-prefixing enables efficient buffer allocation.
|
||||
|
||||
**URL scheme:** `sexpr://host:port/path`
|
||||
|
||||
### Expression Types
|
||||
|
||||
Every expression on the wire is one of these forms:
|
||||
|
||||
#### Requests (client → server)
|
||||
|
||||
The head symbol is the verb. **Any symbol is a valid verb.** There is no fixed set like HTTP's seven methods. The verb describes the intent; the path is the noun:
|
||||
|
||||
```scheme
|
||||
;; Reads
|
||||
(GET "/markets/")
|
||||
(GET "/markets/" :have "sha3-a1b2c3" :have-components ("sha3-aaa" "sha3-bbb"))
|
||||
(GET "/feed/" :stream #t)
|
||||
|
||||
;; Writes (HTTP-style, but you're not limited to these)
|
||||
(POST "/users/" :body (:name "alice" :email "alice@example.com"))
|
||||
(DELETE "/posts/draft-123/")
|
||||
|
||||
;; Domain verbs — describe what's actually happening
|
||||
(publish "/posts/draft-123/")
|
||||
(reserve "/events/saturday-market/" :tickets 2)
|
||||
(subscribe "/newsletters/weekly/")
|
||||
(unsubscribe "/newsletters/weekly/")
|
||||
(pin "/posts/important-announcement/")
|
||||
(refund "/orders/ord-789/" :reason "damaged")
|
||||
(schedule "/calendar/saturday/" :slot 3 :name "Pottery Workshop")
|
||||
(rsvp "/events/annual-meeting/" :attending #t :guests 2)
|
||||
(transfer "/inventory/sourdough/" :from "stall-a" :to "stall-b" :quantity 5)
|
||||
(bid "/auctions/vintage-table/" :amount 45.00)
|
||||
|
||||
;; Cooperative governance
|
||||
(propose "/governance/" :title "New market hours"
|
||||
:body (p "I suggest we open at 8am..."))
|
||||
(second "/governance/proposals/42/")
|
||||
(vote "/governance/proposals/42/" :choice :approve)
|
||||
(ratify "/governance/proposals/42/")
|
||||
|
||||
;; Compute mesh
|
||||
(render :recipe "bafyrecipe..." :input "bafyinput..." :requirements (:min-vram 8))
|
||||
(transcode :input "bafyvideo..." :format "h265" :quality 28)
|
||||
|
||||
;; File uploads — structured, not multipart MIME
|
||||
(POST "/upload/"
|
||||
:body (
|
||||
:username "alice"
|
||||
:avatar (file :name "photo.jpg" :type "image/jpeg" :data <binary>)))
|
||||
```
|
||||
|
||||
`(reserve "/events/saturday-market/" :tickets 2)` reads as English. An AI agent doesn't need API documentation to understand what that does. The URL is the noun, the verb is the verb — no more `POST /api/users/123/send-password-reset-email` where the action is buried in the URL because HTTP doesn't have the right verb.
|
||||
|
||||
**Verb behaviour is declared in the schema**, not assumed by convention:
|
||||
|
||||
```scheme
|
||||
(GET "/__schema/")
|
||||
|
||||
(ok
|
||||
(schema
|
||||
(verb GET :idempotent #t :auth :optional
|
||||
:description "Retrieve a resource")
|
||||
(verb reserve :idempotent #f :auth :required
|
||||
:params (:tickets int)
|
||||
:returns (reservation)
|
||||
:description "Reserve tickets for an event")
|
||||
(verb cancel-reservation :idempotent #t :auth :required
|
||||
:params (:reservation-id str)
|
||||
:returns (ok)
|
||||
:description "Cancel a ticket reservation")
|
||||
(verb publish :idempotent #t :auth :admin
|
||||
:description "Publish a draft post")
|
||||
(verb vote :idempotent #t :auth :member
|
||||
:params (:choice (enum :approve :reject :abstain))
|
||||
:returns (ok)
|
||||
:description "Vote on a governance proposal")))
|
||||
```
|
||||
|
||||
No more arguing about whether `PATCH` should be idempotent. The schema says what each verb does, what it takes, what it returns, and who can call it. AI agents read the schema and know the full API surface — including domain-specific verbs they've never seen before.
|
||||
|
||||
#### Responses (server → client)
|
||||
|
||||
```scheme
|
||||
;; Success with content
|
||||
(ok :hash "sha3-d4e5f6"
|
||||
(page :title "Markets" :layout "main"
|
||||
(section (h1 "Markets")
|
||||
(each markets (lambda (m)
|
||||
(use "vendor-card" :name (get m "name")))))))
|
||||
|
||||
;; Not modified (client already has current version)
|
||||
(not-modified)
|
||||
|
||||
;; Redirect
|
||||
(redirect :to "/new-location/" :permanent #t)
|
||||
|
||||
;; Not found
|
||||
(not-found :message "Page does not exist")
|
||||
|
||||
;; Error
|
||||
(error :code "auth-required" :message "Please log in"
|
||||
:login-url "sexpr://rose-ash.com/login/")
|
||||
|
||||
;; Response with new components the client is missing
|
||||
(ok
|
||||
:new-components (
|
||||
(component "vendor-card" :hash "sha3-ddd" (params ...) (template ...)))
|
||||
(page ...))
|
||||
```
|
||||
|
||||
#### Pushes (server → client, unsolicited)
|
||||
|
||||
```scheme
|
||||
;; DOM mutation
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :title "New post" :author "alice")))
|
||||
|
||||
;; Batch mutation
|
||||
(push! (batch!
|
||||
(swap! "#notifications" :append (div :class "toast" "Saved!"))
|
||||
(class! "#save-btn" :remove "loading")))
|
||||
|
||||
;; Component update (new version available)
|
||||
(push! (component-update "vendor-card" :hash "sha3-eee"))
|
||||
```
|
||||
|
||||
#### Activities (peer → peer, federation)
|
||||
|
||||
```scheme
|
||||
;; Create
|
||||
(Create
|
||||
:id "https://rose-ash.com/ap/activities/456"
|
||||
:actor "https://rose-ash.com/ap/users/alice"
|
||||
:published "2026-02-28T14:00:00Z"
|
||||
:to (:public)
|
||||
:cc ("https://rose-ash.com/ap/users/alice/followers")
|
||||
|
||||
(Note
|
||||
:id "https://rose-ash.com/ap/posts/123"
|
||||
:attributed-to "https://rose-ash.com/ap/users/alice"
|
||||
:in-reply-to "https://remote.social/posts/789"
|
||||
:summary "Weekend market update"
|
||||
|
||||
(section
|
||||
(p "Three new vendors joining this Saturday.")
|
||||
(use "vendor-list" :vendors vendors)
|
||||
(use "calendar-widget" :calendar-id 42))
|
||||
|
||||
(attachment
|
||||
(Image :url "https://rose-ash.com/media/market.jpg"
|
||||
:media-type "image/jpeg"
|
||||
:width 1200 :height 800
|
||||
:name "Saturday market stalls"))
|
||||
|
||||
(tag
|
||||
(Hashtag :name "#markets" :href "https://rose-ash.com/tags/markets")
|
||||
(Mention :name "@bob@remote.social" :href "https://remote.social/users/bob"))))
|
||||
|
||||
;; Follow
|
||||
(Follow :actor "https://rose-ash.com/ap/users/alice"
|
||||
:object "https://remote.social/users/bob")
|
||||
|
||||
;; Accept (response to Follow — on the same connection)
|
||||
(Accept :actor "https://remote.social/users/bob"
|
||||
(Follow :actor "https://rose-ash.com/ap/users/alice"
|
||||
:object "https://remote.social/users/bob"))
|
||||
|
||||
;; Like, Announce, Undo — all just expressions
|
||||
(Like :actor alice :object post-123)
|
||||
(Announce :actor bob (Note :id post-123))
|
||||
(Undo :actor alice (Like :actor alice :object post-123))
|
||||
```
|
||||
|
||||
**Key insight:** Activities are not "posted to an inbox" — they are expressions sent on the bidirectional stream. The peer-to-peer connection *is* the inbox. When you follow someone, their activities stream to you on the same connection you used to follow them. No inbox endpoint, no HTTP POST delivery, no polling.
|
||||
|
||||
#### Collections (queryable, paginated)
|
||||
|
||||
```scheme
|
||||
;; Request an actor's outbox
|
||||
(GET "/ap/users/alice/outbox" :page 1)
|
||||
|
||||
;; Response
|
||||
(ok
|
||||
(OrderedCollectionPage
|
||||
:part-of "https://rose-ash.com/ap/users/alice/outbox"
|
||||
:next "/ap/users/alice/outbox?page=2"
|
||||
:total-items 142
|
||||
|
||||
(Create :actor alice :published "2026-02-28T14:00:00Z"
|
||||
(Note :id post-3 (p "Latest post")))
|
||||
(Announce :actor alice :published "2026-02-27T09:00:00Z"
|
||||
(Note :id post-2 :attributed-to bob))
|
||||
(Create :actor alice :published "2026-02-26T18:00:00Z"
|
||||
(Note :id post-1 (p "Earlier post")))))
|
||||
```
|
||||
|
||||
#### Actor Profiles
|
||||
|
||||
```scheme
|
||||
(Person
|
||||
:id "https://rose-ash.com/ap/users/alice"
|
||||
:preferred-username "alice"
|
||||
:name "Alice"
|
||||
:summary (p "Co-op member, market organiser")
|
||||
:inbox "sexpr://rose-ash.com/ap/users/alice/inbox"
|
||||
:outbox "sexpr://rose-ash.com/ap/users/alice/outbox"
|
||||
:followers "sexpr://rose-ash.com/ap/users/alice/followers"
|
||||
:following "sexpr://rose-ash.com/ap/users/alice/following"
|
||||
:components "sexpr://rose-ash.com/ap/components/"
|
||||
:public-key (:id "https://rose-ash.com/ap/users/alice#main-key"
|
||||
:owner "https://rose-ash.com/ap/users/alice"
|
||||
:pem "-----BEGIN PUBLIC KEY-----\n..."))
|
||||
```
|
||||
|
||||
#### Schema Introspection
|
||||
|
||||
```scheme
|
||||
(GET "/__schema/")
|
||||
|
||||
(ok
|
||||
(schema
|
||||
(endpoint "/" :method GET
|
||||
:returns (page)
|
||||
:params (:stream bool))
|
||||
(endpoint "/markets/" :method GET
|
||||
:returns (page :contains (list (use "vendor-card"))))
|
||||
(endpoint "/like/" :method POST
|
||||
:params (:post-id int)
|
||||
:returns (mutation))
|
||||
(activity Create
|
||||
:object (Note)
|
||||
:delivers-to :followers)
|
||||
(activity Follow
|
||||
:object (Person)
|
||||
:expects (Accept))))
|
||||
```
|
||||
|
||||
An AI agent hitting `/__schema/` learns the entire surface — pages, actions, federation activities — as parseable sexp. No separate OpenAPI doc. The schema *is* the API.
|
||||
|
||||
### Caching
|
||||
|
||||
Content-addressed, hash-based. No expiry headers, no revalidation dance.
|
||||
|
||||
```scheme
|
||||
;; Client has a cached version
|
||||
(GET "/markets/" :have "sha3-a1b2c3")
|
||||
|
||||
;; Unchanged
|
||||
(not-modified)
|
||||
|
||||
;; Changed — new hash included
|
||||
(ok :hash "sha3-d4e5f6" (page ...))
|
||||
```
|
||||
|
||||
Component-level caching:
|
||||
|
||||
```scheme
|
||||
;; Client reports which components it has
|
||||
(GET "/markets/" :have-components ("sha3-aaa" "sha3-bbb"))
|
||||
|
||||
;; Server sends only missing components alongside the page
|
||||
(ok
|
||||
:new-components (
|
||||
(component "vendor-card" :hash "sha3-ddd" (params ...) (template ...)))
|
||||
(page ...))
|
||||
```
|
||||
|
||||
The hash *is* the cache key, the ETag, and the content address. One concept replaces twelve HTTP headers.
|
||||
|
||||
### Component Discovery
|
||||
|
||||
Actors advertise a `:components` endpoint in their profile. Peers fetch and cache component definitions by content hash:
|
||||
|
||||
```scheme
|
||||
;; Fetch component manifest
|
||||
(GET "/ap/components/")
|
||||
|
||||
(ok
|
||||
(component-manifest
|
||||
("cart-mini" :hash "sha3-aaa")
|
||||
("vendor-card" :hash "sha3-bbb")
|
||||
("calendar-widget" :hash "sha3-ccc")
|
||||
("post-card" :hash "sha3-ddd")))
|
||||
|
||||
;; Fetch a specific component by hash
|
||||
(GET "/ap/components/sha3-bbb")
|
||||
|
||||
(ok
|
||||
(component "vendor-card" :hash "sha3-bbb"
|
||||
(params name stall image)
|
||||
(div :class "vendor-card"
|
||||
(img :src image :alt name)
|
||||
(h3 name)
|
||||
(p :class "stall" stall))))
|
||||
```
|
||||
|
||||
When a federated activity includes `(use "vendor-card" :name "Sourdough" :stall "A12")`, the receiving peer resolves the component from its cache (by hash) or fetches it from the sender's manifest. Federated UI, not just federated data.
|
||||
|
||||
### Signatures
|
||||
|
||||
Sexp serialization has a canonical form:
|
||||
- Keywords sorted alphabetically
|
||||
- Single space between atoms
|
||||
- No trailing whitespace
|
||||
- UTF-8 encoding
|
||||
|
||||
This makes signatures deterministic without JSON-LD canonicalization:
|
||||
|
||||
```scheme
|
||||
(signed :sig "base64..." :key-id "https://rose-ash.com/ap/users/alice#main-key"
|
||||
(Create :actor "https://rose-ash.com/ap/users/alice"
|
||||
(Note :id post-123 (p "Hello"))))
|
||||
```
|
||||
|
||||
Sign the canonical serialization of the inner expression. Verify by re-serializing and checking. No compaction/expansion ambiguity.
|
||||
|
||||
---
|
||||
|
||||
## Bidirectional Stream: The Unified Model
|
||||
|
||||
A connection between two sexp-speaking peers carries everything on one stream:
|
||||
|
||||
```scheme
|
||||
;; === Connection opened ===
|
||||
|
||||
;; Client browses
|
||||
(GET "/")
|
||||
(ok (page :title "Home" ...))
|
||||
|
||||
;; Client navigates (same connection)
|
||||
(GET "/markets/")
|
||||
(ok (page :title "Markets" ...))
|
||||
|
||||
;; Client follows a user (federation)
|
||||
(Follow :actor alice :object bob)
|
||||
|
||||
;; Server confirms
|
||||
(Accept :actor bob (Follow :actor alice :object bob))
|
||||
|
||||
;; Bob posts something — server pushes activity
|
||||
(Create :actor bob :published "2026-02-28T15:00:00Z"
|
||||
(Note :id post-456 (p "New vendor announcement!")))
|
||||
|
||||
;; Client sees it rendered in real-time via mutation
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :actor "bob" :content (p "New vendor announcement!"))))
|
||||
|
||||
;; Client likes the post
|
||||
(POST "/like/" :body (:post-id 456))
|
||||
|
||||
;; Server pushes mutation + delivers Like activity to bob
|
||||
(push! (swap! "#like-count-456" :inner "12"))
|
||||
|
||||
;; Client opens a streaming feed
|
||||
(GET "/feed/" :stream #t)
|
||||
(ok :stream #t (page :title "Feed" ...))
|
||||
|
||||
;; More activities arrive over time...
|
||||
(Create :actor charlie :published "2026-02-28T15:30:00Z"
|
||||
(Note :id post-789 (p "Market day tomorrow!")))
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :actor "charlie" :content (p "Market day tomorrow!"))))
|
||||
|
||||
;; === Connection persists ===
|
||||
```
|
||||
|
||||
Browsing, federation, real-time updates, and user actions — all on one bidirectional stream. The distinction between "web server", "AP inbox", and "WebSocket" disappears. They were always the same thing — peers exchanging structured expressions.
|
||||
|
||||
---
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
### With HTTP (Tier 0 and Tier 1)
|
||||
|
||||
The sexp protocol runs alongside HTTPS. The same server handles both:
|
||||
|
||||
```python
|
||||
@bp.get("/markets/")
|
||||
async def markets():
|
||||
data = await get_markets(g.s)
|
||||
tree = sexp('(page :title "Markets" ...)', markets=data)
|
||||
|
||||
accept = request.headers.get("Accept", "")
|
||||
if "application/x-sexpr" in accept:
|
||||
return Response(serialize(tree), content_type="application/x-sexpr")
|
||||
|
||||
html = render_to_html(tree)
|
||||
return Response(html, content_type="text/html")
|
||||
```
|
||||
|
||||
### With ActivityPub (JSON-LD peers)
|
||||
|
||||
For Mastodon, Pleroma, and other JSON-LD AP servers:
|
||||
|
||||
| Peer Type | Outbound | Inbound |
|
||||
|---|---|---|
|
||||
| Standard AP (Mastodon, Pleroma) | Translate sexp → JSON-LD, include `rose:sexpr` extension field | Parse JSON-LD, translate to sexp internally |
|
||||
| Sexp-aware AP (other rose-ash instances) | Native sexp on bidirectional stream | Parse sexp directly |
|
||||
| Sexp-aware with shared components | Sexp with component references | Resolve from cache, render natively |
|
||||
|
||||
JSON-LD bridging for non-sexp peers:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": ["https://www.w3.org/ns/activitystreams", "https://rose-ash.com/ns/sexpr"],
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"content": "<p>Hello world</p>",
|
||||
"rose:sexpr": "(p \"Hello world\")"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No existing AP implementation breaks. The `rose:sexpr` field is ignored by servers that don't understand it.
|
||||
|
||||
### Fallback Gateway
|
||||
|
||||
For browsers without extension or native client:
|
||||
|
||||
```
|
||||
sexpr://rose-ash.com/markets/
|
||||
→ Gateway fetches sexp from server
|
||||
→ Renders to HTML
|
||||
→ Serves to browser at https://rose-ash.com/markets/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Three Client Tiers
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ rose-ash server (Quart) │
|
||||
│ │
|
||||
│ Same sexp component tree │
|
||||
│ Same data, same logic │
|
||||
│ │
|
||||
├──────────┬──────────┬────────┤
|
||||
│ HTTPS │ HTTPS │ SEXPR │
|
||||
│ HTML out │ sexp out │ native │
|
||||
└────┬─────┴────┬─────┴───┬────┘
|
||||
│ │ │
|
||||
┌──────────▼──┐ ┌─────▼──────┐ ┌▼──────────────┐
|
||||
│ Browser │ │ Browser + │ │ Rust client │
|
||||
│ (vanilla) │ │ extension │ │ (native) │
|
||||
│ │ │ │ │ │
|
||||
│ HTML + HTMX │ │ sexpr.js │ │ sexp protocol │
|
||||
│ Full CSS │ │ over HTTPS │ │ over QUIC │
|
||||
│ ~200ms load │ │ ~80ms load │ │ ~20ms load │
|
||||
└─────────────┘ └────────────┘ └────────────────┘
|
||||
Tier 0 Tier 1 Tier 2
|
||||
```
|
||||
|
||||
| | Tier 0: Browser | Tier 1: Extension | Tier 2: Rust Client |
|
||||
|---|---|---|---|
|
||||
| URL | `https://rose-ash.com` | `https://rose-ash.com` | `sexpr://rose-ash.com` |
|
||||
| Protocol | HTTPS | HTTPS | sexpr:// over QUIC |
|
||||
| Wire format | HTML | sexp over HTTP | sexp native stream |
|
||||
| Rendering | Browser DOM | sexpr.js → DOM | Rust → GPU |
|
||||
| Component cache | Browser cache (URL-keyed) | IndexedDB (hash-keyed) | Disk (hash-keyed, pre-parsed AST) |
|
||||
| Real-time | HTMX polling / SSE | WebSocket sexp mutations | Native bidirectional stream |
|
||||
| Federation | N/A | AP via fetch | Native sexp stream |
|
||||
| Bundle size | HTMX 14KB + CSS | sexpr.js ~8KB | 5MB binary, zero runtime deps |
|
||||
| Page load | ~200ms | ~80ms | ~20ms |
|
||||
| Memory per page | ~200MB | ~150MB | ~20MB |
|
||||
| AI integration | Parse HTML (painful) | Parse sexp (easy) | Native sexp (trivial) |
|
||||
|
||||
### Rust Client Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ sexpr-client (Rust) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Parser │ │ Component │ │
|
||||
│ │ (zero- │ │ Cache │ │
|
||||
│ │ copy) │ │ (SHA3 → AST) │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Layout │ │ Network │ │
|
||||
│ │ Engine │ │ (tokio + QUIC) │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Renderer (wgpu / iced) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why it's fast:**
|
||||
- Parser: nanoseconds per node (trivial recursive descent in Rust)
|
||||
- No HTML parser, no CSS cascade, no DOM construction, no JS engine
|
||||
- Render directly to GPU surface — skip the entire browser rendering pipeline
|
||||
- Pre-parsed ASTs from disk cache — zero parse time on cache hit
|
||||
- 10-50MB memory vs 200-500MB per browser tab
|
||||
|
||||
Page load: network (~50ms) → parse (~0.1ms) → layout (~2ms) → paint (~3ms) = **under 60ms**.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Content Negotiation (Quart)
|
||||
|
||||
Add `Accept` header handling to existing Quart routes.
|
||||
|
||||
- Check for `application/x-sexpr` in `Accept` header
|
||||
- If present: serialize sexp tree, return directly
|
||||
- If absent: render to HTML as today
|
||||
- Add `Vary: Accept` header
|
||||
|
||||
**Files:**
|
||||
- `shared/infrastructure/factory.py` — content negotiation middleware
|
||||
- `shared/sexp/serialize.py` — canonical sexp serializer (deterministic for signing/caching)
|
||||
|
||||
**Result:** Existing routes serve sexp to any client that asks. Zero changes to route logic.
|
||||
|
||||
### Phase 2: Sexp ↔ JSON-LD Bridge
|
||||
|
||||
Bidirectional translation for AP federation with non-sexp peers:
|
||||
|
||||
**sexp → JSON-LD** (outbound):
|
||||
- Activity type symbol → `"type"` field
|
||||
- Keyword attributes → JSON object properties
|
||||
- Content sexp → rendered to HTML for `"content"` field
|
||||
- Include `rose:sexpr` extension field
|
||||
|
||||
**JSON-LD → sexp** (inbound):
|
||||
- `"type"` → head symbol
|
||||
- `"content"` HTML → parsed to sexp (best-effort)
|
||||
- Nested objects → child expressions
|
||||
|
||||
**Files:**
|
||||
- `shared/infrastructure/ap_sexpr.py` — serializer/deserializer
|
||||
- `shared/sexp/html_to_sexp.py` — HTML → sexp parser for inbound content
|
||||
- `shared/sexp/tests/test_ap_sexpr.py` — round-trip tests
|
||||
|
||||
### Phase 3: sexpr.js Client Library
|
||||
|
||||
Browser-side JS runtime (see `sexpr-js-runtime-plan.md`):
|
||||
|
||||
- Parser + renderer: `parse()` → AST → `renderToDOM()`
|
||||
- Mutation engine: `swap!`, `batch!`, `class!`, `request!`
|
||||
- Component registry with localStorage cache (content-addressed)
|
||||
- WebSocket connection for real-time pushes
|
||||
|
||||
### Phase 4: Browser Extension
|
||||
|
||||
Package sexpr.js as a WebExtension:
|
||||
|
||||
- Register `sexpr://` protocol handler
|
||||
- Intercept navigation → fetch via HTTPS with `Accept: application/x-sexpr`
|
||||
- Parse → render to DOM
|
||||
- Component cache in IndexedDB (content-addressed)
|
||||
|
||||
**Tech:** WebExtension API (Firefox + Chrome), JS/TS.
|
||||
|
||||
### Phase 5: Component Discovery
|
||||
|
||||
Enable peers to discover and cache shared components:
|
||||
|
||||
- Actor profiles include `:components` endpoint URL
|
||||
- `GET /ap/components/` → manifest (name → hash)
|
||||
- `GET /ap/components/{hash}` → component definition (sexp)
|
||||
- Content-addressed cache on client (hash-keyed)
|
||||
- Federated content with `(use "component" ...)` resolves from cache
|
||||
|
||||
**Files:**
|
||||
- `federation/bp/components/routes.py` — manifest + fetch endpoints
|
||||
- `shared/sexp/component_manifest.py` — hash generation
|
||||
|
||||
### Phase 6: Protocol Specification
|
||||
|
||||
Formal specification document:
|
||||
|
||||
- **Framing**: length-prefixed sexp over QUIC streams
|
||||
- **Expression types**: requests, responses, pushes, activities, collections
|
||||
- **Canonical serialization**: deterministic rules for signing
|
||||
- **Caching**: content-hash based
|
||||
- **Authentication**: token in request keywords
|
||||
- **Component discovery**: manifest protocol
|
||||
- **Bidirectional streaming**: lifecycle, multiplexing
|
||||
- **Schema introspection**: `/__schema/` endpoint
|
||||
- **JSON-LD bridging**: rules for non-sexp AP peers
|
||||
- **Security**: no eval, escaping, signature verification
|
||||
|
||||
Publish as a FEP (Fediverse Enhancement Proposal) and standalone specification.
|
||||
|
||||
### Phase 7: Rust Protocol Server
|
||||
|
||||
QUIC server alongside Hypercorn:
|
||||
|
||||
- Listen on separate port (e.g., 4433)
|
||||
- Parse sexp requests, route to same handler logic
|
||||
- Bidirectional stream: pushes, requests, responses, activities
|
||||
- Component manifest endpoint
|
||||
- Federation delivery on persistent connections (replaces HTTP POST to inbox)
|
||||
|
||||
**Tech:** Rust, `quinn` (QUIC), `tokio`, `rustls`.
|
||||
|
||||
**Files:**
|
||||
- `sexpr-server/src/protocol.rs` — framing, parsing, routing
|
||||
- `sexpr-server/src/quic.rs` — QUIC listener + stream management
|
||||
- `sexpr-server/src/federation.rs` — peer connection manager
|
||||
|
||||
### Phase 8: Rust Native Client
|
||||
|
||||
Standalone sexp document viewer:
|
||||
|
||||
- QUIC client for `sexpr://` URLs
|
||||
- Zero-copy sexp parser (arena-allocated AST)
|
||||
- Component cache on disk (SHA3-keyed)
|
||||
- Layout engine (flexbox subset)
|
||||
- GPU renderer via `wgpu` or `iced`
|
||||
- Text rendering via `cosmic-text`
|
||||
- Bidirectional stream: browsing + federation + real-time on one connection
|
||||
|
||||
**Tech:** Rust, `quinn`, `wgpu`/`iced`, `cosmic-text`, `tokio`.
|
||||
|
||||
**Files:**
|
||||
- `sexpr-client/src/parser.rs` — zero-copy parser
|
||||
- `sexpr-client/src/cache.rs` — content-addressed component cache
|
||||
- `sexpr-client/src/layout.rs` — layout engine
|
||||
- `sexpr-client/src/render.rs` — GPU renderer
|
||||
- `sexpr-client/src/stream.rs` — bidirectional connection manager
|
||||
|
||||
### Phase 9: Fallback Gateway
|
||||
|
||||
HTTP proxy for browsers without extension/client:
|
||||
|
||||
- Accept HTTPS requests at `https://rose-ash.com`
|
||||
- Fetch sexp from server internally
|
||||
- Render to HTML, serve to browser
|
||||
- Add `<link rel="alternate" type="application/x-sexpr" href="sexpr://...">` for discovery
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
Each phase builds on the last. No phase breaks existing functionality:
|
||||
|
||||
```
|
||||
Phase 1 (content negotiation) ── Tier 0 unchanged, sexp available
|
||||
Phase 2 (JSON-LD bridge) ── federation works with all AP peers
|
||||
Phase 3 (sexpr.js) ── Tier 1 client-side rendering
|
||||
Phase 4 (extension) ── sexpr:// URLs in browser
|
||||
Phase 5 (components) ── federated UI exchange
|
||||
Phase 6 (spec) ── formal protocol document
|
||||
Phase 7 (Rust server) ── native protocol alongside HTTPS
|
||||
Phase 8 (Rust client) ── Tier 2 native experience
|
||||
Phase 9 (gateway) ── sexpr:// accessible from any browser
|
||||
```
|
||||
|
||||
Depends on:
|
||||
- **Ghost removal** (see `ghost-removal-plan.md`) — posts must be sexp before Phases 2-3 add real value
|
||||
- **sexpr.js runtime** (see `sexpr-js-runtime-plan.md`) — the JS library that powers Phases 3-4
|
||||
|
||||
---
|
||||
|
||||
## Client as Node: Cooperative Compute Mesh
|
||||
|
||||
### Everyone Has a Server
|
||||
|
||||
The original web was peer-to-peer — everyone ran a server on their workstation. Then we centralised everything into data centres because HTTP was stateless and browsers were passive. The sexp protocol with client-as-node reverses that.
|
||||
|
||||
Each member's Rust client is not just a viewer — it's a full peer node:
|
||||
- An **ActivityPub instance** (keypair, identity, inbox/outbox)
|
||||
- An **IPFS node** (storing and serving content-addressed data)
|
||||
- An **artdag worker** (local GPU for media processing)
|
||||
- A **sexp peer** (bidirectional streams to relay and other peers)
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Alice │ │ Bob │ │ Charlie │
|
||||
│ RTX 4070 │ │ M2 MacBook │ │ RX 7900 │
|
||||
│ 12GB VRAM │ │ 16GB unified │ │ 20GB VRAM │
|
||||
│ │ │ │ │ │
|
||||
│ artdag node │ │ artdag node │ │ artdag node │
|
||||
│ IPFS node │ │ IPFS node │ │ IPFS node │
|
||||
│ sexp peer │ │ sexp peer │ │ sexp peer │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────┬────────┴────────┬────────┘
|
||||
│ │
|
||||
┌────────▼─────────────────▼────────┐
|
||||
│ rose-ash relay │
|
||||
│ │
|
||||
│ • Message queue (offline inbox) │
|
||||
│ • Capability registry │
|
||||
│ • IPFS pinning service │
|
||||
│ • HTTPS gateway (Tier 0) │
|
||||
│ • Peer directory │
|
||||
│ • Federation bridge (JSON-LD) │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Offline Persistence
|
||||
|
||||
When a member's client goes offline, their content persists on IPFS. The relay provides two services:
|
||||
|
||||
**IPFS pinning** — members' CIDs are pinned by the cooperative's pinning node, ensuring content stays available even when the author's client is off. This is cheap — just disk storage, no compute.
|
||||
|
||||
**Message queuing** — activities addressed to an offline member are held by the relay and drained when they reconnect:
|
||||
|
||||
```scheme
|
||||
;; Alice is offline. Bob sends her a message.
|
||||
;; The relay holds it.
|
||||
|
||||
;; Alice's client comes online, connects to relay
|
||||
(hello :actor "alice@rose-ash.com" :last-seen "2026-02-28T12:00:00Z")
|
||||
|
||||
;; Relay drains the queue
|
||||
(queued :count 3 :since "2026-02-28T12:00:00Z"
|
||||
(Create :actor bob :published "2026-02-28T16:00:00Z"
|
||||
(Note (p "See you at the market Saturday!")))
|
||||
(Like :actor charlie :object alice-post-42)
|
||||
(Follow :actor dave :object alice))
|
||||
|
||||
;; Alice's client processes them, sends acknowledgment
|
||||
(ack :through "2026-02-28T16:00:00Z")
|
||||
|
||||
;; Relay clears the queue. Now alice is live —
|
||||
;; subsequent activities stream directly peer-to-peer.
|
||||
```
|
||||
|
||||
### Cooperative GPU Sharing
|
||||
|
||||
Members contribute idle GPU cycles to the cooperative. The relay acts as a job matchmaker:
|
||||
|
||||
```scheme
|
||||
;; Alice uploads a video. Her laptop has integrated graphics — too slow.
|
||||
(submit-job
|
||||
:type "artdag/render"
|
||||
:recipe "bafyrecipe..."
|
||||
:input "bafyinput..."
|
||||
:requirements (:min-vram 8 :gpu #t))
|
||||
|
||||
;; Relay knows Charlie's RTX 7900 is online and idle.
|
||||
;; Job routes to Charlie's client.
|
||||
(job :id "job-789" :assigned-to charlie
|
||||
:type "artdag/render"
|
||||
:recipe "bafyrecipe..."
|
||||
:input "bafyinput...")
|
||||
|
||||
;; Charlie's client runs the job, pins result to IPFS
|
||||
(job-complete :id "job-789"
|
||||
:output "bafyoutput..."
|
||||
:duration-ms 4200
|
||||
:worker charlie)
|
||||
|
||||
;; Alice gets notified
|
||||
(push! (swap! "#render-status" :inner
|
||||
(use "render-complete" :cid "bafyoutput...")))
|
||||
```
|
||||
|
||||
This is already how artdag works conceptually. The L1 server is a Celery worker that picks up rendering tasks. Replace "Celery worker on a cloud server" with "Celery worker on a member's desktop" and the architecture barely changes. The task queue just has different workers.
|
||||
|
||||
### Economics
|
||||
|
||||
| | Centralised (current) | Cooperative mesh |
|
||||
|---|---|---|
|
||||
| Image/video processing | Cloud GPU ($2-5/hr) | Member's local GPU (free) |
|
||||
| Content storage | Server disk + S3 | IPFS (distributed) + pinning |
|
||||
| Content serving | Server bandwidth | Peer-to-peer + IPFS |
|
||||
| Server cost | GPU instances + storage + bandwidth | Cheap relay (CPU + disk only) |
|
||||
| Scaling | More users = more cost | More members = more capacity |
|
||||
|
||||
The co-op's infrastructure cost drops to: **one small VPS + IPFS pinning storage.** That's it. All compute — rendering, processing, serving content — is distributed across members' machines.
|
||||
|
||||
More members joining makes the network faster and more capable, not more expensive. Like BitTorrent seeding, but for an entire application platform.
|
||||
|
||||
### The Relay Server's Role
|
||||
|
||||
The relay is minimal — a matchmaker and persistence layer, not a compute provider:
|
||||
|
||||
- **Peer directory**: who's online, their QUIC address, their GPU capabilities
|
||||
- **Message queue**: hold activities for offline members
|
||||
- **IPFS pinning**: persist content when authors are offline
|
||||
- **HTTPS gateway**: serve HTML to Tier 0 browsers (visitors, search engines)
|
||||
- **Federation bridge**: translate sexp ↔ JSON-LD for Mastodon/Pleroma peers
|
||||
- **Job queue**: match GPU-intensive tasks to available peers
|
||||
- **Capability registry**: what each peer can do (GPU model, VRAM, storage)
|
||||
|
||||
The relay does no rendering, no media processing, no content generation. Its cost stays flat regardless of member count.
|
||||
|
||||
### Content Flow
|
||||
|
||||
```
|
||||
Author creates post:
|
||||
1. Edit in Rust client (local)
|
||||
2. Render media with local GPU (artdag)
|
||||
3. Pin content + media to IPFS (local node)
|
||||
4. Publish CIDs to relay (for pinning + discovery)
|
||||
5. Stream activity to connected followers (peer-to-peer)
|
||||
6. Relay queues activity for offline followers
|
||||
|
||||
Reader views post:
|
||||
1. Fetch sexp from author's client (if online, peer-to-peer)
|
||||
2. Or fetch from IPFS by CID (if author offline)
|
||||
3. Or fetch from relay gateway as HTML (if Tier 0 browser)
|
||||
4. Components resolved from local cache (content-addressed)
|
||||
5. Render locally (Rust GPU or sexpr.js in browser)
|
||||
```
|
||||
|
||||
No server rendered anything. No server stored anything permanently. No server paid for GPU time. The cooperative's members are the infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Cooperative Angle
|
||||
|
||||
- Members install the Rust client → fast native experience, 5MB binary, no app store
|
||||
- Visitors browse `https://rose-ash.com` → standard HTML, no barrier
|
||||
- Federated co-ops connect via persistent sexp streams → rich UI exchange, not just text syndication
|
||||
- AI agents speak the protocol natively → components as tool calls, mutations as actions
|
||||
- Auto-updates via content-addressed components → no gatekeeping
|
||||
- The component registry is a shared vocabulary across the cooperative network
|
||||
- Members' desktops are the cloud — contributing GPU, storage, and bandwidth
|
||||
- The relay server stays cheap and flat-cost regardless of growth
|
||||
- The original vision of the web: everyone has a server
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Plans
|
||||
|
||||
| Document | Role |
|
||||
|---|---|
|
||||
| `sexpr-js-runtime-plan.md` | The JS library powering Tier 1 (Phases 3-4) |
|
||||
| `ghost-removal-plan.md` | Posts must be sexp before federation/client rendering adds value |
|
||||
| `sexpr-ai-integration.md` | AI agents benefit from all tiers and the self-describing schema |
|
||||
| artdag (`artdag/`) | The media processing engine that runs on member GPUs |
|
||||
|
||||
---
|
||||
|
||||
*The document is the program. The program is the document. The protocol is both. The network is its members.*
|
||||
73
docs/sexpr-web-transformation.md
Normal file
73
docs/sexpr-web-transformation.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# How S-expression Protocol Changes the Web
|
||||
|
||||
**What happens when the wire format is structured trees, verbs are open, and every node speaks the same language.**
|
||||
|
||||
---
|
||||
|
||||
## The URL Bar Becomes a REPL
|
||||
|
||||
Today URLs are nouns — you go *to* a page. With open verbs, every interaction is a complete expression. The browser isn't a document viewer, it's a program evaluator. The distinction between "visiting a website" and "calling an API" disappears entirely.
|
||||
|
||||
---
|
||||
|
||||
## APIs Stop Existing as a Separate Concept
|
||||
|
||||
Right now every web service has two interfaces: the human one (HTML) and the machine one (REST/GraphQL JSON). They're built separately, documented separately, versioned separately. With sexp on the wire, there's one interface. A browser renders it visually, an AI agent reads it structurally, a script pipes it — same stream, same verbs, same schema. The entire API economy (Swagger, OpenAPI, Postman, API gateways) becomes unnecessary infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Front-end Frameworks Collapse
|
||||
|
||||
React, Vue, Svelte exist because HTML is a document format being forced to act as an application format. The framework bridges that gap — maintaining state, diffing virtual DOMs, hydrating server markup. If the server sends a tree that *is* the application state, and the client evaluates it directly, the framework layer has nothing to do. Components are functions. State is bindings. The runtime is tiny.
|
||||
|
||||
---
|
||||
|
||||
## AI Becomes a First-Class Web Citizen
|
||||
|
||||
Today an AI agent scraping a website is doing archaeology — parsing HTML that was designed for human eyes, guessing at structure, breaking when CSS classes change. With sexp, the AI reads the same structured tree the browser renders. It can issue `(reserve ...)` or `(vote ...)` as naturally as it calls tools. The web becomes as easy for machines to use as it is for humans — which is the opposite of today, where we bolt on APIs as an afterthought.
|
||||
|
||||
---
|
||||
|
||||
## The Browser Monopoly Breaks
|
||||
|
||||
HTTP+HTML is so complex that only three organisations on earth can build a competitive browser engine. A sexp evaluator is a few thousand lines of code. Anyone can build a client — a Rust terminal app, an Emacs mode, a watch face, a screenreader. The web stops being "whatever Chrome renders" and becomes a protocol that any program can speak.
|
||||
|
||||
---
|
||||
|
||||
## Content Becomes Portable by Default
|
||||
|
||||
Today moving your blog from WordPress to Ghost to Hugo means converting between incompatible formats. If content is sexp — just trees — it's trivially parseable, transformable, and storable. Content-addressed hashing means the same post has the same identity everywhere. Your writing isn't trapped in a platform's database schema.
|
||||
|
||||
---
|
||||
|
||||
## The Client-Server Distinction Blurs
|
||||
|
||||
HTTP is rigidly asymmetric — clients request, servers respond. With bidirectional sexp streams, your laptop is a server too. It has an inbox. Other nodes can send it activities. The cooperative compute mesh isn't a radical addition — it's just what happens when clients and servers speak the same language in both directions. The web returns to its peer-to-peer roots.
|
||||
|
||||
---
|
||||
|
||||
## Governance Becomes Computational
|
||||
|
||||
`(propose ...)`, `(vote ...)`, `(ratify ...)` aren't metaphors — they're protocol-level actions with the same standing as `(GET ...)`. Decision-making processes can be expressed, validated, and executed by the protocol itself. A cooperative doesn't need a separate governance platform — the web *is* the governance platform.
|
||||
|
||||
---
|
||||
|
||||
## The Real Shift
|
||||
|
||||
HTTP was designed for physicists sharing documents in 1991. Everything since — cookies, JavaScript, AJAX, REST, WebSockets, SPAs, GraphQL — has been patches on top of that document-sharing model. The sexp protocol starts from what the web actually is in 2026: a network of programs exchanging structured data, where humans are one of many consumers. That's not an incremental improvement on HTTP. It's a different answer to the question "what is a web request?"
|
||||
|
||||
---
|
||||
|
||||
## The Unix Pipes Analogy
|
||||
|
||||
The closest historical analogy is Unix pipes. Before pipes, programs were monolithic. After pipes, small programs composed freely. The web today is monolithic — giant applications behind opaque interfaces. Sexp on the wire makes the web composable again.
|
||||
|
||||
| Before Pipes | Before Sexp Protocol |
|
||||
|---|---|
|
||||
| Monolithic programs | Monolithic web apps |
|
||||
| Custom file formats | HTML + JSON + GraphQL + WebSocket frames |
|
||||
| No composition | No composition (APIs are afterthoughts) |
|
||||
| **After Pipes** | **After Sexp Protocol** |
|
||||
| Small composable tools | Small composable nodes |
|
||||
| Universal text streams | Universal sexp streams |
|
||||
| `cat | grep | sort` | `(GET ...) → (swap! ...) → (render ...)` |
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, abort, request
|
||||
@@ -8,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments, register_actions, register_data
|
||||
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
|
||||
|
||||
|
||||
async def events_context() -> dict:
|
||||
@@ -89,10 +90,16 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
|
||||
# Calendars nested under post slug: /<slug>/calendars/...
|
||||
# Individual calendars at /<slug>/<calendar_slug>/
|
||||
app.register_blueprint(
|
||||
register_calendar(),
|
||||
url_prefix="/<slug>/<calendar_slug>",
|
||||
)
|
||||
|
||||
# Calendar admin under post slug: /<slug>/admin/
|
||||
app.register_blueprint(
|
||||
register_calendars(),
|
||||
url_prefix="/<slug>/calendars",
|
||||
url_prefix="/<slug>/admin",
|
||||
)
|
||||
|
||||
# Markets nested under post slug: /<slug>/markets/...
|
||||
@@ -101,12 +108,6 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<slug>/markets",
|
||||
)
|
||||
|
||||
# Payments nested under post slug: /<slug>/payments/...
|
||||
app.register_blueprint(
|
||||
register_payments(),
|
||||
url_prefix="/<slug>/payments",
|
||||
)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .all_events.routes import register as register_all_events
|
||||
from .calendar.routes import register as register_calendar
|
||||
from .calendars.routes import register as register_calendars
|
||||
from .markets.routes import register as register_markets
|
||||
from .payments.routes import register as register_payments
|
||||
from .page.routes import register as register_page
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
|
||||
@@ -11,12 +11,12 @@ Routes:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, render_template_string, make_response
|
||||
from quart import Blueprint, g, request, render_template, make_response
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CartSummaryDTO, PostDTO, dto_from_dict
|
||||
from shared.contracts.dtos import PostDTO, dto_from_dict
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
@@ -65,19 +65,14 @@ def register() -> Blueprint:
|
||||
|
||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||
|
||||
ctx = dict(
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_all_events_page, render_all_events_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if is_htmx_request():
|
||||
html = await render_template("_types/all_events/_main_panel.html", **ctx)
|
||||
html = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view)
|
||||
else:
|
||||
html = await render_template("_types/all_events/index.html", **ctx)
|
||||
html = await render_all_events_page(ctx, entries, has_more, pending_tickets, page_info, page, view)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@@ -88,15 +83,8 @@ def register() -> Blueprint:
|
||||
|
||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||
|
||||
html = await render_template(
|
||||
"_types/all_events/_cards.html",
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
pending_tickets=pending_tickets,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
view=view,
|
||||
)
|
||||
from sexp.sexp_components import render_all_events_cards
|
||||
html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/all-tickets/adjust")
|
||||
@@ -125,28 +113,20 @@ def register() -> Blueprint:
|
||||
# Load entry DTO for the widget template
|
||||
entry = await services.calendar.entry_by_id(g.s, entry_id)
|
||||
|
||||
# Updated cart count for OOB mini-cart
|
||||
summary_params = {}
|
||||
if ident["user_id"] is not None:
|
||||
summary_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
summary_params["session_id"] = ident["session_id"]
|
||||
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
|
||||
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
|
||||
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||
# Commit so cross-service calls see the updated tickets
|
||||
await g.tx.commit()
|
||||
g.tx = await g.s.begin()
|
||||
|
||||
# Render widget + OOB cart-mini
|
||||
widget_html = await render_template(
|
||||
"_types/page_summary/_ticket_widget.html",
|
||||
entry=entry,
|
||||
qty=qty,
|
||||
ticket_url="/all-tickets/adjust",
|
||||
)
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=cart_count,
|
||||
)
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
frag_params = {"oob": "1"}
|
||||
if ident["user_id"] is not None:
|
||||
frag_params["user_id"] = str(ident["user_id"])
|
||||
if ident["session_id"] is not None:
|
||||
frag_params["session_id"] = ident["session_id"]
|
||||
|
||||
from sexp.sexp_components import render_ticket_widget
|
||||
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
|
||||
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
|
||||
return await make_response(widget_html + mini_html, 200)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -19,13 +19,14 @@ def register():
|
||||
async def admin(calendar_slug: str, **kwargs):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_calendar_admin_page, render_calendar_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
html = await render_calendar_admin_page(tctx)
|
||||
else:
|
||||
# HTMX request: main panel + OOB elements
|
||||
html = await render_template("_types/calendar/admin/_oob_elements.html")
|
||||
html = await render_calendar_admin_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -33,12 +34,8 @@ def register():
|
||||
@bp.get("/description/")
|
||||
@require_admin
|
||||
async def calendar_description_edit(calendar_slug: str, **kwargs):
|
||||
# g.post and g.calendar should already be set by the parent calendar bp
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description_edit.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
from sexp.sexp_components import render_calendar_description_edit
|
||||
html = render_calendar_description_edit(g.calendar)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@@ -53,24 +50,16 @@ def register():
|
||||
g.calendar.description = description
|
||||
await g.s.flush()
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
oob=True
|
||||
)
|
||||
from sexp.sexp_components import render_calendar_description
|
||||
html = render_calendar_description(g.calendar, oob=True)
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.get("/description/view/")
|
||||
@require_admin
|
||||
async def calendar_description_view(calendar_slug: str, **kwargs):
|
||||
# just render the display version without touching the DB (used by Cancel)
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
post=g.post_data['post'],
|
||||
calendar=g.calendar,
|
||||
)
|
||||
from sexp.sexp_components import render_calendar_description
|
||||
html = render_calendar_description(g.calendar)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -77,9 +77,23 @@ def register():
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
container_nav_html = ""
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = post_data["post"]["id"]
|
||||
post_slug = post_data["post"]["slug"]
|
||||
container_nav_html = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": post_slug,
|
||||
"exclude": "page->calendar",
|
||||
})
|
||||
|
||||
return {
|
||||
"calendar": getattr(g, "calendar", None),
|
||||
"container_nav_html": container_nav_html,
|
||||
}
|
||||
|
||||
# ---------- Pages ----------
|
||||
@@ -142,47 +156,25 @@ def register():
|
||||
user_entries = visible.user_entries
|
||||
confirmed_entries = visible.confirmed_entries
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/calendar/index.html",
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_calendar_page, render_calendar_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(dict(
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
year=year, month=month, month_name=month_name,
|
||||
weekday_names=weekday_names, weeks=weeks,
|
||||
prev_month=prev_month, prev_month_year=prev_month_year,
|
||||
next_month=next_month, next_month_year=next_month_year,
|
||||
prev_year=prev_year, next_year=next_year,
|
||||
user_entries=user_entries, confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
))
|
||||
if not is_htmx_request():
|
||||
html = await render_calendar_page(tctx)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/_oob_elements.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
|
||||
html = await render_calendar_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@@ -205,7 +197,10 @@ def register():
|
||||
description = (form.get("description") or "").strip()
|
||||
|
||||
await update_calendar_description(g.calendar, description)
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import _calendar_admin_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
html = _calendar_admin_main_panel_html(ctx)
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
@@ -221,10 +216,14 @@ def register():
|
||||
|
||||
# If we have post context (blog-embedded mode), update nav
|
||||
post_data = getattr(g, "post_data", None)
|
||||
html = await render_template("_types/calendars/index.html")
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_calendars_list_panel
|
||||
ctx = await get_template_context()
|
||||
html = render_calendars_list_panel(ctx)
|
||||
|
||||
if post_data:
|
||||
from shared.services.entry_associations import get_associated_entries
|
||||
from sexp.sexp_components import render_post_nav_entries_oob
|
||||
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cals = (
|
||||
@@ -236,13 +235,7 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=cals,
|
||||
post=post_data["post"],
|
||||
)
|
||||
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
|
||||
html = html + nav_oob
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@@ -3,14 +3,11 @@ from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from quart import (
|
||||
request, render_template, render_template_string, make_response,
|
||||
request, render_template, make_response,
|
||||
Blueprint, g, redirect, url_for, jsonify,
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import update, func as sa_func
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
from .services.entries import (
|
||||
@@ -206,40 +203,63 @@ def register():
|
||||
entry.ticket_price = ticket_price
|
||||
entry.ticket_count = ticket_count
|
||||
|
||||
# Count pending calendar entries from local session (sees the just-added entry)
|
||||
user_id = getattr(g, "user", None) and g.user.id
|
||||
cal_filters = [
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "pending",
|
||||
]
|
||||
if user_id:
|
||||
cal_filters.append(CalendarEntry.user_id == user_id)
|
||||
# Commit so cross-service calls see the new entry
|
||||
await g.tx.commit()
|
||||
g.tx = await g.s.begin()
|
||||
|
||||
cal_count = await g.s.scalar(
|
||||
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
|
||||
) or 0
|
||||
|
||||
# Get product cart count via HTTP
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
ident = current_cart_identity()
|
||||
summary_params = {}
|
||||
frag_params = {"oob": "1"}
|
||||
if ident["user_id"] is not None:
|
||||
summary_params["user_id"] = ident["user_id"]
|
||||
frag_params["user_id"] = str(ident["user_id"])
|
||||
if ident["session_id"] is not None:
|
||||
summary_params["session_id"] = ident["session_id"]
|
||||
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
|
||||
cart_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
|
||||
product_count = cart_summary.count
|
||||
total_count = product_count + cal_count
|
||||
frag_params["session_id"] = ident["session_id"]
|
||||
|
||||
html = await render_template("_types/day/_main_panel.html")
|
||||
mini_html = await render_template_string(
|
||||
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||
'{{ mini(oob="true") }}',
|
||||
cart_count=total_count,
|
||||
# Re-query day entries for the sexp component
|
||||
from datetime import date as date_cls, timedelta
|
||||
from bp.calendar.services import get_visible_entries_for_period
|
||||
from quart import session as qsession
|
||||
|
||||
period_start = datetime(year, month, day, tzinfo=timezone.utc)
|
||||
period_end = period_start + timedelta(days=1)
|
||||
user = getattr(g, "user", None)
|
||||
session_id = qsession.get("calendar_sid")
|
||||
|
||||
visible = await get_visible_entries_for_period(
|
||||
sess=g.s,
|
||||
calendar_id=g.calendar.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Query day slots for this weekday
|
||||
day_date = date_cls(year, month, day)
|
||||
weekday_attr = ["mon","tue","wed","thu","fri","sat","sun"][day_date.weekday()]
|
||||
stmt = select(CalendarSlot).where(
|
||||
CalendarSlot.calendar_id == g.calendar.id,
|
||||
getattr(CalendarSlot, weekday_attr) == True,
|
||||
CalendarSlot.deleted_at.is_(None),
|
||||
).order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
|
||||
result = await g.s.execute(stmt)
|
||||
day_slots = list(result.scalars())
|
||||
|
||||
styles = getattr(g, "styles", None) or {}
|
||||
ctx = {
|
||||
"calendar": g.calendar,
|
||||
"day_entries": visible.merged_entries,
|
||||
"day": day,
|
||||
"month": month,
|
||||
"year": year,
|
||||
"hx_select_search": "#main-panel",
|
||||
"styles": styles,
|
||||
}
|
||||
|
||||
from sexp.sexp_components import render_day_main_panel
|
||||
html = render_day_main_panel(ctx)
|
||||
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
|
||||
return await make_response(html + mini_html, 200)
|
||||
|
||||
@bp.get("/add/")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user