From 22802bd36bc02223949a376c1c2aabb8f9526b0e Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 09:45:07 +0000 Subject: [PATCH] Send all responses as sexp wire format with client-side rendering - Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 --- .claude/plans/hazy-sniffing-sphinx.md | 325 +++++ account/app.py | 8 +- account/bp/account/routes.py | 23 +- account/bp/fragments/routes.py | 19 +- account/sexp/auth.sexpr | 20 +- account/sexp/dashboard.sexpr | 30 +- account/sexp/newsletters.sexpr | 22 +- account/sexp/sexp_components.py | 265 ++-- .../_types/auth/_newsletter_toggle.html | 8 +- .../_types/auth/_newsletters_panel.html | 8 +- blog/app.py | 10 +- blog/bp/admin/routes.py | 14 +- blog/bp/blog/admin/routes.py | 5 +- blog/bp/blog/routes.py | 38 +- blog/bp/fragments/routes.py | 44 +- blog/bp/menu_items/routes.py | 15 +- blog/bp/post/admin/routes.py | 46 +- blog/bp/post/routes.py | 18 +- blog/bp/snippets/routes.py | 13 +- blog/sexp/admin.sexpr | 74 +- blog/sexp/cards.sexpr | 136 +- blog/sexp/detail.sexpr | 27 +- blog/sexp/editor.sexpr | 2 +- blog/sexp/filters.sexpr | 44 +- blog/sexp/header.sexpr | 4 +- blog/sexp/index.sexpr | 53 +- blog/sexp/nav.sexpr | 28 +- blog/sexp/settings.sexpr | 48 +- blog/sexp/sexp_components.py | 1207 +++++++++-------- .../_types/blog/_action_buttons.html | 40 +- blog/templates/_types/blog/_card.html | 10 +- blog/templates/_types/blog/_card_tile.html | 10 +- blog/templates/_types/blog/_cards.html | 87 +- blog/templates/_types/blog/_main_panel.html | 44 +- blog/templates/_types/blog/_page_card.html | 10 +- blog/templates/_types/blog/_page_cards.html | 6 +- .../_types/blog/desktop/menu/authors.html | 20 +- .../_types/blog/desktop/menu/tag_groups.html | 20 +- .../_types/blog/desktop/menu/tags.html | 20 +- blog/templates/_types/blog/not_found.html | 10 +- .../_types/blog_drafts/_main_panel.html | 12 +- blog/templates/_types/menu_items/_form.html | 20 +- blog/templates/_types/menu_items/_list.html | 16 +- .../_types/menu_items/_main_panel.html | 6 +- .../templates/_types/menu_items/_nav_oob.html | 12 +- .../menu_items/_page_search_results.html | 8 +- .../_types/post/_entry_container.html | 8 +- blog/templates/_types/post/_entry_items.html | 8 +- blog/templates/_types/post/_main_panel.html | 10 +- .../post/admin/_associated_entries.html | 12 +- .../_types/post/admin/_calendar_view.html | 38 +- .../_types/post/admin/_features_panel.html | 20 +- .../_types/post/admin/_markets_panel.html | 14 +- .../_types/post/admin/_nav_entries.html | 17 +- .../_types/post_entries/_main_panel.html | 15 +- .../root/settings/cache/_main_panel.html | 8 +- blog/templates/_types/snippets/_list.html | 18 +- blog/templates/macros/scrolling_menu.html | 17 +- cart/app.py | 10 +- cart/bp/cart/global_routes.py | 2 +- cart/bp/cart/overview_routes.py | 6 +- cart/bp/cart/page_routes.py | 6 +- cart/bp/fragments/routes.py | 30 +- cart/bp/order/routes.py | 7 +- cart/bp/orders/routes.py | 10 +- cart/bp/page_admin/routes.py | 13 +- cart/sexp/calendar.sexpr | 10 +- cart/sexp/checkout.sexpr | 8 +- cart/sexp/header.sexpr | 42 +- cart/sexp/items.sexpr | 32 +- cart/sexp/order_detail.sexpr | 34 +- cart/sexp/orders.sexpr | 26 +- cart/sexp/overview.sexpr | 28 +- cart/sexp/payments.sexpr | 2 +- cart/sexp/sexp_components.py | 515 +++---- cart/sexp/summary.sexpr | 10 +- cart/sexp/tickets.sexpr | 28 +- cart/templates/_types/cart/_cart.html | 12 +- cart/templates/_types/cart/_mini.html | 2 +- cart/templates/_types/orders/_rows.html | 41 +- cart/templates/_types/product/_cart.html | 38 +- events/app.py | 10 +- events/bp/all_events/routes.py | 13 +- events/bp/calendar/admin/routes.py | 13 +- events/bp/calendar/routes.py | 17 +- events/bp/calendar_entries/routes.py | 3 +- events/bp/calendar_entry/routes.py | 27 +- events/bp/calendars/routes.py | 8 +- events/bp/day/admin/routes.py | 7 +- events/bp/day/routes.py | 12 +- events/bp/fragments/routes.py | 80 +- events/bp/markets/routes.py | 12 +- events/bp/page/routes.py | 13 +- events/bp/slot/routes.py | 10 +- events/bp/slots/routes.py | 9 +- events/bp/ticket_admin/routes.py | 21 +- events/bp/ticket_type/routes.py | 16 +- events/bp/ticket_types/routes.py | 6 +- events/bp/tickets/routes.py | 22 +- events/sexp/admin.sexpr | 62 +- events/sexp/calendar.sexpr | 50 +- events/sexp/day.sexpr | 36 +- events/sexp/entries.sexpr | 62 +- events/sexp/forms.sexpr | 501 +++++++ events/sexp/fragments.sexpr | 94 ++ events/sexp/header.sexpr | 4 +- events/sexp/markets.sexpr | 20 +- events/sexp/page.sexpr | 158 +-- events/sexp/payments.sexpr | 22 +- events/sexp/sexp_components.py | 992 +++++++------- events/sexp/tickets.sexpr | 68 +- .../templates/_types/all_events/_cards.html | 6 +- .../_types/all_events/_main_panel.html | 24 +- .../_types/calendar/_description.html | 2 +- .../_types/calendar/_main_panel.html | 50 +- .../_types/calendar/admin/_description.html | 6 +- .../calendar/admin/_description_edit.html | 12 +- .../_types/calendar/admin/_main_panel.html | 10 +- .../_types/calendars/_calendars_list.html | 22 +- .../_types/calendars/_main_panel.html | 12 +- events/templates/_types/day/_add.html | 14 +- events/templates/_types/day/_add_button.html | 6 +- .../_types/day/admin/_nav_entries_oob.html | 4 +- events/templates/_types/entry/_edit.html | 12 +- .../templates/_types/entry/_main_panel.html | 6 +- events/templates/_types/entry/_optioned.html | 4 +- events/templates/_types/entry/_options.html | 26 +- .../_types/entry/_post_search_results.html | 47 +- events/templates/_types/entry/_posts.html | 18 +- events/templates/_types/entry/_tickets.html | 6 +- .../_types/entry/admin/_nav_posts_oob.html | 4 +- .../templates/_types/markets/_main_panel.html | 12 +- .../_types/markets/_markets_list.html | 12 +- .../templates/_types/page_summary/_cards.html | 6 +- .../_types/page_summary/_main_panel.html | 24 +- .../_types/page_summary/_ticket_widget.html | 18 +- .../_types/payments/_main_panel.html | 8 +- .../post/admin/_associated_entries.html | 12 +- .../_types/post_entries/_main_panel.html | 13 +- .../templates/_types/slot/__description.html | 2 +- events/templates/_types/slot/_edit.html | 14 +- events/templates/_types/slot/_main_panel.html | 6 +- events/templates/_types/slots/_add.html | 18 +- .../templates/_types/slots/_add_button.html | 6 +- events/templates/_types/slots/_row.html | 12 +- .../_types/ticket_admin/_entry_tickets.html | 6 +- .../_types/ticket_admin/_lookup_result.html | 6 +- .../_types/ticket_admin/_main_panel.html | 14 +- .../templates/_types/ticket_type/_edit.html | 14 +- .../_types/ticket_type/_main_panel.html | 6 +- .../templates/_types/ticket_types/_add.html | 18 +- .../_types/ticket_types/_add_button.html | 6 +- .../templates/_types/ticket_types/_row.html | 12 +- .../templates/_types/tickets/_buy_form.html | 36 +- .../fragments/account_nav_items.html | 20 +- federation/app.py | 8 +- federation/bp/fragments/routes.py | 34 +- federation/bp/social/routes.py | 29 +- federation/sexp/auth.sexpr | 24 +- federation/sexp/notifications.sexpr | 24 +- federation/sexp/profile.sexpr | 50 +- federation/sexp/search.sexpr | 44 +- federation/sexp/sexp_components.py | 336 ++--- federation/sexp/social.sexpr | 72 +- .../_types/social/header/_header.html | 2 +- .../federation/_actor_list_items.html | 18 +- .../federation/_interaction_buttons.html | 24 +- .../templates/federation/_search_results.html | 18 +- .../templates/federation/_timeline_items.html | 12 +- .../templates/federation/choose_username.html | 8 +- federation/templates/federation/search.html | 6 +- market/app.py | 12 +- market/bp/all_markets/routes.py | 11 +- market/bp/browse/routes.py | 33 +- market/bp/browse/services/services.py | 5 +- market/bp/fragments/routes.py | 59 +- market/bp/market/admin/routes.py | 7 +- market/bp/page_admin/routes.py | 6 +- market/bp/page_markets/routes.py | 11 +- market/bp/product/routes.py | 23 +- market/sexp/cards.sexpr | 83 +- market/sexp/cart.sexpr | 15 +- market/sexp/detail.sexpr | 54 +- market/sexp/filters.sexpr | 92 +- market/sexp/grids.sexpr | 14 +- market/sexp/headers.sexpr | 8 +- market/sexp/meta.sexpr | 2 +- market/sexp/navigation.sexpr | 40 +- market/sexp/prices.sexpr | 8 +- market/sexp/sexp_components.py | 1044 +++++++------- .../templates/_types/all_markets/_cards.html | 6 +- .../_types/browse/_product_card.html | 20 +- .../_types/browse/_product_cards.html | 87 +- .../browse/desktop/_category_selector.html | 20 +- .../_types/browse/desktop/_filter/brand.html | 8 +- .../_types/browse/desktop/_filter/labels.html | 10 +- .../_types/browse/desktop/_filter/like.html | 10 +- .../_types/browse/desktop/_filter/search.html | 18 +- .../_types/browse/desktop/_filter/sort.html | 10 +- .../browse/desktop/_filter/stickers.html | 10 +- .../templates/_types/browse/like/button.html | 12 +- .../browse/mobile/_filter/brand_ul.html | 10 +- .../_types/browse/mobile/_filter/index.html | 10 +- .../_types/browse/mobile/_filter/labels.html | 10 +- .../_types/browse/mobile/_filter/like.html | 10 +- .../_types/browse/mobile/_filter/search.html | 18 +- .../_types/browse/mobile/_filter/sort_ul.html | 10 +- .../browse/mobile/_filter/stickers.html | 10 +- .../templates/_types/market/desktop/_nav.html | 20 +- .../_types/market/mobile/_nav_panel.html | 50 +- .../templates/_types/page_markets/_cards.html | 6 +- .../_types/post/admin/_nav_entries.html | 17 +- market/templates/_types/product/_cart.html | 42 +- market/templates/aside_clear.html | 2 +- market/templates/filter_clear.html | 2 +- market/templates/macros/filters.html | 10 +- orders/app.py | 8 +- orders/bp/fragments/routes.py | 15 +- orders/bp/order/routes.py | 6 +- orders/bp/orders/routes.py | 11 +- orders/sexp/checkout.sexpr | 14 +- orders/sexp/detail.sexpr | 46 +- orders/sexp/list.sexpr | 36 +- orders/sexp/sexp_components.py | 300 ++-- orders/templates/_types/orders/_rows.html | 41 +- relations/bp/fragments/routes.py | 30 +- shared/browser/app/errors.py | 36 +- shared/browser/app/redis_cacher.py | 2 +- shared/browser/app/utils/htmx.py | 40 +- shared/browser/app/utils/utils.py | 2 +- .../templates/_types/browse/like/button.html | 12 +- .../browser/templates/_types/root/_head.html | 10 +- .../templates/_types/root/_n/macros.html | 2 +- .../templates/_types/root/_oob_menu.html | 8 +- .../templates/_types/root/header/_oob.html | 4 +- .../templates/_types/root/header/_oob_.html | 4 +- .../browser/templates/macros/cart_icon.html | 2 +- shared/browser/templates/macros/layout.html | 4 +- shared/browser/templates/macros/links.html | 12 +- .../browser/templates/macros/nav_entries.html | 20 +- .../templates/macros/scrolling_menu.html | 17 +- shared/browser/templates/macros/search.html | 36 +- shared/browser/templates/oob_elements.html | 6 +- .../templates/social/_actor_list_items.html | 18 +- .../templates/social/_search_results.html | 18 +- .../templates/social/_timeline_items.html | 6 +- shared/browser/templates/social/search.html | 6 +- shared/infrastructure/ap_social.py | 4 +- shared/infrastructure/context.py | 2 +- shared/infrastructure/factory.py | 4 +- shared/infrastructure/fragments.py | 12 +- shared/infrastructure/http_utils.py | 6 +- shared/sexp/helpers.py | 331 +++-- shared/sexp/jinja_bridge.py | 2 +- shared/sexp/page.py | 2 +- shared/sexp/parser.py | 41 + shared/sexp/templates/cards.sexp | 18 +- shared/sexp/templates/controls.sexp | 110 +- shared/sexp/templates/fragments.sexp | 6 +- shared/sexp/templates/layout.sexp | 143 +- shared/sexp/templates/misc.sexp | 29 +- shared/sexp/templates/relations.sexp | 12 +- shared/sexp/tests/test_sexp_js.py | 28 + shared/static/scripts/body.js | 301 ++-- shared/static/scripts/sexp.js | 727 +++++++++- shared/utils/__init__.py | 2 +- test/app.py | 6 +- test/bp/dashboard/routes.py | 16 +- test/sexp/dashboard.sexpr | 34 +- test/sexp/sexp_components.py | 291 ++-- 270 files changed, 7153 insertions(+), 5382 deletions(-) create mode 100644 .claude/plans/hazy-sniffing-sphinx.md create mode 100644 events/sexp/forms.sexpr create mode 100644 events/sexp/fragments.sexpr diff --git a/.claude/plans/hazy-sniffing-sphinx.md b/.claude/plans/hazy-sniffing-sphinx.md new file mode 100644 index 0000000..071b175 --- /dev/null +++ b/.claude/plans/hazy-sniffing-sphinx.md @@ -0,0 +1,325 @@ +# Split Cart into Microservices + +## Context +The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces: + +1. **Relations service** — internal only, owns ContainerRelation +2. **Likes service** — internal only, unified generic likes replacing ProductLike + PostLike +3. **PageConfig → blog** — move to blog (which already owns pages) +4. **Orders service** — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout + +After the split, cart becomes a thin CartItem CRUD + inbox service. + +--- + +## Phase 1: Relations Service (internal only) + +### 1.1 Scaffold `relations/` +Create minimal internal-only app (no templates, no context_fn): + +| File | Notes | +|------|-------| +| `relations/__init__.py` | Empty | +| `relations/path_setup.py` | Copy from cart | +| `relations/app.py` | `create_base_app("relations")`, register data + actions BPs only | +| `relations/services/__init__.py` | Empty `register_domain_services()` | +| `relations/models/__init__.py` | `from shared.models.container_relation import ContainerRelation` | +| `relations/bp/__init__.py` | Export `register_data`, `register_actions` | +| `relations/bp/data/routes.py` | Move `get-children` handler from `cart/bp/data/routes.py:175-198` | +| `relations/bp/actions/routes.py` | Move `attach-child` + `detach-child` from `cart/bp/actions/routes.py:112-153` | +| `relations/alembic.ini` | Copy from cart, adjust path | +| `relations/alembic/env.py` | MODELS=`["shared.models.container_relation"]`, TABLES=`{"container_relations"}` | +| `relations/alembic/versions/0001_initial.py` | Create `container_relations` table | +| `relations/Dockerfile` | Follow cart pattern, `COPY relations/ ./` | +| `relations/entrypoint.sh` | Standard pattern, db=`db_relations` | + +### 1.2 Retarget callers (`"cart"` → `"relations"`) + +| File | Lines | Change | +|------|-------|--------| +| `events/bp/calendars/services/calendars.py` | 74, 111, 121 | `call_action("cart", ...)` → `call_action("relations", ...)` | +| `blog/bp/menu_items/services/menu_items.py` | 83, 137, 141, 157 | Same | +| `shared/services/market_impl.py` | 96, 109, 133 | Same | + +### 1.3 Clean up cart +- Remove `get-children` from `cart/bp/data/routes.py:175-198` +- Remove `attach-child`, `detach-child` from `cart/bp/actions/routes.py:112-153` +- Remove `"shared.models.container_relation"` and `"container_relations"` from `cart/alembic/env.py` + +--- + +## Phase 2: Likes Service (internal only) + +### 2.1 New unified model +Single `likes` table in `db_likes`: + +```python +class Like(Base): + __tablename__ = "likes" + id: Mapped[int] (pk) + user_id: Mapped[int] (not null, indexed) + target_type: Mapped[str] (String 32, not null) # "product" or "post" + target_slug: Mapped[str | None] (String 255) # for products + target_id: Mapped[int | None] (Integer) # for posts + created_at, updated_at, deleted_at + + UniqueConstraint("user_id", "target_type", "target_slug") + UniqueConstraint("user_id", "target_type", "target_id") + Index("ix_likes_target", "target_type", "target_slug") +``` + +Products use `target_type="product"`, `target_slug=slug`. Posts use `target_type="post"`, `target_id=post.id`. + +### 2.2 Scaffold `likes/` + +| File | Notes | +|------|-------| +| `likes/__init__.py` | Empty | +| `likes/path_setup.py` | Standard | +| `likes/app.py` | Internal-only, `create_base_app("likes")`, data + actions BPs | +| `likes/services/__init__.py` | Empty `register_domain_services()` | +| `likes/models/__init__.py` | Import Like | +| `likes/models/like.py` | Generic Like model (above) | +| `likes/bp/__init__.py` | Export register functions | +| `likes/bp/data/routes.py` | `is-liked`, `liked-slugs`, `liked-ids` | +| `likes/bp/actions/routes.py` | `toggle` action | +| `likes/alembic.ini` | Standard | +| `likes/alembic/env.py` | MODELS=`["likes.models.like"]`, TABLES=`{"likes"}` | +| `likes/alembic/versions/0001_initial.py` | Create `likes` table | +| `likes/Dockerfile` | Standard pattern | +| `likes/entrypoint.sh` | Standard, db=`db_likes` | + +### 2.3 Data endpoints (`likes/bp/data/routes.py`) +- `is-liked`: params `user_id, target_type, target_slug/target_id` → `{"liked": bool}` +- `liked-slugs`: params `user_id, target_type` → `["slug1", "slug2"]` +- `liked-ids`: params `user_id, target_type` → `[1, 2, 3]` + +### 2.4 Action endpoints (`likes/bp/actions/routes.py`) +- `toggle`: payload `{user_id, target_type, target_slug?, target_id?}` → `{"liked": bool}` + +### 2.5 Retarget market app + +**`market/bp/product/routes.py`** (like_toggle, ~line 119): +Replace `toggle_product_like(g.s, user_id, product_slug)` with: +```python +result = await call_action("likes", "toggle", payload={ + "user_id": user_id, "target_type": "product", "target_slug": product_slug +}) +liked = result["liked"] +``` + +**`market/bp/browse/services/db_backend.py`** (most complex): +- `db_product_full` / `db_product_full_id`: Replace `ProductLike` subquery with `fetch_data("likes", "is-liked", ...)`. Annotate `is_liked` after query. +- `db_products_nocounts` / `db_products_counts`: Fetch `liked_slugs` once via `fetch_data("likes", "liked-slugs", ...)`, filter `Product.slug.in_(liked_slugs)` for `?liked=true`, annotate `is_liked` post-query. + +**Delete**: `toggle_product_like` from `market/bp/product/services/product_operations.py` + +### 2.6 Retarget blog app + +**`blog/bp/post/routes.py`** (like_toggle): +Replace `toggle_post_like(g.s, user_id, post_id)` with `call_action("likes", "toggle", payload={...})`. + +**Delete**: `toggle_post_like` from `blog/bp/post/services/post_operations.py` + +### 2.7 Remove old like models +- Remove `ProductLike` from `shared/models/market.py` (lines 118-131) + `Product.likes` relationship (lines 110-114) +- Remove `PostLike` from `shared/models/ghost_content.py` + `Post.likes` relationship +- Remove `product_likes` from market alembic TABLES +- Remove `post_likes` from blog alembic TABLES + +--- + +## Phase 3: PageConfig → Blog + +### 3.1 Replace blog proxy endpoints with direct DB queries + +**`blog/bp/data/routes.py`** (lines 77-102): Replace the 3 proxy handlers that currently call `fetch_data("cart", ...)` with direct DB queries. Copy logic from `cart/bp/data/routes.py`: +- `page-config` (cart lines 114-134) +- `page-config-by-id` (cart lines 136-149) +- `page-configs-batch` (cart lines 151-172) +- `page-config-ensure` (cart lines 49-81) — add new + +Also add the `_page_config_dict` helper (cart lines 203-213). + +### 3.2 Move action to blog + +**`blog/bp/actions/routes.py`** (~line 40): Replace `call_action("cart", "update-page-config", ...)` proxy with direct handler. Copy logic from `cart/bp/actions/routes.py:51-110`. + +### 3.3 Blog callers become local + +| File | Current | After | +|------|---------|-------| +| `blog/bp/post/admin/routes.py:34` | `fetch_data("cart", "page-config", ...)` | Direct DB query (blog now owns table) | +| `blog/bp/post/admin/routes.py:87,132` | `call_action("cart", "update-page-config", ...)` | Direct call to local handler | +| `blog/bp/post/services/markets.py:44` | `fetch_data("cart", "page-config", ...)` | Direct DB query | +| `blog/bp/blog/ghost_db.py:295` | `fetch_data("cart", "page-configs-batch", ...)` | Direct DB query | + +### 3.4 Retarget cross-service callers (`"cart"` → `"blog"`) + +| File | Change | +|------|--------| +| `cart/bp/cart/services/page_cart.py:181` | `fetch_data("cart", "page-configs-batch", ...)` → `fetch_data("blog", "page-configs-batch", ...)` | +| `cart/bp/cart/global_routes.py:274` | `fetch_data("cart", "page-config-by-id", ...)` → `fetch_data("blog", "page-config-by-id", ...)` | + +(Note: `checkout.py:117` and `cart/app.py:177` already target `"blog"`) + +### 3.5 Update blog alembic +**`blog/alembic/env.py`**: Add `"shared.models.page_config"` to MODELS and `"page_configs"` to TABLES. + +### 3.6 Clean up cart +- Remove all `page-config*` handlers from `cart/bp/data/routes.py` (lines 49-172) +- Remove `update-page-config` from `cart/bp/actions/routes.py` (lines 50-110) +- Remove `"shared.models.page_config"` and `"page_configs"` from `cart/alembic/env.py` + +--- + +## Phase 4: Orders Service (public, orders.rose-ash.com) + +### 4.1 Scaffold `orders/` + +| File | Notes | +|------|-------| +| `orders/__init__.py` | Empty | +| `orders/path_setup.py` | Standard | +| `orders/app.py` | Public app with `context_fn`, templates, fragments, page slug hydration | +| `orders/services/__init__.py` | `register_domain_services()` | +| `orders/models/__init__.py` | `from shared.models.order import Order, OrderItem` | +| `orders/bp/__init__.py` | Export all BPs | +| `orders/bp/order/` | Move from `cart/bp/order/` (single order: detail, pay, recheck) | +| `orders/bp/orders/` | Move from `cart/bp/orders/` (order list + pagination) | +| `orders/bp/checkout/routes.py` | Webhook + return routes from `cart/bp/cart/global_routes.py` | +| `orders/bp/data/routes.py` | Minimal | +| `orders/bp/actions/routes.py` | `create-order` action (called by cart during checkout) | +| `orders/bp/fragments/routes.py` | `account-nav-item` fragment (orders link) | +| `orders/templates/` | Move `_types/order/`, `_types/orders/`, checkout templates from cart | +| `orders/alembic.ini` | Standard | +| `orders/alembic/env.py` | MODELS=`["shared.models.order"]`, TABLES=`{"orders", "order_items"}` | +| `orders/alembic/versions/0001_initial.py` | Create `orders` + `order_items` tables | +| `orders/Dockerfile` | Standard, public-facing | +| `orders/entrypoint.sh` | Standard, db=`db_orders` | + +### 4.2 Move checkout services to orders + +**Move to `orders/services/`:** +- `checkout.py` — from `cart/bp/cart/services/checkout.py` (move: `create_order_from_cart`, `resolve_page_config`, `build_sumup_*`, `get_order_with_details`. Keep `find_or_create_cart_item` in cart.) +- `check_sumup_status.py` — from `cart/bp/cart/services/check_sumup_status.py` + +**`clear_cart_for_order`** stays in cart as new action: +- Add `clear-cart-for-order` to `cart/bp/actions/routes.py` +- Orders calls `call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})` + +### 4.3 `create-order` action endpoint (`orders/bp/actions/routes.py`) +Cart's `POST /checkout/` calls this: +``` +Payload: {cart_items: [{product_id, product_title, product_slug, product_image, + product_special_price, product_regular_price, product_price_currency, + quantity, market_place_container_id}], + calendar_entries, tickets, user_id, session_id, + product_total, calendar_total, ticket_total, + page_post_id, redirect_url, webhook_base_url} +Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description} +``` + +### 4.4 Refactor cart's checkout route +`cart/bp/cart/global_routes.py` `POST /checkout/`: +1. Load local cart data (get_cart, calendar entries, tickets, totals) +2. Serialize cart items to dicts +3. `result = await call_action("orders", "create-order", payload={...})` +4. Redirect to `result["sumup_hosted_url"]` + +Same for page-scoped checkout in `cart/bp/cart/page_routes.py`. + +### 4.5 Move webhook + return routes to orders +- `POST /checkout/webhook//` → `orders/bp/checkout/routes.py` +- `GET /checkout/return//` → `orders/bp/checkout/routes.py` +- SumUp redirect/webhook URLs must now point to orders.rose-ash.com + +### 4.6 Move order list/detail routes +- `cart/bp/order/` → `orders/bp/order/` +- `cart/bp/orders/` → `orders/bp/orders/` + +### 4.7 Move startup reconciliation +`_reconcile_pending_orders` from `cart/app.py:209-265` → `orders/app.py` + +### 4.8 Clean up cart +- Remove `cart/bp/order/`, `cart/bp/orders/` +- Remove checkout webhook/return from `cart/bp/cart/global_routes.py` +- Remove `_reconcile_pending_orders` from `cart/app.py` +- Remove order templates from `cart/templates/` +- Remove `"shared.models.order"` and `"orders", "order_items"` from `cart/alembic/env.py` + +--- + +## Phase 5: Infrastructure (applies to all new services) + +### 5.1 docker-compose.yml +Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9). + +Add to `x-app-env`: +```yaml +INTERNAL_URL_RELATIONS: http://relations:8000 +INTERNAL_URL_LIKES: http://likes:8000 +INTERNAL_URL_ORDERS: http://orders:8000 +APP_URL_ORDERS: https://orders.rose-ash.com +``` + +### 5.2 docker-compose.dev.yml +Add all 3 services with dev volumes (ports 8008, 8009, 8010). +Add to `x-sibling-models` for all 3 new services. + +### 5.3 deploy.sh +Add `relations likes orders` to APPS list. + +### 5.4 Caddyfile (`/root/caddy/Caddyfile`) +Add only orders (public): +``` +orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 } +``` + +### 5.5 shared/infrastructure/factory.py +Add to model import loop: `"relations.models", "likes.models", "orders.models"` + +### 5.6 shared/infrastructure/urls.py +Add `orders_url(path)` helper. + +### 5.7 All existing Dockerfiles +Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account). + +### 5.8 CLAUDE.md +Update project structure and add notes about the new services. + +--- + +## Data Migration (one-time, run before code switch) + +1. `container_relations` from `db_cart` → `db_relations` +2. `product_likes` from `db_market` + `post_likes` from `db_blog` → `db_likes.likes` +3. `page_configs` from `db_cart` → `db_blog` +4. `orders` + `order_items` from `db_cart` → `db_orders` + +Use `pg_dump`/`pg_restore` or direct SQL for migration. + +--- + +## Post-Split Cart State + +After all 4 phases, cart owns only: +- **Model**: CartItem (table in db_cart) +- **Alembic**: `cart_items` only +- **Data endpoints**: `cart-summary`, `cart-items` +- **Action endpoints**: `adopt-cart-for-user`, `clear-cart-for-order` (new) +- **Inbox handlers**: Add/Remove/Update `rose:CartItem` +- **Public routes**: cart overview, page cart, add-to-cart, quantity, delete +- **Fragments**: `cart-mini` +- **Checkout**: POST /checkout/ (creates order via `call_action("orders", "create-order")`, redirects to SumUp) + +--- + +## Verification +1. **Relations**: Blog attach/detach marketplace to page; events attach/detach calendar +2. **Likes**: Toggle product like on market page; toggle post like on blog; `?liked=true` filter +3. **PageConfig**: Blog admin page config update; cart checkout resolves page config from blog +4. **Orders**: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com +5. No remaining `call_action("cart", "attach-child|detach-child|update-page-config")` +6. No remaining `fetch_data("cart", "page-config*|get-children")` +7. Cart alembic only manages `cart_items` table diff --git a/account/app.py b/account/app.py index e328b48..1a46aae 100644 --- a/account/app.py +++ b/account/app.py @@ -44,14 +44,14 @@ async def account_context() -> dict: if ident["session_id"] is not None: cart_params["session_id"] = ident["session_id"] - cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([ + cart_mini, auth_menu, nav_tree = await fetch_fragments([ ("cart", "cart-mini", cart_params or None), ("account", "auth-menu", {"email": user.email} if user else None), ("blog", "nav-tree", {"app_name": "account", "path": request.path}), ]) - ctx["cart_mini_html"] = cart_mini_html - ctx["auth_menu_html"] = auth_menu_html - ctx["nav_tree_html"] = nav_tree_html + ctx["cart_mini"] = cart_mini + ctx["auth_menu"] = auth_menu + ctx["nav_tree"] = nav_tree return ctx diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index aeb1b12..de94bf0 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -18,6 +18,7 @@ from shared.models import UserNewsletter from shared.models.ghost_membership_entities import GhostNewsletter from shared.infrastructure.urls import login_url from shared.infrastructure.fragments import fetch_fragment, fetch_fragments +from shared.sexp.helpers import sexp_response oob = { "oob_extends": "oob_elements.html", @@ -41,7 +42,7 @@ def register(url_prefix="/"): ("cart", "account-nav-item", {}), ("artdag", "nav-item", {}), ], required=False) - return {"oob": oob, "account_nav_html": events_nav + cart_nav + artdag_nav} + return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav} @account_bp.get("/") async def account(): @@ -55,10 +56,10 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_account_page(ctx) + return await make_response(html) else: - html = await render_account_oob(ctx) - - return await make_response(html) + sexp_src = await render_account_oob(ctx) + return sexp_response(sexp_src) @account_bp.get("/newsletters/") async def newsletters(): @@ -94,10 +95,10 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_newsletters_page(ctx, newsletter_list) + return await make_response(html) else: - html = await render_newsletters_oob(ctx, newsletter_list) - - return await make_response(html) + sexp_src = await render_newsletters_oob(ctx, newsletter_list) + return sexp_response(sexp_src) @account_bp.post("/newsletter//toggle/") async def toggle_newsletter(newsletter_id: int): @@ -125,7 +126,7 @@ def register(url_prefix="/"): await g.s.flush() from sexp.sexp_components import render_newsletter_toggle - return render_newsletter_toggle(un) + return sexp_response(render_newsletter_toggle(un)) # Catch-all for fragment-provided pages — must be last @account_bp.get("//") @@ -149,9 +150,9 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_fragment_page(ctx, fragment_html) + return await make_response(html) else: - html = await render_fragment_oob(ctx, fragment_html) - - return await make_response(html) + sexp_src = await render_fragment_oob(ctx, fragment_html) + return sexp_response(sexp_src) return account_bp diff --git a/account/bp/fragments/routes.py b/account/bp/fragments/routes.py index 6574e8f..1298c54 100644 --- a/account/bp/fragments/routes.py +++ b/account/bp/fragments/routes.py @@ -1,6 +1,6 @@ """Account app fragment endpoints. -Exposes HTML fragments at ``/internal/fragments/`` for consumption +Exposes sexp fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. Fragments: @@ -18,18 +18,17 @@ def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") # --------------------------------------------------------------- - # Fragment handlers + # Fragment handlers — return sexp source text # --------------------------------------------------------------- async def _auth_menu(): from shared.infrastructure.urls import account_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.helpers import sexp_call user_email = request.args.get("email", "") - return render_sexp( - '(~auth-menu :user-email user-email :account-url account-url)', - **{"user-email": user_email or None, "account-url": account_url("")}, - ) + return sexp_call("auth-menu", + user_email=user_email or None, + account_url=account_url("")) _handlers = { "auth-menu": _auth_menu, @@ -48,8 +47,8 @@ def register(): async def get_fragment(fragment_type: str): handler = _handlers.get(fragment_type) if handler is None: - return Response("", status=200, content_type="text/html") - html = await handler() - return Response(html, status=200, content_type="text/html") + return Response("", status=200, content_type="text/sexp") + src = await handler() + return Response(src, status=200, content_type="text/sexp") return bp diff --git a/account/sexp/auth.sexpr b/account/sexp/auth.sexpr index 530fc0a..ca906e3 100644 --- a/account/sexp/auth.sexpr +++ b/account/sexp/auth.sexpr @@ -3,12 +3,12 @@ (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)))) + error))) -(defcomp ~account-login-form (&key error-html action csrf-token email) +(defcomp ~account-login-form (&key error 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) + error (form :method "post" :action action :class "space-y-4" (input :type "hidden" :name "csrf_token" :value csrf-token) (div @@ -22,13 +22,13 @@ (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)))) + error))) -(defcomp ~account-device-form (&key error-html action csrf-token code) +(defcomp ~account-device-form (&key error 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) + error (form :method "post" :action action :class "space-y-4" (input :type "hidden" :name "csrf_token" :value csrf-token) (div @@ -48,11 +48,11 @@ (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)))) + error))) -(defcomp ~account-check-email (&key email error-html) +(defcomp ~account-check-email (&key email error) (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-600 mb-2" "We sent a sign-in link to " (strong 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))) + error)) diff --git a/account/sexp/dashboard.sexpr b/account/sexp/dashboard.sexpr index 4f2fc7a..2305bb7 100644 --- a/account/sexp/dashboard.sexpr +++ b/account/sexp/dashboard.sexpr @@ -3,15 +3,15 @@ (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)))) + error))) (defcomp ~account-user-email (&key email) (when email - (p :class "text-sm text-stone-500 mt-1" (raw! email)))) + (p :class "text-sm text-stone-500 mt-1" email))) (defcomp ~account-user-name (&key name) (when name - (p :class "text-sm text-stone-600" (raw! name)))) + (p :class "text-sm text-stone-600" name))) (defcomp ~account-logout-form (&key csrf-token) (form :action "/auth/logout/" :method "post" @@ -22,27 +22,27 @@ (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))) + name)) -(defcomp ~account-labels-section (&key items-html) - (when items-html +(defcomp ~account-labels-section (&key items) + (when items (div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels") - (div :class "flex flex-wrap gap-2" (raw! items-html))))) + (div :class "flex flex-wrap gap-2" items)))) -(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html) +(defcomp ~account-main-panel (&key error email name logout labels) (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) + error (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)))) + email + name) + logout) + labels))) ;; Header child wrapper -(defcomp ~account-header-child (&key inner-html) +(defcomp ~account-header-child (&key inner) (div :id "root-header-child" :class "flex flex-col w-full items-center" - (raw! inner-html))) + inner)) diff --git a/account/sexp/newsletters.sexpr b/account/sexp/newsletters.sexpr index 4896598..2a98f32 100644 --- a/account/sexp/newsletters.sexpr +++ b/account/sexp/newsletters.sexpr @@ -2,36 +2,36 @@ (defcomp ~account-newsletter-desc (&key description) (when description - (p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description)))) + (p :class "text-xs text-stone-500 mt-0.5 truncate" 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" + (button :sx-post url :sx-headers hdrs :sx-target target :sx-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" + (button :sx-post url :sx-headers hdrs :sx-target target :sx-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) +(defcomp ~account-newsletter-item (&key name desc toggle) (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)))) + (p :class "text-sm font-medium text-stone-800" name) + desc) + (div :class "ml-4 flex-shrink-0" toggle))) -(defcomp ~account-newsletter-list (&key items-html) - (div :class "divide-y divide-stone-100" (raw! items-html))) +(defcomp ~account-newsletter-list (&key items) + (div :class "divide-y divide-stone-100" items)) (defcomp ~account-newsletter-empty () (p :class "text-sm text-stone-500" "No newsletters available.")) -(defcomp ~account-newsletters-panel (&key list-html) +(defcomp ~account-newsletters-panel (&key list) (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)))) + list))) diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py index 422d0eb..19f2339 100644 --- a/account/sexp/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -9,10 +9,10 @@ from __future__ import annotations import os from typing import Any -from shared.sexp.jinja_bridge import render, load_service_components +from shared.sexp.jinja_bridge import load_service_components from shared.sexp.helpers import ( - call_url, root_header_html, search_desktop_html, - search_mobile_html, full_page, oob_page, + call_url, sexp_call, SexpExpr, + root_header_sexp, full_page_sexp, header_child_sexp, oob_page_sexp, ) # Load account-specific .sexpr components at import time @@ -23,51 +23,53 @@ load_service_components(os.path.dirname(os.path.dirname(__file__))) # Header helpers # --------------------------------------------------------------------------- -def _auth_nav_html(ctx: dict) -> str: +def _auth_nav_sexp(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 + parts = [ + sexp_call("nav-link", + href=call_url(ctx, "account_url", "/newsletters/"), + label="newsletters", + select_colours=ctx.get("select_colours", ""), + ) + ] + account_nav = ctx.get("account_nav") + if account_nav: + parts.append(account_nav) + return "(<> " + " ".join(parts) + ")" -def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: +def _auth_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" - return render( - "menu-row", + return sexp_call( + "menu-row-sx", 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), + nav=SexpExpr(_auth_nav_sexp(ctx)), child_id="auth-header-child", oob=oob, ) -def _auth_nav_mobile_html(ctx: dict) -> str: +def _auth_nav_mobile_sexp(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 + parts = [ + sexp_call("nav-link", + href=call_url(ctx, "account_url", "/newsletters/"), + label="newsletters", + select_colours=ctx.get("select_colours", ""), + ) + ] + account_nav = ctx.get("account_nav") + if account_nav: + parts.append(account_nav) + return "(<> " + " ".join(parts) + ")" # --------------------------------------------------------------------------- # Account dashboard (GET /) # --------------------------------------------------------------------------- -def _account_main_panel_html(ctx: dict) -> str: +def _account_main_panel_sexp(ctx: dict) -> str: """Account info panel with user details and logout.""" from quart import g from shared.browser.app.csrf import generate_csrf_token @@ -75,30 +77,33 @@ def _account_main_panel_html(ctx: dict) -> str: user = getattr(g, "user", None) error = ctx.get("error", "") - error_html = render("account-error-banner", error=error) if error else "" + error_sexp = sexp_call("account-error-banner", error=error) if error else "" - user_email_html = "" - user_name_html = "" + user_email_sexp = "" + user_name_sexp = "" if user: - user_email_html = render("account-user-email", email=user.email) + user_email_sexp = sexp_call("account-user-email", email=user.email) if user.name: - user_name_html = render("account-user-name", name=user.name) + user_name_sexp = sexp_call("account-user-name", name=user.name) - logout_html = render("account-logout-form", csrf_token=generate_csrf_token()) + logout_sexp = sexp_call("account-logout-form", csrf_token=generate_csrf_token()) - labels_html = "" + labels_sexp = "" if user and hasattr(user, "labels") and user.labels: - label_items = "".join( - render("account-label-item", name=label.name) + label_items = " ".join( + sexp_call("account-label-item", name=label.name) for label in user.labels ) - labels_html = render("account-labels-section", items_html=label_items) + labels_sexp = sexp_call("account-labels-section", + items=SexpExpr("(<> " + label_items + ")")) - return render( + return sexp_call( "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, + error=SexpExpr(error_sexp) if error_sexp else None, + email=SexpExpr(user_email_sexp) if user_email_sexp else None, + name=SexpExpr(user_name_sexp) if user_name_sexp else None, + logout=SexpExpr(logout_sexp), + labels=SexpExpr(labels_sexp) if labels_sexp else None, ) @@ -106,7 +111,7 @@ def _account_main_panel_html(ctx: dict) -> str: # Newsletters (GET /newsletters/) # --------------------------------------------------------------------------- -def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str: +def _newsletter_toggle_sexp(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/") @@ -118,7 +123,7 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st bg = "bg-stone-300" translate = "translate-x-1" checked = "false" - return render( + return sexp_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', @@ -129,9 +134,9 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st ) -def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str: +def _newsletter_toggle_off_sexp(nid: int, toggle_url: str, csrf_token: str) -> str: """Render an unsubscribed newsletter toggle (no subscription record yet).""" - return render( + return sexp_call( "account-newsletter-toggle-off", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', @@ -139,7 +144,7 @@ def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> s ) -def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: +def _newsletters_panel_sexp(ctx: dict, newsletter_list: list) -> str: """Newsletters management panel.""" from shared.browser.app.csrf import generate_csrf_token @@ -152,28 +157,30 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: nl = item["newsletter"] un = item.get("un") - desc_html = render( + desc_sexp = sexp_call( "account-newsletter-desc", description=nl.description ) if nl.description else "" if un: - toggle = _newsletter_toggle_html(un, account_url_fn, csrf) + toggle = _newsletter_toggle_sexp(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) + toggle = _newsletter_toggle_off_sexp(nl.id, toggle_url, csrf) - items.append(render( + items.append(sexp_call( "account-newsletter-item", - name=nl.name, desc_html=desc_html, toggle_html=toggle, + name=nl.name, + desc=SexpExpr(desc_sexp) if desc_sexp else None, + toggle=SexpExpr(toggle), )) - list_html = render( + list_sexp = sexp_call( "account-newsletter-list", - items_html="".join(items), + items=SexpExpr("(<> " + " ".join(items) + ")"), ) else: - list_html = render("account-newsletter-empty") + list_sexp = sexp_call("account-newsletter-empty") - return render("account-newsletters-panel", list_html=list_html) + return sexp_call("account-newsletters-panel", list=SexpExpr(list_sexp)) # --------------------------------------------------------------------------- @@ -189,11 +196,12 @@ def _login_page_content(ctx: dict) -> str: email = ctx.get("email", "") action = url_for("auth.start_login") - error_html = render("account-login-error", error=error) if error else "" + error_sexp = sexp_call("account-login-error", error=error) if error else "" - return render( + return sexp_call( "account-login-form", - error_html=error_html, action=action, + error=SexpExpr(error_sexp) if error_sexp else None, + action=action, csrf_token=generate_csrf_token(), email=email, ) @@ -207,18 +215,19 @@ def _device_page_content(ctx: dict) -> str: code = ctx.get("code", "") action = url_for("auth.device_submit") - error_html = render("account-device-error", error=error) if error else "" + error_sexp = sexp_call("account-device-error", error=error) if error else "" - return render( + return sexp_call( "account-device-form", - error_html=error_html, action=action, + error=SexpExpr(error_sexp) if error_sexp else None, + action=action, csrf_token=generate_csrf_token(), code=code, ) def _device_approved_content() -> str: """Device approved success content.""" - return render("account-device-approved") + return sexp_call("account-device-approved") # --------------------------------------------------------------------------- @@ -227,28 +236,26 @@ def _device_approved_content() -> str: async def render_account_page(ctx: dict) -> str: """Full page: account dashboard.""" - main = _account_main_panel_html(ctx) + main = _account_main_panel_sexp(ctx) - hdr = root_header_html(ctx) - hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) async def render_account_oob(ctx: dict) -> str: """OOB response for account dashboard.""" - main = _account_main_panel_html(ctx) + main = _account_main_panel_sexp(ctx) - oobs = ( - _auth_header_html(ctx, oob=True) - + root_header_html(ctx, oob=True) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -257,28 +264,26 @@ async def render_account_oob(ctx: dict) -> str: async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: """Full page: newsletters.""" - main = _newsletters_panel_html(ctx, newsletter_list) + main = _newsletters_panel_sexp(ctx, newsletter_list) - hdr = root_header_html(ctx) - hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: """OOB response for newsletters.""" - main = _newsletters_panel_html(ctx, newsletter_list) + main = _newsletters_panel_sexp(ctx, newsletter_list) - oobs = ( - _auth_header_html(ctx, oob=True) - + root_header_html(ctx, oob=True) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -287,24 +292,22 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: 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)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=page_fragment_html, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")', + menu=_auth_nav_mobile_sexp(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) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=page_fragment_html, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")', + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -313,26 +316,26 @@ async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: 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='Login \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_login_page_content(ctx), + meta_html='Login \u2014 Rose Ash') 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='Authorize Device \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_device_page_content(ctx), + meta_html='Authorize Device \u2014 Rose Ash') 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='Device Authorized \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_device_approved_content(), + meta_html='Device Authorized \u2014 Rose Ash') # --------------------------------------------------------------------------- @@ -343,13 +346,14 @@ def _check_email_content(email: str, email_error: str | None = None) -> str: """Check email confirmation content.""" from markupsafe import escape - error_html = render( + error_sexp = sexp_call( "account-check-email-error", error=str(escape(email_error)) ) if email_error else "" - return render( + return sexp_call( "account-check-email", - email=str(escape(email)), error_html=error_html, + email=str(escape(email)), + error=SexpExpr(error_sexp) if error_sexp else None, ) @@ -357,10 +361,10 @@ 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='Check your email \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_check_email_content(email, email_error), + meta_html='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- @@ -370,7 +374,7 @@ async def render_check_email_page(ctx: dict) -> str: 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, + return _newsletter_toggle_sexp(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p, generate_csrf_token()) @@ -382,4 +386,13 @@ def render_newsletter_toggle(un) -> str: 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()) + return _newsletter_toggle_sexp(un, account_url_fn, generate_csrf_token()) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _sexp_escape(s: str) -> str: + """Escape a string for embedding in sexp string literals.""" + return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/account/templates/_types/auth/_newsletter_toggle.html b/account/templates/_types/auth/_newsletter_toggle.html index 8bb3f69..700c402 100644 --- a/account/templates/_types/auth/_newsletter_toggle.html +++ b/account/templates/_types/auth/_newsletter_toggle.html @@ -1,9 +1,9 @@
@@ -47,11 +47,11 @@ data-confirm-confirm-text="Yes, delete" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}" - hx-trigger="confirmed" - hx-target="#menu-items-list" - hx-swap="innerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}" + sx-trigger="confirmed" + sx-target="#menu-items-list" + sx-swap="innerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"> Delete diff --git a/blog/templates/_types/menu_items/_main_panel.html b/blog/templates/_types/menu_items/_main_panel.html index bc502dd..b18df34 100644 --- a/blog/templates/_types/menu_items/_main_panel.html +++ b/blog/templates/_types/menu_items/_main_panel.html @@ -2,9 +2,9 @@
diff --git a/blog/templates/_types/menu_items/_nav_oob.html b/blog/templates/_types/menu_items/_nav_oob.html index e25189a..872b4a9 100644 --- a/blog/templates/_types/menu_items/_nav_oob.html +++ b/blog/templates/_types/menu_items/_nav_oob.html @@ -2,18 +2,18 @@ {% set _first_seg = request.path.strip('/').split('/')[0] %} diff --git a/blog/templates/_types/post/_entry_container.html b/blog/templates/_types/post/_entry_container.html index 3c3965a..685243e 100644 --- a/blog/templates/_types/post/_entry_container.html +++ b/blog/templates/_types/post/_entry_container.html @@ -1,13 +1,7 @@
+ data-scroll-arrows="entries-nav-arrow">
{% include '_types/post/_entry_items.html' with context %}
diff --git a/blog/templates/_types/post/_entry_items.html b/blog/templates/_types/post/_entry_items.html index e671368..106af53 100644 --- a/blog/templates/_types/post/_entry_items.html +++ b/blog/templates/_types/post/_entry_items.html @@ -29,10 +29,10 @@ {# Load more entries one at a time until container is full #} {% if has_more_entries %}
{% endif %} diff --git a/blog/templates/_types/post/_main_panel.html b/blog/templates/_types/post/_main_panel.html index 52a2c3a..2c62caa 100644 --- a/blog/templates/_types/post/_main_panel.html +++ b/blog/templates/_types/post/_main_panel.html @@ -12,11 +12,11 @@ {% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
Edit diff --git a/blog/templates/_types/post/admin/_associated_entries.html b/blog/templates/_types/post/admin/_associated_entries.html index d9fe853..d5538e4 100644 --- a/blog/templates/_types/post/admin/_associated_entries.html +++ b/blog/templates/_types/post/admin/_associated_entries.html @@ -15,12 +15,12 @@ data-confirm-confirm-text="Yes, remove it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" >
{% if calendar.post.feature_image %} diff --git a/blog/templates/_types/post/admin/_calendar_view.html b/blog/templates/_types/post/admin/_calendar_view.html index 80ae33f..6e4518b 100644 --- a/blog/templates/_types/post/admin/_calendar_view.html +++ b/blog/templates/_types/post/admin/_calendar_view.html @@ -1,15 +1,15 @@
+ sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}" + sx-trigger="entryToggled from:body" + sx-swap="outerHTML"> {# Month/year navigation #}
@@ -45,12 +45,12 @@ data-confirm-confirm-text="Yes, remove it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" > @@ -67,12 +67,12 @@ data-confirm-confirm-text="Yes, add it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" > {{ e.name }} diff --git a/blog/templates/_types/post/admin/_features_panel.html b/blog/templates/_types/post/admin/_features_panel.html index 19f9296..12ac7f2 100644 --- a/blog/templates/_types/post/admin/_features_panel.html +++ b/blog/templates/_types/post/admin/_features_panel.html @@ -3,11 +3,11 @@

Page Features