92 Commits

Author SHA1 Message Date
bc7a4a5128 Add cross-service URL functions and rights to base_context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
blog_url, market_url, cart_url, events_url and g.rights were only
available as Jinja globals, not in the ctx dict passed to sexp
helper functions. This caused all cross-service links in the header
system (post title, cart badge, admin cog, admin nav items) to
produce relative URLs resolving to the current service domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:19:42 +00:00
8e4c2c139e Fix duplicate menu rows on HTMX navigation between depth levels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
When navigating from a deeper page (e.g. day) to a shallower one
(e.g. calendar) via HTMX, orphaned header rows from the deeper page
persisted in the DOM because OOB swaps only replaced specific child
divs, not siblings. Fix by sending empty OOB swaps to clear all
header row IDs not present at the current depth.

Applied to events (calendars/calendar/day/entry/admin/slots) and
market (market_home/browse/product/admin). Also restore app_label
in root header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:09:15 +00:00
db3f48ec75 Remove app_label text from root header, keep settings cog
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m6s
The word "settings" (app_label) was showing next to "Rose Ash 2.0"
in the top bar. Removed that label while restoring the settings cog
icon on the right side of the menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:03:46 +00:00
b40f3d124c Remove settings cog from root header bar
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
The settings page is accessible via its own route; no need for a
persistent cog icon next to Rose Ash 2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:59:33 +00:00
3809affcab Test dashboard: full menu system, all-service tests, filtering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
- Run tests for all 10 services via per-service pytest subprocesses
- Group results by service with section headers
- Clickable summary cards filter by outcome (passed/failed/errors/skipped)
- Service filter nav using ~nav-link buttons in menu bar
- Full menu integration: ~header-row + ~header-child + ~menu-row
- Show logo image via cart-mini rendering
- Mount full service directories in docker-compose for test access
- Add 24 unit test files across 9 services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:54:25 +00:00
81e51ae7bc Fix settings cog URL: /settings/ not /admin/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:50:16 +00:00
b6119b7f04 Show settings cog on root header for admin users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
Pass settings_url and is_admin to header-row component so the blog
settings cog appears on the root header row for admin users across
all services. Links to blog /admin/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:47:32 +00:00
75cb5d43b9 Apply generic admin header pattern to all events admin pages
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Events admin pages (calendars, calendar admin, day admin, entry admin,
slots, slot detail) now use shared post_admin_header_html with
selected="calendars". Container nav is fetched via fragments so post
header row matches other services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:46:00 +00:00
f628b35fc3 Make post header row generic: admin cog + container_nav in shared helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m31s
Move admin cog generation and container_nav border wrapping from
blog-specific wrapper into shared post_header_html so all services
render identical post header rows. Blog, events, cart all delegate
to the shared helper now. Cart admin pages fetch container_nav_html
via fragments. Village Hall always links to blog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:37:24 +00:00
2e4fbd5777 Remove extra cart header row from admin pages, use shared post header
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Cart admin pages (admin overview, payments) now use the same header
pattern as blog/market/events: root_header → post_header → admin_header.
The domain name appears via app_label on the root header instead of a
separate level-1 "cart" row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:17:36 +00:00
b47ad6224b Unify post admin nav across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m56s
Move post admin header into shared/sexp/helpers.py so blog, cart,
events, and market all render the same admin row with identical nav:
calendars | markets | payments | entries | data | edit | settings.

All links are external (cross-service). The selected item shows
highlighted on the right and as white text next to "admin" on the left.

- blog: delegates to shared helper, removes blog-specific nav builder
- cart: delegates to shared helper for payments admin
- events: adds shared admin row (selected=calendars) to calendar admin
- market: adds /<slug>/admin/ route + page_admin blueprint, delegates
  to shared helper (selected=markets). Fixes 404 on page-level admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:01:56 +00:00
2d08d6f787 Eliminate payments sub-admin row in cart, show selection on admin label
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
Same pattern as blog: remove the level-3 payments header row, instead
show "payments" in white text next to "admin" on the admin row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:35:02 +00:00
beebe559cd Show selected sub-page name in white next to admin label
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m29s
Appends e.g. "settings" in white text next to the admin shield icon
on the left side of the admin row, in addition to the highlighted
nav button on the right.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:28:27 +00:00
b63aa72efb Fix admin nav selection: use !important to override text-black
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The direct bg-stone-500 text-white classes were losing to text-black
in Tailwind specificity. Use !bg-stone-500 !text-white to ensure
selected admin nav items display correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:27:02 +00:00
8cfa12de6b Eliminate post sub-admin rows, highlight active nav on admin row
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Remove the separate sub-admin header rows (data, entries, edit, settings)
that caused duplicate/stale rows on HTMX navigation and font styling breaks.
Instead, pass selected= to the admin row to highlight the active nav item
via aria-selected styling. External nav items (calendars, markets, payments)
also gain is-selected and select-colours support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:40:03 +00:00
3dd62bd9bf Bigger text in test dashboard + add deliberate failing test
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:34:19 +00:00
c926e5221d Fix test dashboard: use raw! for pre-rendered table rows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:32:40 +00:00
d62643312a Skip OAuth/auth for test service (public dashboard)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:07 +00:00
8852ab1108 Add test service to OAuth allowed clients
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:09:13 +00:00
1559c5c931 Add test runner dashboard service (test.rose-ash.com)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Public Quart microservice that runs pytest against shared/tests/ and
shared/sexp/tests/, serving an HTMX-powered sexp-rendered dashboard
with pass/fail/running status, auto-refresh polling, and re-run button.
No database — results stored in memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:08:10 +00:00
00efbc2a35 Add unit test coverage for shared pure-logic modules (240 tests)
Track 1.1 of master plan: expand from sexp-only tests to cover
DTOs, HTTP signatures, HMAC auth, URL utilities, Jinja filters,
calendar helpers, config freeze, activity bus registry, parse
utilities, sexp helpers, error classes, and jinja bridge render API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:34:37 +00:00
6c44a5f3d0 Add app label to root header and auto-reload sexp templates in dev
Show current subdomain name (blog, cart, events, etc.) next to the site
title in the root header row. Remove the redundant second "cart" menu row
from cart overview and checkout error pages.

Add dev-mode hot-reload for sexp templates: track file mtimes and re-read
changed files per-request when RELOAD=true, so .sexp edits are picked up
without restarting services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:33:00 +00:00
6d43404b12 Consolidate post header/menu system into shared infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
Replace duplicated _post_header_html, _oob_header_html, and header-child
components across blog/events/market/errors with shared sexpr components
(~post-label, ~page-cart-badge, ~oob-header, ~header-child, ~error-content)
and shared Python helpers (post_header_html, oob_header_html,
header_child_html, error_content_html). App-specific logic (blog container-nav
wrapping, admin cog, events calendar links) preserved via thin wrappers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:06:18 +00:00
97c4e25ba7 Fix post-row link on 404: inject Jinja globals into error context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
base_context() doesn't include blog_url/cart_url/etc — those live in
Jinja globals. Without them call_url(ctx, "blog_url", ...) falls back
to a relative path instead of https://blog.rose-ash.com/...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:47:03 +00:00
f1b7fdd37d Make rich 404 resilient to cross-service failures
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
Build a minimal context directly instead of relying on
get_template_context() which runs the full context processor chain
including cross-service fragment fetches. Each step (base_context,
fragments, post hydration) is independently try/excepted so the page
renders with whatever is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:36:11 +00:00
597b0d7a2f Fix relations nav_label URL bug and add rich 404 pages with headers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
The relations container-nav fragment was inserting nav_label (e.g.
"calendars", "markets") as a URL path segment, generating wrong links
like /the-village-hall/markets/suma/ instead of /the-village-hall/suma/.
The nav_label is for display only, not URL construction.

Also adds a rich 404 handler that shows site headers and post breadcrumb
when a slug can be resolved from the URL path. Falls back gracefully to
the minimal error page if context building fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:30:44 +00:00
ee41e30d5b Move payments admin from events to cart service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
Payments config (SumUp credentials per page) is a cart concern since all
checkouts go through the cart service. Moves it from events.rose-ash.com
to cart.rose-ash.com/<page_slug>/admin/payments/ and adds a cart admin
overview page at /<page_slug>/admin/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:15:35 +00:00
5957bd8941 Move calendar blueprint to app level for correct URL routing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
The calendar blueprint was nested under calendars (admin), making URLs
/{slug}/admin/{calendar_slug}/ instead of /{slug}/{calendar_slug}/.

Register calendar blueprint directly on the app and update all endpoint
references from calendars.calendar.* to calendar.* (37 in Python,
~50 in templates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:50:13 +00:00
a8edc26a1d Add external flag to menu-row for cross-subdomain links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Cross-subdomain hx-get breaks due to OAuth redirects. When external=true,
menu-row renders a plain <a href> without HTMX attributes, allowing
normal browser navigation.

Applied to post header links on events and market services which link
back to blog.rose-ash.com.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:46:47 +00:00
6a331e4ad8 Fix payments admin link to cart.rose-ash.com/{slug}/admin/payments/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Payments are managed by the cart service, not events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:37:41 +00:00
4a99bc56e9 Fix markets admin link to market.rose-ash.com/{slug}/admin/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Markets use the same admin pattern as calendars but on the market
subdomain, not the events subdomain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:54 +00:00
4fe5afe3e6 Move calendar management to /{slug}/admin/ and reserve slug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Change calendars blueprint prefix from /calendars to /admin
- Simplify routes from /calendars/ to / within blueprint
- Reserve admin, markets, payments, entries as calendar slugs
- Update blog admin nav link to /{slug}/admin/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:31:24 +00:00
efae7f5533 Fix calendars admin link to correct events URL path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:28:12 +00:00
105f4c4679 Rewrite sprint plan: fit the task to the timescale
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Six 2-week sprints, each shipping one or two complete deliverables.
Not 20 weeks crammed into 2 — the right amount of work for the time.
Each sprint is valuable on its own. Stop after any and you've shipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:24:59 +00:00
a7cca2f720 Fix admin nav label: calendar → calendars
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:23:04 +00:00
8269977751 Add two-week sprint plan: 90% of the masterplan in 14 days
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Ghost killed by day 5, sexp protocol running internally by day 8,
sexpr.js on every page by day 10. Cut Rust client, IPFS mesh, and
browser extension to later. Everything users touch runs on sexp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:21:13 +00:00
0df932bd94 Fix blog page title showing post name twice
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Stop concatenating post title into base_title in route context.
Build proper "Post Title — Site Title" format in meta component instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:20:44 +00:00
c220fe21d6 Add master plan: 9 tracks from stability to federated protocol
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
Schedules all existing plans into coherent 20-week roadmap with parallel
tracks: platform stability, decoupling, entities/relations, Ghost removal,
sexp pages, internal protocol, client-side runtime, native client, and
scalability. Critical path identified through Ghost removal as linchpin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:39 +00:00
f9d9697c67 Externalize sexp to .sexpr files + render() API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:14:58 +00:00
f4c2f4b6b8 Add internal-first strategy for sexpr:// protocol development
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Build and battle-test the protocol on the internal microservice mesh
before exposing it publicly. Current fetch_data/call_action/fetch_fragment
map directly to sexp verbs. Same protocol serves internal and public clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:16:35 +00:00
881ed2cdcc Add doc on sexp as microservice wire format
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Strongest near-term application: replace lossy dicts and opaque HTML
fragments with structured trees that are both inspectable and renderable.
Includes incremental migration path from current fetch_data/fetch_fragment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:11:05 +00:00
2ce2077d14 Add risks and pitfalls analysis for sexp protocol
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Honest assessment: adoption chicken-and-egg, security surface area,
accessibility gap, tooling desert, Lisp Curse fragmentation, Worse Is
Better problem, and mitigation strategy for each.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:08:44 +00:00
8cf834dd55 Add doc on how sexp protocol fundamentally changes the web
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Covers: APIs as separate concept disappearing, front-end framework
collapse, AI as first-class citizen, browser monopoly breaking,
content portability, client-server blur, computational governance,
and the Unix pipes analogy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:04:13 +00:00
4daecabf30 Add open verb system to unified sexp protocol spec
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
Verbs are no longer limited to HTTP's fixed seven methods — any symbol
is a valid verb. Domain-specific actions (reserve, publish, vote, bid)
read as natural language. Verb behaviour declared via schema endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:59:34 +00:00
19240c6ca3 Add cooperative compute mesh: client-as-node, GPU sharing, IPFS persistence
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Members' Rust clients become full peer nodes — AP instances, IPFS nodes,
and artdag GPU workers. The relay server becomes a lightweight matchmaker
(message queue, pinning, peer directory) while all compute, rendering,
and content serving is distributed across members' own hardware. Back
to the original vision of the web: everyone has a server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:52:17 +00:00
3e29c2a334 Unify sexp protocol and ActivityPub extension into single spec
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
Merges sexpr-activitypub-extension.md and sexpr-protocol-and-tiered-clients.md
into sexpr-unified-protocol.md — recognising that browsing, federation, and
real-time updates are all the same thing: peers exchanging s-expressions on
a bidirectional stream. One format, one connection, one parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:44:39 +00:00
a70d3648ec Add sexp protocol spec and tiered client architecture plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Defines three client tiers (browser HTML, browser extension with
sexpr.js, Rust native client) served from the same route handlers
via content negotiation. Includes native sexp:// protocol design
over QUIC, content-addressed caching, bidirectional streaming,
self-describing schema, and implementation plan from Phase 1
(Quart content negotiation) through Phase 7 (fallback gateway).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:40:18 +00:00
0d1ce92e52 Fix sexp parse errors: avoid literal parentheses in sexp string args
The sexp parser doesn't handle "(" and ")" as string literals
inside expressions. Use raw! with pre-formatted strings instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:20:41 +00:00
09b5a5b4f6 Convert account, orders, and federation sexp_components.py to pure sexp() calls
Eliminates all f-string HTML from the remaining three services,
completing the migration of all sexp_components.py files to the
s-expression rendering system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:15:17 +00:00
f0a100fd77 Convert cart sexp_components.py from f-string HTML to pure sexp() calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:08:36 +00:00
16da08ff05 Fix market and calendar URL routing
Market: blog links now use market_url('/{slug}/') instead of
events_url('/{slug}/markets/'), matching the market service's
actual route structure /<page_slug>/<market_slug>/.

Calendar: flatten route from /<slug>/calendars/<calendar_slug>/
to /<slug>/<calendar_slug>/ by changing the events app blueprint
prefix and moving listing routes to explicit /calendars/ paths.
Update all hardcoded calendar URL paths across blog and events
services (Python + Jinja templates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:58:05 +00:00
5c6d83f474 Add sexp ActivityPub extension plan with implementation phases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Defines a backwards-compatible AP extension using s-expressions as
the wire format: content negotiation, component discovery protocol,
WebSocket streaming, and a path to publishing as a FEP. Includes
bidirectional JSON-LD bridging for Mastodon/Pleroma compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:40:15 +00:00
da8a766e3f Convert all f-string HTML to sexp() in market/sexp/sexp_components.py
Eliminates every HTML tag from the market service's sexp component layer,
replacing f-string HTML with composable sexp() calls throughout ~30 functions
including product cards, filter panels, nav panels, product detail, meta tags,
cart controls, like buttons, and sentinel infinite-scroll elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:38:53 +00:00
9fa3b8800c Add sexp-as-wire-format rationale for AI-driven systems
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Documents why s-expressions on the wire are a natural fit for
LLM agents: fewer tokens, no closing-tag errors, components as
tool calls, mutations as agent actions, content-addressed caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:31:38 +00:00
f24292f99d Convert editor panel <script> block to sexp wrapper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Separate JS content from HTML tag — pass JS body into
(script (raw! js)) so zero raw HTML tags remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:27:48 +00:00
de3a6e4dde Convert all f-string HTML to sexp() in blog/sexp/sexp_components.py
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
~39 functions converted from f-string HTML to sexp() calls.
Only remaining HTML is the intentional <script> block in
render_editor_panel (complex JS init for WYSIWYG editor).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:24:16 +00:00
0bb57136d2 Add sexpr.js runtime plan and comprehensive Ghost removal plan
Two planning documents for the next major architectural steps:
- sexpr-js-runtime-plan: isomorphic JS s-expression runtime for
  client-side rendering, content-addressed component caching,
  and native hypermedia mutations
- ghost-removal-plan: full Ghost CMS replacement covering content
  (Lexical→sexp), membership, newsletters, Stripe subscriptions,
  and media uploads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:53:12 +00:00
495e6589dc Convert all remaining f-string HTML to sexp() in events/sexp_components.py
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Eliminates every raw HTML string from the events service component file.
Converted ~30 functions including ticket admin, entry cards, ticket widgets,
view toggles, entry detail, options, buy forms, slots/ticket-type tables,
calendar description forms, nav OOB panels, and cart icon.

Zero HTML tags remain in events/sexp/sexp_components.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:39:37 +00:00
903193d825 Convert events header/panel f-string HTML to sexp calls
Migrates ~20 functions from f-string HTML construction to sexp():
- _oob_header_html, _post_header_html label/cart badge
- _calendars_header_html, _calendar_header_html, _calendar_nav_html
- _day_header_html, _day_nav_html (entries scroll menu + admin cog)
- _markets_header_html, _payments_header_html labels
- _calendars_main_panel_html + _calendars_list_html
- _calendar_main_panel_html (full month grid with day cells + entry badges)
- _day_main_panel_html + _day_row_html (entries table)
- _calendar_admin_main_panel_html + _calendar_description_display_html
- _markets_main_panel_html + _markets_list_html
- _payments_main_panel_html (SumUp config form)
- _entry_state_badge_html, _ticket_state_badge_html
- _day_admin_main_panel_html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:19:57 +00:00
eda95ec58b Enable cross-subdomain htmx and purify layout to sexp
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com
- Remove same-origin guards from ~menu-row and ~nav-link htmx attrs
- Convert ~app-layout from string-concatenated HTML to pure sexp tree
- Extract ~app-head component, replace ~app-shell with inline structure
- Convert hamburger SVG from Python HTML constant to ~hamburger sexp component
- Fix cross-domain fragment URLs (events_url, market_url)
- Fix starts-with? primitive to handle nil values
- Fix duplicate admin menu rows on OOB swaps
- Add calendar admin nav links (slots, description)
- Convert slots page from Jinja to sexp rendering
- Disable page caching in development mode
- Backfill migration to clean orphaned container_relations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:09:00 +00:00
d2f1da4944 Migrate callers from attach-child/detach-child to relate/unrelate API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Switch all cross-service relation calls to the new registry-aware
relate/unrelate/can-relate actions, and consolidate per-service
container-nav fragment fetches into the generic relations handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:24:52 +00:00
53c4a0a1e0 Externalize sexp component templates and delete redundant HTML fragments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
Move 24 defcomp definitions from Python string constants in components.py
to 7 grouped .sexp files under shared/sexp/templates/. Add load_sexp_dir()
to jinja_bridge.py for file-based loading. Migrate events and market
link-card fragment handlers from render_template to sexp. Delete 9
superseded Jinja HTML fragment templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:55:54 +00:00
9c6170ed31 Add SVG child elements (path, circle, rect, etc.) to HTML_TAGS
Fixes EvalError: Undefined symbol: path when rendering ~mobile-filter
component which uses an SVG <path> element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:37:35 +00:00
a0a0f5ebc2 Implement flexible entity relation system (Phases A–E)
Declarative relation registry via defrelation s-expressions with
cardinality enforcement (one-to-one, one-to-many, many-to-many),
registry-aware relate/unrelate/can-relate API endpoints, generic
container-nav fragment, and relation-driven UI components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:35:17 +00:00
6f1d5bac3c relation plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
2026-02-28 08:23:10 +00:00
b52ef719bf Fix 500 errors and double-slash URLs found during sexp rendering testing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
- events: fix ImportError for events_url (was importing from shared.utils
  instead of shared.infrastructure.urls)
- blog: add missing ~mobile-filter sexp component (details/summary panel)
- shared: fix double-slash URLs in ~auth-menu, ~cart-mini, ~header-row
  by removing redundant "/" concatenation on URLs that already have trailing slash
- blog: fix ghost_sync select UnboundLocalError caused by redundant local
  import shadowing module-level import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:40:02 +00:00
838ec982eb Phase 7: Replace render_template() with s-expression rendering in all POST/PUT/DELETE routes
Eliminates all render_template() calls from POST/PUT/DELETE handlers across
all 7 services. Moves sexp_components.py into sexp/ packages per service.

- Blog: like toggle, snippets, cache clear, features/sumup/entry panels,
  create/delete market, WYSIWYG editor panel (render_editor_panel)
- Federation: like/unlike/boost/unboost, follow/unfollow, actor card,
  interaction buttons
- Events: ticket widget, checkin, confirm/decline/provisional, tickets
  config, posts CRUD, description edit/save, calendar/slot/ticket_type
  CRUD, payments, buy tickets, day main panel, entry page
- Market: like toggle, cart add response
- Account: newsletter toggle
- Cart: checkout error pages (3 handlers)
- Orders: checkout error page (1 handler)

Remaining render_template() calls are exclusively in GET handlers and
internal services (email templates, fragment endpoints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:15:29 +00:00
e65232761b Fix NoneType strftime error in events calendar grid
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Guard day_date.strftime() call with None check — day_cell.date can
be None for empty grid cells.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:31:00 +00:00
1c794b6c0e Fix nested raw! sexp errors and missing container nav in market pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
- Fix invalid nested (raw! a (raw! b)) patterns in market and events
  sexp_components — concatenate HTML strings in Python, pass single
  var to (raw! h) instead
- Add container_nav_html fetch to market inject_post context processor
  so page-scoped market pages show calendar/market nav links
- Add qs_filter to base_context for sexp filter URL building

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:28:11 +00:00
d53b9648a9 Phase 6: Replace render_template() with s-expression rendering in all GET routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.

- Add per-service sexp_components.py (account, blog, cart, events,
  federation, market, orders) with full page, OOB, and pagination
  card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
  full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:19:33 +00:00
8013317b41 Phase 5: Page layouts as s-expressions — components, fragments, error pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Add 9 new shared s-expression components (cart-mini, auth-menu,
account-nav-item, calendar-entry-nav, calendar-link-nav, market-link-nav,
post-card, base-shell, error-page) and wire them into all fragment route
handlers. 404/403 error pages now render entirely via s-expressions as a
full-page proof-of-concept, with Jinja fallback on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:25:11 +00:00
04419a1ec6 Switch federation link-card fragment to sexp rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
All four services (blog, market, events, federation) now use the shared
~link-card s-expression component instead of per-service Jinja templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:59:25 +00:00
573aec7dfa Add restart: unless-stopped to all dev services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
Containers now auto-restart on crash instead of staying down.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:46:25 +00:00
36b5f1d19d Fix blog startup deadlock: use direct DB instead of self-HTTP call
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m30s
ghost_sync was calling blog's own /internal/data/page-config-ensure via
HTTP during startup, but the server isn't listening yet — causing a retry
loop that times out Hypercorn. Replace with direct DB insert using the
existing session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:44:04 +00:00
28c66c3650 Wire s-expression rendering into live app — blog link-card
- Add setup_sexp_bridge() and load_shared_components() to factory.py
  so all services get s-expression support automatically
- Create shared/sexp/components.py with ~link-card component definition
  (replaces 5 per-service Jinja link_card.html templates)
- Replace blog's link-card fragment handler to use sexp() instead of
  render_template() — first real s-expression rendered page content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:38:51 +00:00
5d9f1586af Phase 4: Jinja bridge for incremental s-expression migration
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Two-way bridge: sexp() Jinja global renders s-expression components in
templates, register_components() loads definitions at startup. Includes
~link-card component test proving unified replacement of 5 per-service
Jinja fragment templates.

19 new tests (218 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:34:42 +00:00
fbb7a1422c Phase 3: Async resolver with parallel I/O and graceful degradation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Tree walker collects I/O nodes (frag, query, action, current-user,
htmx-request?), dispatches them via asyncio.gather(), substitutes results,
and renders to HTML. Failed I/O degrades gracefully to empty string.

27 new tests (199 total), all mocked at execute_io boundary — no
infrastructure dependencies needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:22:28 +00:00
09010db70e Phase 2: HSX-style HTML renderer with render-aware evaluation
S-expression AST → HTML string renderer with ~100 HTML tags, void elements,
boolean attributes, XSS escaping, raw!, fragments, and components. Render-aware
special forms (if, when, cond, let, map, etc.) handle HTML tags in control flow
branches correctly by calling _render instead of _eval.

63 new tests (172 total across parser, evaluator, renderer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:04:35 +00:00
0fb87e3b1c Phase 1: s-expression core library + test infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
S-expression parser, evaluator, and primitive registry in shared/sexp/.
109 unit tests covering parsing, evaluation, special forms, lambdas,
closures, components (defcomp), and 60+ pure builtins.

Test infrastructure: Dockerfile.unit (tier 1, fast) and
Dockerfile.integration (tier 2, ffmpeg). Dev watch mode auto-reruns
on file changes. Deploy gate blocks push on test failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:26:18 +00:00
996ddad2ea Fix ticket adjust: commit before cart-summary fetch
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
The tickets adjust_quantity route fetches cart-summary from cart, which
calls back to events for ticket counts. Without committing first, the
callback misses the just-adjusted tickets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:34:54 +00:00
f486e02413 Add orders to OAuth ALLOWED_CLIENTS
Checkout return from SumUp redirects to orders.rose-ash.com which needs
to authenticate via the account OAuth flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:04:00 +00:00
69a0989b7a Fix events: return 404 for deleted/missing calendar entries
The before_request handler loaded the entry but didn't abort when it was
None, causing template UndefinedError when building URLs with entry.id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:41:40 +00:00
0c4682e4d7 Fix stale cart count: commit transaction before cross-service fragment fetch
The cart-mini fragment relies on cart calling back to events for calendar/
ticket counts. Without committing first, the callback runs in a separate
transaction and misses the just-added entry or ticket adjustment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:30:13 +00:00
bcac8e5adc Fix events: use cart-mini fragment instead of local cart template
Events was trying to render _types/cart/_mini.html locally, which only
exists in the cart service. Replace with fetch_fragment("cart", "cart-mini")
calls and add oob param support to the cart-mini fragment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:22:37 +00:00
e1b47e5b62 Refactor nav_entries_oob into composable shared macro with caller block
Replace the shared fallback template with a Jinja macro that each domain
(blog, events, market) can call with its own domain-specific nav items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:13:38 +00:00
ae134907a4 Move _nav_entries_oob.html to shared templates instead of duplicating
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Used by both blog and events — belongs in shared/browser/templates
where the ChoiceLoader fallback resolves it for all apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:05:23 +00:00
db7342c7d2 Fix events: add missing _nav_entries_oob.html template
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Template exists in blog but was missing from events, causing
TemplateNotFound on calendar creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:03:58 +00:00
94b1fca938 Fix entrypoint.sh permissions for new services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Mark executable so bind-mounted dev volumes work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:50:23 +00:00
96b02d93df Fix blog: add page_configs migration, fix stale cart reference in ghost_sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
- Add 0003_add_page_configs.py migration to create table in db_blog
- Fix ghost_sync.py: fetch_data("cart", "page-config-ensure") → "blog"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:25:19 +00:00
fe34ea8e5b Fix market crash: remove stale toggle_product_like import
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:19:49 +00:00
f2d040c323 CI: deploy swarm only on main, dev stack on all branches
- Trigger on all branches (not just main/decoupling)
- Swarm stack deploy gated behind main branch check
- Dev stack (docker compose) always deployed
- Added relations, likes, orders to build loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:18:33 +00:00
22460db450 Rewrite CLAUDE.md to reflect full monorepo
Replaces the old art-dag-only docs with comprehensive documentation
covering all web platform services, shared library, art DAG subsystem,
architecture patterns, auth, inter-service communication, and dev/deploy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:17:30 +00:00
319 changed files with 29276 additions and 1691 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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)))

View 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)))

View 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))))

View 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())

View File

@@ -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>

View File

View 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

View 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 == {}

View 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")

View File

@@ -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

View File

@@ -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"))

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>/")

View File

@@ -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={

View File

@@ -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
View File

178
blog/sexp/admin.sexpr Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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}}"

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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
View File

View 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) == {}

View 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

View 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 "&lt;script&gt;" 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(&quot;xss&quot;)" 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(&#x27;hi&#x27;)" 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

View 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

View 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"

View File

@@ -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="/"),

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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()

View File

View 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
View File

12
cart/sexp/calendar.sexpr Normal file
View 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
View 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
View 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
View 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)))))

View 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
View 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
View 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
View 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"))))))

View 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
View 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
View 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))))

View File

@@ -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
View File

View 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
View 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

View 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
View 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")

View File

@@ -2,7 +2,7 @@
set -euo pipefail
REGISTRY="registry.rose-ash.com:5000"
APPS="blog market cart events federation account relations likes orders"
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
View File

@@ -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

View File

@@ -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

View File

@@ -35,6 +35,7 @@ x-app-env: &app-env
APP_URL_ORDERS: https://orders.rose-ash.com
APP_URL_RELATIONS: http://relations:8000
APP_URL_LIKES: http://likes:8000
APP_URL_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
View 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
View 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
View 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.

View 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.

View File

@@ -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

View 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.

View 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

View 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 14)
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 58)
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 912)
### 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 1316)
### 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 1720)
### 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 2128)
### 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 2528, 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.*

View 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.

View 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 |

View 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.*

View 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 ...)` |

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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