181 Commits

Author SHA1 Message Date
6aa2f3f6bd Add Special Forms docs page at /docs/special-forms
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m54s
Parses special-forms.sx spec into categorized form cards with syntax,
description, tail-position info, and highlighted examples. Follows the
same pattern as the Primitives page: Python helper returns structured
data, .sx components render it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:59:47 +00:00
6c27ebd3b4 Merge branch 'worktree-refactor-primitives' into macros
# Conflicts:
#	shared/sx/ref/bootstrap_js.py
#	shared/sx/ref/bootstrap_py.py
2026-03-06 01:50:29 +00:00
f77d7350dd Refactor SX primitives: modular, isomorphic, general-purpose
Spec modularization:
- Add (define-module :name) markers to primitives.sx creating 11 modules
  (7 core, 4 stdlib). Bootstrappers can now selectively include modules.
- Add parse_primitives_by_module() to boundary_parser.py.
- Remove split-ids primitive; inline at 4 call sites in blog/market queries.

Python file split:
- primitives.py: slimmed to registry + core primitives only (~350 lines)
- primitives_stdlib.py: NEW — stdlib primitives (format, text, style, debug)
- primitives_ctx.py: NEW — extracted 12 page context builders from IO
- primitives_io.py: add register_io_handler decorator, auto-derive
  IO_PRIMITIVES from registry, move sync IO bridges here

JS parity fixes:
- = uses === (strict equality), != uses !==
- round supports optional ndigits parameter
- concat uses nil-check not falsy-check (preserves 0, "", false)
- escape adds single quote entity (&#x27;) matching Python/markupsafe
- assert added (was missing from JS entirely)

Bootstrapper modularization:
- PRIMITIVES_JS_MODULES / PRIMITIVES_PY_MODULES dicts keyed by module
- --modules CLI flag for selective inclusion (core.* always included)
- Regenerated sx-ref.js and sx_ref.py with all fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:45:29 +00:00
ca8de3be1a Make continuations an optional extension, add special-forms.sx, ellipsis parsing
- Both bootstrappers (JS + Python) now gate shift/reset behind --extensions
  continuations flag. Without it, using reset/shift errors at runtime.
- JS bootstrapper: extracted Continuation/ShiftSignal types, sfReset/sfShift,
  continuation? primitive, and typeOf handling into CONTINUATIONS_JS constant.
  Extension wraps evalList, aserSpecial, and typeOf post-transpilation.
- Python bootstrapper: added special-forms.sx validation cross-check against
  eval.sx dispatch, warns on mismatches.
- Added shared/sx/ref/special-forms.sx: 36 declarative form specs with syntax,
  docs, tail-position, and examples. Used by bootstrappers for validation.
- Added ellipsis (...) support to both parser.py and parser.sx spec.
- Updated continuations essay to reflect optional extension architecture.
- Updated specs page and nav with special-forms.sx entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:44:50 +00:00
31ace8768e Merge scheme-forms into macros: named let, letrec, dynamic-wind, eq?/eqv?/equal?
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:16:16 +00:00
f34e55aa9b Add Scheme forms: named let, letrec, dynamic-wind, three-tier equality
Spec (eval.sx, primitives.sx):
- Named let: (let loop ((i 0)) body) — self-recursive lambda with TCO
- letrec: mutually recursive local bindings with closure patching
- dynamic-wind: entry/exit guards with wind stack for future continuations
- eq?/eqv?/equal?: identity, atom-value, and deep structural equality

Implementation (evaluator.py, async_eval.py, primitives.py):
- Both sync and async evaluators implement all four forms
- 33 new tests covering all forms including TCO at 10k depth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:11:31 +00:00
102a27e845 Implement delimited continuations (shift/reset) across all evaluators
Bootstrap shift/reset to both Python and JS targets. The implementation
uses exception-based capture with re-evaluation: reset wraps in try/catch
for ShiftSignal, shift raises to the nearest reset, and continuation
invocation pushes a resume value and re-evaluates the body.

- Add Continuation type and _ShiftSignal to shared/sx/types.py
- Add sf_reset/sf_shift to hand-written evaluator.py
- Add async versions to async_eval.py
- Add shift/reset dispatch to eval.sx spec
- Bootstrap to Python: FIXUPS_PY with sf_reset/sf_shift, regenerate sx_ref.py
- Bootstrap to JS: Continuation/ShiftSignal types, sfReset/sfShift in fixups
- Add continuation? primitive to both bootstrappers and primitives.sx
- Allow callables (including Continuation) in hand-written HO map
- 44 unit tests (22 per evaluator) covering: passthrough, abort, invoke,
  double invoke, predicate, stored continuation, nested reset, practical patterns
- Update continuations essay to reflect implemented status with examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:58:50 +00:00
12fe93bb55 Add continuation specs: delimited (shift/reset) and full (call/cc)
Optional bolt-on extensions to the SX spec. continuations.sx defines
delimited continuations for all targets. callcc.sx defines full call/cc
for targets where it's native (Scheme, Haskell). Shared continuation
type if both are loaded. Wired into specs section of sx-docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:41:28 +00:00
0693586e6f Minor wording fixes in server architecture essay
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:20:32 +00:00
cfde5bc491 Fix sync IO primitives unreachable from sx_ref.py evaluator
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m49s
app-url, asset-url, config, jinja-global, relations-from are declared
as IO in boundary.sx but called inline in .sx code (inside let/filter).
async_eval_ref.py only intercepts IO at the top level — nested calls
fall through to sx_ref.eval_expr which couldn't find them.

Register sync bridge wrappers directly in _PRIMITIVES (bypassing
@register_primitive validation since they're boundary.sx, not
primitives.sx). Both async and sync eval paths now work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:06:09 +00:00
abeb4551da Add server architecture essay to sx-docs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m54s
Documents the boundary enforcement model: three tiers, boundary types,
runtime validation, the SX-in-Python rule, and the multi-language story.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:58:20 +00:00
04366990ec Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s
Add boundary.sx declaring all 34 I/O primitives, 32 page helpers, and 9
allowed boundary types. Runtime validation in boundary.py checks every
registration against the spec — undeclared primitives/helpers crash at
startup with SX_BOUNDARY_STRICT=1 (now set in both dev and prod).

Key changes:
- Move 5 I/O-in-disguise primitives (app-url, asset-url, config,
  jinja-global, relations-from) from primitives.py to primitives_io.py
- Remove duplicate url-for/route-prefix from primitives.py (already in IO)
- Fix parse-datetime to return ISO string instead of raw datetime
- Add datetime→isoformat conversion in _convert_result at the edge
- Wrap page helper return values with boundary type validation
- Replace all SxExpr(f"...") patterns with sx_call() or _sx_fragment()
- Add assert declaration to primitives.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:50:02 +00:00
54adc9c216 Add TCO for parser loops in JS bootstrapper, enable SX_USE_REF
The JS parser transpiled from parser.sx used tail-recursive functions
(readStrLoop, skipWs, readListLoop, etc.) which overflow the stack on
large inputs — the bootstrapper page highlights 100KB of Python and
143KB of JavaScript, producing 7620 spans in a 907KB response.

The bootstrapper now detects zero-arg self-tail-recursive functions and
emits them as while(true) loops with continue instead of recursive
calls. Tested with 150K char strings and 8000 sibling elements.

Also enables SX_USE_REF=1 in dev via x-dev-env anchor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:09:02 +00:00
38f1f82988 Merge branch 'worktree-bootstrap-py' into macros 2026-03-05 22:48:14 +00:00
bb5c7e8444 Fall through to shared primitive registry for external primitives
is_primitive/get_primitive now check the shared registry
(shared.sx.primitives) when a name isn't in the transpiled PRIMITIVES
dict. Fixes Undefined symbol errors for register_primitive'd functions
like relations-from.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:48:14 +00:00
a40dd06811 Merge branch 'worktree-bootstrap-py' into macros 2026-03-05 22:45:05 +00:00
ef04beba00 Add call-fn dispatch for HO forms: handle both Lambda and native callable
HO forms (map, filter, reduce, etc.) now use call-fn which dispatches
Lambda → call-lambda, native callable → apply, else → clear EvalError.
Previously call-lambda crashed with AttributeError on native functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:45:01 +00:00
4ed879bc84 Merge branch 'worktree-bootstrap-py' into macros 2026-03-05 22:36:59 +00:00
d076fc1465 Spec server definition forms (defhandler/defquery/defaction/defpage) in forms.sx
Previously defhandler routed to sf-define which tried to evaluate
(&key ...) params as expressions. Now each form has its own spec
with parse-key-params and platform constructors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:36:56 +00:00
17767ed8c4 Merge branch 'worktree-bootstrap-py' into macros 2026-03-05 22:17:29 +00:00
5aa13a99d1 Wire SX_USE_REF=1 switching through all route intermediaries
jinja_bridge, helpers, handlers, query_executor now conditionally
import from ref/sx_ref and ref/async_eval_ref when SX_USE_REF=1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:17:26 +00:00
6328e3d680 Merge branch 'worktree-bootstrap-py' into macros
Bootstrap Python transpiler: reads .sx spec files and emits standalone
Python evaluator (sx_ref.py) with both HTML and SX wire format adapters.
Includes async wrapper and SX_USE_REF=1 switching mechanism.
2026-03-05 22:06:18 +00:00
7982a07f94 Add adapter-sx.sx transpilation, async wrapper, and SX_USE_REF switching
- Transpile adapter-sx.sx (aser) alongside adapter-html.sx for SX wire format
- Add platform functions: serialize, escape_string, is_special_form, is_ho_form,
  aser_special (with proper control-flow-through-aser dispatch)
- SxExpr wrapping prevents double-quoting in aser output
- async_eval_ref.py: async wrapper with I/O primitives, RequestContext,
  async_render, async_eval_to_sx, async_eval_slot_to_sx
- SX_USE_REF=1 env var switches shared.sx imports to transpiled backend
- 68 comparison tests (test_sx_ref.py), 289 total tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:05:35 +00:00
4534fb9fee Add bootstrap_py.py: transpile SX spec to Python evaluator
Mirrors bootstrap_js.py pattern — reads the .sx reference spec files
(eval.sx, render.sx, adapter-html.sx) and emits a standalone Python
evaluator module (sx_ref.py) that can be compared against the
hand-written evaluator.py / html.py.

Key transpilation techniques:
- Nested IIFE lambdas for let bindings: (lambda a: body)(val)
- _sx_case helper for case/type dispatch
- Short-circuit and/or via Python ternaries
- Functions with set! emitted as def with _cells dict for mutation
- for-each with inline fn emitted as Python for loops
- Statement-level cond emitted as if/elif/else chains

Passes 27/27 comparison tests against hand-written evaluator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:32:01 +00:00
c43f774992 Skip event processor in standalone mode (no DB for LISTEN/NOTIFY)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:02:05 +00:00
9cde15c3ce Skip DB registration in standalone mode (fixes sx-web.org startup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m5s
The sx app is stateless — no database needed. In standalone mode
(SX_STANDALONE=true), the factory now skips register_db() so the app
doesn't crash trying to connect to a non-existent PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:53:05 +00:00
6a98c39937 Use existing Caddy for sx-web.org routing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
Single Caddy instance handles all domains. sx-web stack joins
externalnet instead of running its own Caddy (port conflict).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:41:36 +00:00
60ed828e0e Merge branch 'macros'
# Conflicts:
#	blog/bp/post/admin/routes.py
#	events/sxc/pages/calendar.py
#	events/sxc/pages/entries.py
#	events/sxc/pages/slots.py
#	events/sxc/pages/tickets.py
2026-03-05 16:40:06 +00:00
0f4520d987 Add standalone mode for sx-web.org deployment
- SX_STANDALONE=true env var: no OAuth, no root header, no cross-service
  fragments. Same image runs in both rose-ash cooperative and standalone.
- Factory: added no_oauth parameter to create_base_app()
- Standalone layout defcomps skip ~root-header-auto/~root-mobile-auto
- Fixed Dockerfile: was missing sx/sx/ component directory copy
- CI: deploys sx-web swarm stack on main branch when sx changes
- Stack config at ~/sx-web/ (Caddy → sx_docs, Redis)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:39:21 +00:00
5fff83ae79 Add header and event detail pages, fix copyright, rename essay
- Detail pages for all 18 HTTP headers with descriptions, example usage,
  direction badges (request/response/both), and live demos for SX-Prompt,
  SX-Trigger, SX-Retarget
- Detail pages for all 10 DOM events with descriptions, example usage,
  and live demos for beforeRequest, afterSettle, responseError,
  validationFailed
- Header and event table rows now link to their detail pages
- Fix copyright symbol on home page (was literal \u00a9, now actual ©)
- Rename "Godel, Escher, Bach" essay to "Strange Loops" with updated summary
- Remove duplicate script injection from bootstrapper page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:25:15 +00:00
1797bd4b16 Add Bootstrappers section, essays index, specs prose, layout fixes
- New Bootstrappers top-level section with overview index and JS bootstrapper
  page that runs bootstrap_js.py and displays both source and generated output
  with live script injection (full page load, not SX navigation)
- Essays section: index page with linked cards and summaries, sx-sucks moved
  to end of nav, removed "grand tradition" line
- Specs: English prose descriptions alongside all canonical .sx specs, added
  Boot/CSSX/Browser spec files to architecture page
- Layout: menu bar nav items wrap instead of overflow, baseline alignment
  between label and nav options
- Homepage: added copyright line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:12:38 +00:00
436848060d Merge branch 'worktree-sx-loop-cleanup' into macros
# Conflicts:
#	blog/sx/sx_components.py
#	federation/sx/profile.sx
#	federation/sx/sx_components.py
#	orders/sx/sx_components.py
2026-03-05 16:08:36 +00:00
c1ad6fd8d4 Replace Python sx_call loops with data-driven SX defcomps using map
Move rendering logic from Python for-loops building sx_call strings into
SX defcomp components that use map/lambda over data dicts. Python now
serializes display data into plain dicts and passes them via a single
sx_call; the SX layer handles iteration and conditional rendering.

Covers orders (rows, items, calendar, tickets), federation (timeline,
search, actors, profile activities), and blog (cards, pages, filters,
snippets, menu items, tag groups, page search, nav OOB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:03:29 +00:00
cea009084f Fix sx-browser.js navigation bugs: CSS tracking meta tag and stale verb info
Two fixes for sx-browser.js (spec-compiled) vs sx.js (hand-written):

1. CSS meta tag mismatch: initCssTracking read meta[name="sx-css-hash"]
   but the page template uses meta[name="sx-css-classes"]. This left
   _cssHash empty, causing the server to send ALL CSS as "new" on every
   navigation, appending duplicate rules that broke Tailwind responsive
   ordering (e.g. menu bar layout).

2. Stale verb info after morph: execute-request used captured verbInfo
   from bind time. After morph updated element attributes (e.g. during
   OOB nav swap), click handlers still fired with old URLs. Now re-reads
   verb info from the element first, matching sx.js behavior.

Also includes: render-expression dispatch in eval.sx, NIL guard for
preload cache in bootstrap_js.py, and helpers.py switched to
sx-browser.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:28:56 +00:00
af77fc32c7 Move spec metadata from Python to SX, add orchestration to spec viewer
Spec file registry (slugs, filenames, titles, descriptions) now lives in
nav-data.sx as SX data definitions. Python helper reduced to pure file I/O
(read-spec-file). Architecture page updated with engine/orchestration split
and dependency graph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:34:58 +00:00
d696735f95 Merge branch 'worktree-sx-meta-eval' into macros
# Conflicts:
#	shared/static/scripts/sx-browser.js
2026-03-05 13:20:36 +00:00
bea071a039 Add CSSX and boot adapters to SX spec (style dictionary + browser lifecycle)
- cssx.sx: on-demand CSS style dictionary (variant splitting, atom resolution, content-addressed hashing, style merging)
- boot.sx: browser boot lifecycle (script processing, mount/hydrate/update, component caching, head element hoisting)
- bootstrap_js.py: platform JS for cssx (FNV-1a hash, regex, CSS injection) and boot (localStorage, cookies, DOM mounting)
- Rebuilt sx-browser.js (136K) and sx-ref.js (148K) with all adapters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:20:29 +00:00
1c7346ab37 Resolve merge conflict in generated sx-browser.js 2026-03-05 13:04:44 +00:00
d07a408c89 Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 13:04:35 +00:00
eac0fce8f7 Split orchestration from engine into separate adapter
engine.sx now contains only pure logic: parsing, morph, swap, headers,
retry, target resolution, etc. orchestration.sx contains the browser
wiring: request execution, trigger binding, SSE, boost, post-swap
lifecycle, and init. Dependency is one-way: orchestration → engine.

Bootstrap compiler gains "orchestration" as a separate adapter with
deps on engine+dom. Engine-only builds get morph/swap without the
full browser runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:04:27 +00:00
639f96fe6b Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 12:54:48 +00:00
d4b23aae4c Add engine orchestration to SX spec (fetch, triggers, swap, SSE, history, init)
29 orchestration functions written in SX + adapter style: request pipeline
(execute-request, do-fetch, handle-fetch-success), trigger binding (poll,
intersect, load, revealed, event), post-swap processing, OOB swaps, boost,
SSE, inline handlers, preload, history/popstate, and engine-init. Platform
JS implementations in bootstrap_js.py for all browser-specific operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:54:39 +00:00
3197022299 Restructure Specs section into Architecture, Core, and Adapters pages
- Add Architecture intro page explaining the spec's two-layer design
  (core language + selectable adapters) with dependency graph
- Split specs into Core (parser, eval, primitives, render) and
  Adapters (DOM, HTML, SX wire, SxEngine) overview pages
- Add individual detail pages for all adapter and engine specs
- Update nav with Architecture landing, Core, Adapters, and all
  individual spec file links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:55:59 +00:00
7c99002345 Merge sx-browser.js into macros 2026-03-05 11:52:06 +00:00
157a32b426 Add sx-browser.js — browser-only build from SX spec (dom+engine)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:52:03 +00:00
ab50fb5f56 Merge core+adapter SX ref restructure into macros 2026-03-05 11:50:04 +00:00
daeecab310 Restructure SX ref spec into core + selectable adapters
Split monolithic render.sx into core (tag registries, shared utils) plus
four adapter .sx files: adapter-html (server HTML strings), adapter-sx
(SX wire format), adapter-dom (browser DOM nodes), and engine (SxEngine
triggers, morphing, swaps). All adapters written in s-expressions with
platform interface declarations for JS bridge functions.

Bootstrap compiler now accepts --adapters flag to emit targeted builds:
  -a html        → server-only (1108 lines)
  -a dom,engine  → browser-only (1634 lines)
  -a html,sx     → server with SX wire (1169 lines)
  (default)      → all adapters (1800 lines)

Fixes: keyword arg i-counter desync in reduce across all adapters,
render-aware special forms (let/if/when/cond/map) in HTML adapter,
component children double-escaping, ~prefixed macro dispatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:49:44 +00:00
7ecbf19c11 Add Specs section, Reflexive Web essay, fix highlight and dev caching
- Fix highlight() returning SxExpr so syntax-highlighted code renders
  as DOM elements instead of leaking SX source text into the page
- Add Specs section that reads and displays canonical SX spec files
  from shared/sx/ref/ with syntax highlighting
- Add "The Reflexive Web" essay on SX becoming a complete LISP with
  AI as native participant
- Change logo from (<x>) to (<sx>) everywhere
- Unify all backgrounds to bg-stone-100, center code blocks
- Skip component/style cookie cache in dev mode so .sx edits are
  visible immediately on refresh without clearing localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:49:05 +00:00
6fa843016b Gate server-side component expansion with contextvar, fix nth arg order, add GEB essay and manifesto links
- Add _expand_components contextvar so _aser only expands components
  during page slot evaluation (fixes highlight on examples, avoids
  breaking fragment responses)
- Fix nth arg order (nth coll n) in docs.sx, examples.sx (delete-row,
  edit-row, bulk-update)
- Add "Godel, Escher, Bach and SX" essay with Wikipedia links
- Update SX Manifesto: new authors, Wikipedia links throughout,
  remove Marx/Engels link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:03:50 +00:00
4a515f1a0d Add canonical SX language spec reference to CLAUDE.md
Points AI and developers to shared/sx/ref/ as the authoritative
source for SX semantics — eval rules, type system, rendering modes,
component calling convention, and platform interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:36:22 +00:00
824396c7b0 Merge branch 'worktree-sx-meta-eval' into macros 2026-03-05 10:23:45 +00:00
dea4f52454 Expand known components server-side in _aser to fix nested highlight calls
_aser previously serialized all ~component calls for client rendering.
Components whose bodies call Python-only functions (e.g. highlight) would
fail on the client with "Undefined symbol". Now _aser expands components
that are defined in the env via _aser_component, producing SX wire format
with tag-level bodies inlined. Unknown components still serialize as-is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:20:24 +00:00
a9526c4fa1 Update reference SX spec to match sx.js macros branch (CSSX, dict literals, new primitives)
- eval.sx: Add defstyle, defkeyframes, defhandler special forms; add ho-for-each
- parser.sx: Add dict {...} literal parsing and quasiquote/unquote sugar
- primitives.sx: Add parse-datetime, split-ids, css, merge-styles primitives
- render.sx: Add StyleValue handling, SVG filter elements, definition forms in render, fix render-to-html to handle HTML tags directly
- bootstrap_js.py: Add StyleValue type, buildKeyframes, isEvery platform helper, new primitives (format-date, parse-datetime, split-ids, css, merge-styles), dict/quasiquote parser, expose render functions as primitives
- sx-ref.js: Regenerated — 132/132 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:17:28 +00:00
4a3a510a23 Merge branch 'macros' into worktree-sx-meta-eval 2026-03-05 10:03:15 +00:00
e1ae81f736 Add bootstrap compiler: reference SX spec → JavaScript
bootstrap_js.py reads the reference .sx specification (eval.sx, render.sx)
and transpiles the defined evaluator functions into standalone JavaScript.
The output sx-ref.js is a fully functional SX evaluator bootstrapped from
the s-expression spec, comparable against the hand-written sx.js.

Key features:
- JSEmitter class transpiles SX AST → JS (fn→function, let→IIFE, cond→ternary, etc.)
- Platform interface (types, env ops, primitives) implemented as native JS
- Post-transpilation fixup wraps callLambda to handle both Lambda objects and primitives
- 93/93 tests passing: arithmetic, strings, control flow, closures, HO forms,
  components, macros, threading, dict ops, predicates

Fixed during development:
- Bool before int isinstance check (Python bool is subclass of int)
- SX NIL sentinel detection (not Python None)
- Cond style detection (determine Scheme vs Clojure once, not per-pair)
- Predicate null safety (x != null instead of x && to avoid 0-as-falsy in SX)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:58:48 +00:00
8c69e329e0 Fix dict kwarg evaluation in renderComponentDOM, no-cache static in dev
Dict values (e.g. {:X-CSRFToken csrf}) passed as component kwargs were
not being evaluated through sxEval — symbols stayed unresolved in the DOM.
Also add Cache-Control: no-cache headers for /static/ in dev mode so
browser always fetches fresh JS/CSS without needing hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:37:07 +00:00
235428628a Add reference SX evaluator written in s-expressions
Meta-circular evaluator: the SX language specifying its own semantics.
A thin bootstrap compiler per target (JS, Python, Rust) reads these
.sx files and emits a native evaluator.

Files:
- eval.sx: Core evaluator — type dispatch, special forms, TCO trampoline,
  lambda/component/macro invocation, higher-order forms
- primitives.sx: Declarative specification of ~80 built-in pure functions
- render.sx: Three rendering modes (DOM, HTML string, SX wire format)
- parser.sx: Tokenizer, parser, and serializer specification

Platform-specific concerns (DOM ops, async I/O, HTML emission) are
declared as interfaces that each target implements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:31:40 +00:00
64aa417d63 Replace JSON sx-headers with SX dict expressions, fix blog like component
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
sx-headers attributes now use native SX dict format {:key val} instead of
JSON strings. Eliminates manual JSON string construction in both .sx files
and Python callers.

- sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback,
  add _serializeDict for dict→attribute serialization, fix verbInfo scope in
  _doFetch error handler
- html.py: serialize dict attribute values via SX serialize() not str()
- All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}")
- All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON
- Blog like: extract ~blog-like-toggle, fix POST returning wrong component,
  fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers
  keyword mismatch, wrap sx_content in SxExpr for evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:25:28 +00:00
2a04aaad5e Fix market header ImportError and sx docs menu bar 3 OOB insertion
- market/sx/layouts.sx: Update ~market-header-auto macro to build nav
  from data fields via ~market-desktop-nav-from-data instead of
  expecting pre-built "desktop-nav" SxExpr (removed in Phase 9)
- shared/sx/primitives_io.py: Import _market_header_data instead of
  deleted _desktop_category_nav_sx, return individual data fields
- sx/sx/layouts.sx: Fix ~sx-section-layout-oob to use ~oob-header-sx
  for inserting sub-row into always-existing container div

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:50:48 +00:00
51ebf347ba Move events/market/blog composition from Python to .sx defcomps (Phase 9)
Continues the pattern of eliminating Python sx_call tree-building in favour
of data-driven .sx defcomps. POST/PUT/DELETE routes now pass plain data
(dicts, lists, scalars) and let .sx handle iteration, conditionals, and
layout via map/let/when/if. Single response components wrap OOB swaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:17:09 +00:00
1d59023571 Move events composition from Python to .sx defcomps (Phase 9)
Convert all 14 events page helpers from returning sx_call() strings
to returning data dicts. Defpage expressions compose SX components
with data bindings using map/fn/if/when.

Complex sub-panels (entry tickets config, buy form, posts panel,
options buttons, entry nav menu) returned as SxExpr from existing
render functions which remain for HTMX handler use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:30:46 +00:00
877e776977 Move market composition from Python to .sx defcomps (Phase 8)
Convert 5 market page helpers from returning sx_call() strings to
returning data dicts. Defpages now use :data + :content pattern.
Admin panel uses inline map/fn for CRUD item composition.
Removed market-admin-content helper (placeholder inlined in defpage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:10:55 +00:00
1560207097 Move blog composition from Python to .sx defcomps (Phase 7)
Convert all 8 blog page helpers from returning sx_call() strings to
returning data dicts. Defpages now use :data + :content pattern:
helpers load data, SX composes markup. Newsletter options and footer
badges composed inline with map/fn in defpage expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:10:55 +00:00
aed4c03537 Fix highlight undefined symbol by expanding component results server-side
When defpage content expressions use case/if branches that resolve to
component calls (e.g. `(case slug "intro" (~docs-intro-content) ...)`),
_aser serializes them for the client. Components containing Python-only
helpers like `highlight` then fail with "Undefined symbol" on the client.

Add _maybe_expand_component_result() which detects when the evaluated
result (SxExpr or string) is a component call starting with "(~" and
re-parses + expands it through async_eval_slot_to_sx server-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:52:45 +00:00
dfccd113fc Move sx docs page helpers from Python to pure SX composition (Phase 6)
Nav data, section nav, example content, reference table builders, and
all slug dispatch now live in .sx files. Python helpers reduced to
data-only returns (highlight, primitives-data, reference-data,
attr-detail-data). Deleted essays.py and utils.py entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:49:04 +00:00
b15025befd Fix highlight undefined symbol by expanding component strings server-side
Page helpers returning SX component call strings (e.g. "(~docs-intro-content)")
were sent to the client unexpanded. Components containing Python-only helpers
like `highlight` then failed with "Undefined symbol" on the client. Now
async_eval_slot_to_sx re-parses and expands these strings server-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:45:04 +00:00
0144220427 Move cart composition from Python to .sx defcomps (Phase 5)
- render_orders_rows: Python loop building row-pairs → ~cart-orders-rows-content
  defcomp that maps over order data and handles pagination sentinel
- render_checkout_error_page: conditional order badge composition →
  ~cart-checkout-error-from-data defcomp
- Remove unused SxExpr import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:27:54 +00:00
c71ca6754d Move blog composition from Python to .sx defcomps (Phase 4)
- Settings form: ~135 lines raw HTML → ~blog-settings-form-content defcomp
- Data introspection: ~110 lines raw HTML → ~blog-data-table-content with
  recursive ~blog-data-model-content defcomps, Python extracts ORM data only
- Preview: sx_call composition → ~blog-preview-content defcomp
- Entries browser: ~65 lines raw HTML → ~blog-entries-browser-content +
  ~blog-calendar-browser-item + ~blog-associated-entries-from-data defcomps
- Editor panels: sx_call composition in both helpers.py and renders.py →
  ~blog-editor-content and ~blog-edit-content composition defcomps
- renders.py: 178 → 25 lines (87% reduction)
- routes.py _render_associated_entries: data extraction → single sx_call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:24:37 +00:00
e81d77437e Move market composition from Python to .sx defcomps (Phase 3)
Python sxc/pages/ functions no longer build nested sx_call chains or
reference leaf component names. Instead they extract data (URLs, prices,
CSRF, cart state) and call a single top-level composition defcomp with
pure data values. The .sx defcomps handle all component-to-component
wiring, iteration (map), and conditional rendering.

New .sx composition defcomps:
- headers.sx: ~market-header-from-data, ~market-desktop-nav-from-data,
  ~market-product-header-from-data, ~market-product-admin-header-from-data
- prices.sx: ~market-prices-header-from-data, ~market-card-price-from-data
- navigation.sx: ~market-mobile-nav-from-data
- cards.sx: ~market-product-cards-content, ~market-card-from-data,
  ~market-cards-content, ~market-landing-from-data
- detail.sx: ~market-product-detail-from-data, ~market-detail-gallery-from-data,
  ~market-detail-info-from-data
- meta.sx: ~market-product-meta-from-data
- filters.sx: ~market-desktop-filter-from-data, ~market-mobile-chips-from-data,
  ~market-mobile-filter-content-from-data, plus 6 sub-composition defcomps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:11:57 +00:00
36a0bd8577 Move sx docs markup from Python to .sx files (Phase 2)
Migrate ~2,500 lines of SX markup from Python string concatenation in
essays.py to proper .sx defcomp definitions:

- docs-content.sx: 8 defcomps for docs pages (intro, getting-started,
  components, evaluator, primitives, css, server-rendering, home)
- protocols.sx: 6 defcomps for protocol documentation pages
- essays.sx: 9 essay defcomps (pure content, no params)
- examples.sx: template defcomp receiving data values, calls highlight
  internally — Python passes raw code strings, never SX
- reference.sx: 6 defcomps for data-driven reference pages

essays.py reduced from 2,699 to 619 lines. Docs/protocol/essay
functions become one-liners returning component names. Example functions
use sx_call to pass data values to the template. Reference functions
pass data-built component trees via SxExpr.

renders.py: removed _code, _example_code, _placeholder,
_clear_components_btn (now handled by .sx templates).
helpers.py: removed inline hero code building, uses ~sx-home-content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:22:17 +00:00
4298d5be16 Fix sx docs pages leaking raw s-expressions and missing sub-row
Three issues fixed:

- async_eval_slot_to_sx (and async_eval_to_sx) was calling serialize()
  on plain strings returned by page helpers, quoting them as literals
  instead of treating them as sx source. Added str check to wrap
  directly in SxExpr.

- _render_to_sx_with_env passed layout kwargs only as env free
  variables, but _aser_component defaults all declared params to NIL
  regardless of env. Now builds the AST with extra_env entries as
  keyword args so they bind through normal param mechanism.

- _nav_items_sx returned plain str; changed to SxExpr so nav fragments
  serialize unquoted when passed as layout kwargs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:02:23 +00:00
1077fae815 Merge branch 'worktree-sx-layout-conversion' into macros
# Conflicts:
#	blog/sxc/pages/layouts.py
#	cart/sxc/pages/layouts.py
#	events/sxc/pages/helpers.py
#	events/sxc/pages/layouts.py
#	market/sxc/pages/layouts.py
#	sx/sxc/pages/layouts.py
2026-03-04 22:25:52 +00:00
57a31a3b83 Convert all 23 register_custom_layout calls to register_sx_layout across 6 services
Layout defcomps are now fully self-contained via IO-primitive auto-fetch
macros, eliminating Python layout functions that manually threaded context
values through SxExpr wrappers.

Services converted:
- Federation (1 layout): social
- Blog (7 layouts): blog, blog-settings, blog-cache, blog-snippets,
  blog-menu-items, blog-tag-groups, blog-tag-group-edit
- SX docs (2 layouts): sx, sx-section
- Cart (2 layouts): cart-page, cart-admin + orders/order-detail
- Events (9 layouts): calendar-admin, slots, slot, day-admin, entry,
  entry-admin, ticket-types, ticket-type, markets
- Market (2 layouts): market, market-admin

New IO primitives added to shared/sx/primitives_io.py:
- federation-actor-ctx, cart-page-ctx, request-view-args
- events-calendar-ctx, events-day-ctx, events-entry-ctx,
  events-slot-ctx, events-ticket-type-ctx
- market-header-ctx (pre-builds desktop/mobile nav as SxExpr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:21:44 +00:00
1db52472e3 Fix entry url_for endpoints: use defpage_entry_detail/defpage_entry_admin after auto-mount migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:59:08 +00:00
278ae3e8f6 Make SxExpr a str subclass, sx_call/render functions return SxExpr
SxExpr is now a str subclass so it works everywhere a plain string
does (join, isinstance, f-strings) while serialize() still emits it
unquoted. sx_call() and all internal render functions (_render_to_sx,
async_eval_to_sx, etc.) return SxExpr, eliminating the "forgot to
wrap" bug class that caused the sx_content leak and list serialization
bugs.

- Phase 0: SxExpr(str) with .source property, __add__/__radd__
- Phase 1: sx_call returns SxExpr (drop-in, all 200+ sites unchanged)
- Phase 2: async_eval_to_sx, async_eval_slot_to_sx, _render_to_sx,
  mobile_menu_sx return SxExpr; remove isinstance(str) workaround
- Phase 3: Remove ~150 redundant SxExpr() wrappings across 45 files
- Phase 4: serialize() docstring, handler return docs, ;; returns: sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:47:00 +00:00
ad75798ab7 Fix day admin url_for endpoints: use defpage_day_admin after auto-mount migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:52:46 +00:00
0456b3d25c Fix _aser_call and sx_call list serialization: use (list ...) for data arrays
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
Data lists (dicts, strings, numbers) were wrapped in (<> ...) fragments
which the client rendered as empty DocumentFragments instead of iterable
arrays. This broke map/filter over cards, tag_groups, and authors in
blog index and similar components.

- _aser_call: data lists → (list ...), rendered content (SxExpr) → (<> ...)
- sx_call: all list kwargs → (list ...)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:11:11 +00:00
959e63d440 Remove render_to_sx from public API: enforce sx_call for all service code
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s
Replace ~250 render_to_sx calls across all services with sync sx_call,
converting many async functions to sync where no other awaits remained.
Make render_to_sx/render_to_sx_with_env private (_render_to_sx).
Add (post-header-ctx) IO primitive and shared post/post-admin defmacros.
Convert built-in post/post-admin layouts from Python to register_sx_layout
with .sx defcomps. Remove dead post_admin_mobile_nav_sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:30:45 +00:00
57e0d0c341 Fix defmacro expansion in _aser: check for macros before serializing ~components
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m39s
The ~component check in _aser immediately serialized all names starting
with ~ as unexpanded component calls. This meant defmacro definitions
like ~root-header-auto were sent to the client unexpanded, causing
"Undefined symbol: root-header-ctx" errors since IO primitives only
exist server-side. Now checks env for Macro instances first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:29:14 +00:00
7fda7a8027 Replace env free-variable threading with IO-primitive auto-fetch macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m38s
Layout components now self-resolve context (cart-mini, auth-menu, nav-tree,
rights, URLs) via new IO primitives (root-header-ctx, select-colours,
account-nav-ctx, app-rights) and defmacro wrappers (~root-header-auto,
~auth-header-row-auto, ~root-mobile-auto). This eliminates _ctx_to_env(),
HELPER_CSS_CLASSES, and verbose :key threading across all 10 services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:20:57 +00:00
8be00df6d9 Merge branch 'worktree-macros-essays' into macros 2026-03-04 17:13:50 +00:00
ad6a8ecb17 Refine events + sx sub-module imports from background agents
Events: route imports now point to specific sub-modules (entries,
tickets, slots) instead of all going through renders.py. Merged
layouts into helpers.py. __init__.py now 20 lines.

SX Docs: moved dispatchers from helpers.py into essays.py, cleaned
up __init__.py to 24 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:13:45 +00:00
8772d59d84 Fix _aser_call list serialization causing EvalError on re-parse
Plain Python lists (e.g. from map) were serialized as ((item1) (item2))
which re-parses as a function application, causing "Not callable: _RawHTML"
when the head gets fully evaluated. Keyword list values now wrap as
(<> item1 item2) fragments; positional list children are flattened.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:12:17 +00:00
ece30fb1d2 Merge branch 'worktree-macros-essays' into macros
# Conflicts:
#	sx/sxc/pages/__init__.py
2026-03-04 17:07:26 +00:00
5344b382a5 Slim events + sx sxc/pages/__init__.py → registration-only
Events: 3861 → 21 lines, split into 8 sub-modules (renders, helpers,
layouts, calendar, entries, slots, tickets, utils). Updated 16 bp routes.

SX Docs: 3224 → 27 lines, split into 5 sub-modules (renders, utils,
essays, helpers, layouts). Updated 37 import sites in bp/pages/routes.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:07:08 +00:00
0e0a42ac04 Merge branch 'worktree-macros-essays' into macros 2026-03-04 16:58:51 +00:00
9cbfb09b41 Slim market/sxc/pages/__init__.py → 21 lines
Move ~1670 lines to 6 sub-modules: renders.py, layouts.py, helpers.py,
cards.py, filters.py, utils.py. Update all bp route imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:58:47 +00:00
5690bb0388 Merge branch 'worktree-macros-essays' into macros 2026-03-04 16:52:13 +00:00
8eaf4026ab Slim sxc/pages/__init__.py for federation, test, cart, blog
Move render functions, layouts, helpers, and utils from __init__.py
to sub-modules (renders.py, layouts.py, helpers.py, utils.py).
Update all bp route imports to point at sub-modules directly.
Each __init__.py is now ≤20 lines of setup + registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:51:57 +00:00
76bc293faa Document SX rendering pipeline, add missing sx_docs mount, loud error on missing component
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m18s
- CLAUDE.md: add SX rendering pipeline overview, service sx/ vs sxc/
  convention, dev container mount convention
- docker-compose.dev.yml: add missing ./sx/sx:/app/sx bind mount for
  sx_docs (root cause of "Unknown component: ~sx-layout-full")
- async_eval.py: add evaluation modes table to module docstring; log
  error when async_eval_slot_to_sx can't find a component instead of
  silently falling through to client-side serialization
- helpers.py: remove debug logging from render_to_sx_with_env

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:48:01 +00:00
992a9e1731 Merge branch 'worktree-macros-essays' into macros 2026-03-04 16:14:29 +00:00
03d7b29745 Fix load_service_components path for sx, market, events
The load_service_components call used dirname twice from
sxc/pages/__init__.py, yielding {service}/sxc/ instead of
{service}/. This meant {service}/sx/*.sx files (layouts, calendar
components, etc.) were never loaded into the component env.

- sx: ~sx-layout-full not found → Unknown component on client
- events: ~events-calendar-grid not found → Unknown component
- market: also fix url_for endpoint for defpage_market_admin
  (mounted on app, not blueprint — no prefix needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:14:26 +00:00
9a9999d2e1 Merge branch 'worktree-macros-essays' into macros 2026-03-04 16:04:35 +00:00
015469e401 Fix Undefined symbol: div — delegate HTML tags to renderDOM in sxEval
When an HTML tag like (div) appears as a kwarg value in SX wire format,
callComponent evaluates it with sxEval (data mode) which doesn't handle
HTML tags. Now sxEval delegates to renderDOM for any render expression
(HTML tags, SVG tags, fragments, raw!, components).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:04:29 +00:00
2258a0790b Merge branch 'worktree-macros-essays' into macros 2026-03-04 15:49:02 +00:00
527c4186ee Fix _aser_component: evaluate kwargs with _aser not async_eval
_aser_component expands component bodies in SX wire format mode,
but was evaluating kwarg values with async_eval (HTML mode). This
caused SxExpr kwargs to be fully rendered to HTML strings, which
then broke when serialized back to SX — producing bare symbols
like 'div' that the client couldn't resolve.

Fix: use _aser() for kwarg evaluation to keep values in SX format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:48:57 +00:00
0b4443f394 Merge branch 'worktree-macros-essays' into macros 2026-03-04 15:45:32 +00:00
4939884f25 Add debug logging for Undefined symbol errors in sx.js
Logs env keys (non-function) when a symbol lookup fails, to help
diagnose which component/context is missing the expected binding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:45:32 +00:00
e23d73d1b1 Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m51s
2026-03-04 15:27:46 +00:00
715df11f82 Phase 8-9: Convert events + sx layouts, add missing JS primitives
Events (Phase 8):
- Create events/sx/layouts.sx with 18 defcomps for all 9 layout pairs
- Convert all layout functions to render_to_sx_with_env + _ctx_to_env
- Convert 5 render functions to eliminate root_header_sx calls
- Zero root_header_sx references remain in events

SX Docs (Phase 9):
- Create sx/sx/layouts.sx with layout defcomps
- Convert 4 layout functions to render_to_sx_with_env + _ctx_to_env

JS primitives:
- Add slice, replace, upper, lower, trim, escape, strip-tags, split,
  join, pluralize, clamp, parse-int, format-decimal, format-date,
  parse-datetime, split-ids, starts-with?, ends-with?, dissoc, into
- Fix contains? for strings (indexOf instead of in operator)
- Prevents "Undefined symbol" errors when .sx expressions using
  server-side primitives are evaluated client-side

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:27:41 +00:00
69d328b20f Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m2s
2026-03-04 15:15:57 +00:00
121aa30f32 Fix contains? primitive to handle strings in both JS and Python
The JS contains? used `k in c` which throws TypeError on strings.
The Python version silently returned False for strings. Both now
use indexOf/`in` for substring matching on strings.

Fixes: sx.js MOUNT PARSE ERROR on blog index where
(contains? current-local-href "?") was evaluated client-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:15:50 +00:00
be3e86d8d6 Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m11s
2026-03-04 15:03:04 +00:00
1dbf600af2 Convert test/cart/blog/market layouts to use _ctx_to_env + render_to_sx_with_env
Phase 4 (Test): Update ~test-layout-full and ~test-detail-layout-full defcomps
to use ~root-header with env free variables. Switch render functions to
render_to_sx_with_env.

Phase 5 (Cart): Convert cart-page, cart-admin, and order render functions.
Update cart .sx layout defcomps to use ~root-header from free variables.

Phase 6 (Blog): Convert all 7 blog layouts (blog, settings, sub-settings x5).
Remove all root_header_sx calls from blog.

Phase 7 (Market): Convert market and market-admin layouts plus browse/product
render functions. Remove root_header_sx import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:02:59 +00:00
9be8a38fe9 Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
2026-03-04 14:50:20 +00:00
a30e7228d8 Pass all values as &key args to ~root-header/~root-mobile
Nested component calls in _aser are serialized without body expansion,
so free variables inside ~root-header would be sent unresolved to the
client. Fix by making ~root-header/~root-mobile take all values as
&key params, and having parent layout defcomps pass them explicitly.
The parent layout bodies ARE expanded (via async_eval_slot_to_sx),
so their free variables resolve correctly during that expansion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:50:14 +00:00
2f26437004 Merge branch 'worktree-macros-essays' into macros
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-03-04 14:48:48 +00:00
e4bfd46c48 Fix register_sx_layout: use async_eval_slot_to_sx to expand component bodies
async_eval_to_sx serializes component calls without expanding their bodies,
so free variables from _ctx_to_env were passed through as unresolved symbols
to the client. Switch to async_eval_slot_to_sx which expands the top-level
component body server-side, resolving free variables during expansion.

Also inline ~root-header/~root-mobile into layout defcomps rather than using
wrapper defcomps (nested ~component calls in _aser are serialized without
expansion, so wrapper defcomps would still leave free vars unresolved).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:48:43 +00:00
2e23feb09e Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
2026-03-04 14:39:57 +00:00
45c5e4a0db Add register_sx_layout infrastructure, convert account/federation/orders
Phase 0: Add _ctx_to_env() and render_to_sx_with_env() to shared/sx/helpers.py,
register_sx_layout() to shared/sx/layouts.py, and ~root-header/~root-mobile
wrapper defcomps to layout.sx. Convert built-in "root" layout to .sx.

Phases 1-3: Convert account (65→19 lines), federation (105→97 lines),
and orders (88→21 lines) to use register_sx_layout with .sx defcomps
that read ctx values as free variables from the evaluation environment.
No more Python building SX strings via SxExpr(await root_header_sx(ctx)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:39:53 +00:00
a84916e82f Fix filter/map tag disambiguation inside SVG context without keyword attrs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m19s
(filter (feTurbulence ...)) inside (svg ...) has no keyword first arg,
so the keyword-only check dispatched it as a HO function. Now also
check SVG/MathML context (ns in client, _svg_context in server).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:03:02 +00:00
f5c266e785 Fix custom element check: require keyword arg to disambiguate from variables
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m14s
Hyphenated names like app-url are variables, not custom elements.
Only treat as custom element when first arg is a Keyword (tag call pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:55:36 +00:00
d551806976 Add SVG namespace auto-detection, custom elements, html: prefix, and fix filter/map tag collision
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- Fix filter/map dispatching as HO functions when used as SVG/HTML tags
  (peek at first arg — Keyword means tag call, not function call)
- Add html: prefix escape hatch to force any name to render as an element
- Support custom elements (hyphenated names) per Web Components spec
- SVG/MathML namespace auto-detection: client threads ns param through
  render chain; server uses _svg_context ContextVar so unknown tags
  inside (svg ...) or (math ...) render as elements without enumeration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:53:08 +00:00
2663dfb095 Add SVG cover art to SX Manifesto as s-expression
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m1s
Soviet constructivist poster with paper texture filters, grid lines,
aged stain spots, and "(<x>)" symbol in red.

Add missing SVG filter primitive tags to both server (html.py) and
client (sx.js): feTurbulence, feColorMatrix, feBlend,
feComponentTransfer, feFuncR/G/B/A, feDisplacementMap, feComposite,
feFlood, feImage, feMorphology, feSpecularLighting, feDiffuseLighting,
fePointLight, feSpotLight, feDistantLight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:34:58 +00:00
ccd9b969ea Add 40 more links to SX Manifesto, author: Markdown & Anglebrackets
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m26s
Link HTML, CSS, JavaScript, framework, separation of concerns, markup,
weak typing, prototype chains, declarative, Turing-complete, DOM,
homoiconicity, validate forms, server-side execution, signals, hooks,
reconciler, tree-shaking, code-splitting, HMR, transpilation, scoping,
composition model, template literals, inline styles, node_modules,
structured data, dependency tree, breaking changes, developer experience,
angle bracket, and "designed in ten days". Remove original source link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:27:43 +00:00
7325bb9ecf Fix ParseError in SX Manifesto: bare backslash + unclosed li
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m47s
- Wrap \"this\" in sx string quotes so backslash escapes are inside a string
- Remove stray quote before closing paren on wire protocol li item

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:15:09 +00:00
6f3562707a Change language to paradigm where referring to SX in the manifesto
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m37s
SX is a paradigm, not a language. Changed 7 instances where "language"
referred to SX itself: "one paradigm since 1958", "the paradigm is the
framework", "not a framework but a paradigm", "paradigms do not have
breaking changes", "the paradigm itself provides", "a paradigm that
does not require a migration guide", "distinct from the paradigm".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:00:02 +00:00
2609e782fc Change s-expressions to s-expressionism where it refers to the movement
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
In the SX Manifesto: "the spectre of s-expressionism", "S-expressionism
abolishes", "S-expressionism needs no ecosystem", "S-expressionism
resolves the CSS question", "The s-expressionist revolution".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:57:51 +00:00
28cbe60dc6 Add 78 links throughout the SX Manifesto essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
External links to Google, Meta, webpack, Vercel, Stack Overflow, React,
Vue, Angular, Svelte, SolidJS, Qwik, Astro, Next, Nuxt, Remix, Gatsby,
Rollup, Parcel, esbuild, Vite, Turbopack, TypeScript, Emacs, Clojure,
npm, Lisp, Scheme, Brendan Eich, Tailwind, Sass, Less, PostCSS, XML,
XSLT, JSON, YAML, TOML, JSX, SFCs, Lodash, Moment, Axios, left-pad,
is-odd, CSS-in-JS, virtual DOM, Vicar of Bray, CGI-bin, and Marx.

Internal links to /docs/components, /docs/evaluator, /docs/primitives,
/docs/css, /essays/on-demand-css, /protocols/wire-format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:55:12 +00:00
0f82294dc1 Update /docs/css page with CSSX native style primitives docs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m56s
Document css primitive, style atoms, variants, defstyle, defkeyframes,
and on-demand delivery protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:50:07 +00:00
19d59f5f4b Implement CSSX Phase 2: native SX style primitives
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Replace Tailwind class strings with native SX expressions:
(css :flex :gap-4 :hover:bg-sky-200) instead of :class "flex gap-4 ..."

- Add style_dict.py: 516 atoms, variants, breakpoints, keyframes, patterns
- Add style_resolver.py: memoized resolver with variant splitting
- Add StyleValue type to types.py (frozen dataclass with class_name, declarations, etc.)
- Add css and merge-styles primitives to primitives.py
- Add defstyle and defkeyframes special forms to evaluator.py and async_eval.py
- Integrate StyleValue into html.py and async_eval.py render paths
- Add register_generated_rule() to css_registry.py, fix media query selector
- Add style dict JSON delivery with localStorage caching to helpers.py
- Add client-side css primitive, resolver, and style injection to sx.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:47:51 +00:00
28388540d5 Fix unquoted (code) element in SX manifesto essay
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s
The (code :class "text-violet-700" ...) was embedded inside a string
child of (p), causing the SX parser to see text-violet-700 as a bare
symbol. Close the text string before the (code) element so it becomes
a proper child of the paragraph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:41 +00:00
5fac47c132 Fix sx-manifesto EvalError: close string before italic span
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
The em span variable was embedded inside an unclosed sx string,
causing the " before "italic" to close the outer string and
leaving italic as an undefined bare symbol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:57:28 +00:00
213421516e Add SSE, response headers, view transitions, and 5 new sx attributes
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Implement missing SxEngine features:
- SSE (sx-sse, sx-sse-swap) with EventSource management and auto-cleanup
- Response headers: SX-Trigger, SX-Retarget, SX-Reswap, SX-Redirect,
  SX-Refresh, SX-Location, SX-Replace-Url, SX-Trigger-After-Swap/Settle
- View Transitions API: transition:true swap modifier + global config
- every:<time> trigger for polling (setInterval)
- sx-replace-url (replaceState instead of pushState)
- sx-disabled-elt (disable elements during request)
- sx-prompt (window.prompt, value sent as SX-Prompt header)
- sx-params (filter form parameters: *, none, not x,y, x,y)

Adds docs (ATTR_DETAILS, BEHAVIOR_ATTRS, headers, events), demo
components in reference.sx, API endpoints (prompt-echo, sse-time),
and 27 new unit tests for engine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:55:21 +00:00
3bffc212cc Fix sx docs load_sx_dir path to point to sxc/ not sxc/pages/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
The .sx component files (home.sx, docs.sx, etc.) live in sxc/, but
the path was pointing to sxc/pages/ after the move from sx_components.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:35:22 +00:00
b51b050dda Delete last sx_components.py files: relations + test (phase 9)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m2s
Move relations component loading into app.py. Move test rendering
functions to test/sxc/pages/__init__.py, update route imports, and
delete both sx_components.py files. Zero sx_components imports remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:26:58 +00:00
5bb02b7dd5 Move 7 htmx-equivalent attrs from SX_UNIQUE_ATTRS to BEHAVIOR_ATTRS
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
These are sx implementations of htmx attributes (boost, preload,
preserve, indicator, validate, ignore, optimistic), not unique to sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:26:24 +00:00
16f0908ec9 Move SX docs rendering from sx_components.py to sxc/pages (phase 8)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
Consolidate 86 component rendering functions into sxc/pages/__init__.py,
update 37 import sites in routes.py, remove app.py side-effect imports,
and delete sx/sxc/sx_components.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:24:26 +00:00
7419ecf3c0 Delete events sx_components.py — move all rendering to sxc/pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Phase 7 of the zero-Python-rendering plan. All 100 rendering functions
move from events/sx/sx_components.py into events/sxc/pages/__init__.py.
Route handlers (15 files) import from sxc.pages instead.
load_service_components call moves into _load_events_page_files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:19:38 +00:00
31a8b755d9 Implement 7 missing sx attributes: boost, preload, preserve, indicator, validate, ignore, optimistic
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Add sx-preserve/sx-ignore (morph skip), sx-indicator (loading element),
sx-validate (form validation), sx-boost (progressive enhancement),
sx-preload (hover prefetch with 30s cache), and sx-optimistic (instant
UI preview with rollback). Move all from HTMX_MISSING_ATTRS to
SX_UNIQUE_ATTRS with full ATTR_DETAILS docs and reference.sx demos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:18:31 +00:00
049796c391 Delete market sx_components.py — move all rendering to sxc/pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 32s
Phase 6 of the zero-Python-rendering plan. All 46 rendering functions
move from market/sx/sx_components.py into market/sxc/pages/__init__.py.
Route handlers import from sxc.pages instead. load_service_components
call moves into _load_market_page_files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:08:44 +00:00
8578eb525e Change sx logo to (<x>)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:56:50 +00:00
96a4f56424 Fix extra closing paren in SX manifesto and TCO essays
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Both essays had one excess `)` causing ParseError when navigating
via HTMX (oob_page_sx path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:55:51 +00:00
e72f7485f4 Add TCO trampolining to async evaluator and sx.js client
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Both evaluators now use thunk-based trampolining to eliminate stack
overflow on deep tail recursion (verified at 50K+ depth). Mirrors
the sync evaluator TCO added in 5069072.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:53:16 +00:00
da8d2e342f Continuations essay: add argument that they're easier to reason about than workarounds
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m11s
Without call/cc you get callback pyramids, state machines, command
pattern undo stacks, Promise chains, and framework-specific hooks —
each a partial ad-hoc reinvention of continuations with its own edge
cases. The complexity doesn't disappear; it moves into user code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:08 +00:00
fd67f202c2 Add Continuations essay to SX docs
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Covers server-side (suspendable rendering, streaming, error boundaries),
client-side (linear async flows, wizard forms, cooperative scheduling,
undo), and implementation path from the existing TCO trampoline. Updates
TCO essay's continuations section to link to the new essay instead of
dismissing the idea. Fixes "What sx is not" to acknowledge macros + TCO.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:41:22 +00:00
5069072715 Add TCO to evaluator, update SX docs messaging
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m3s
Evaluator: add _Thunk + _trampoline for tail-call optimization in
lambdas, components, if/when/cond/case/let/begin. All callers in
html.py, resolver.py, handlers.py, pages.py, jinja_bridge.py, and
query_registry.py unwrap thunks at non-tail positions.

SX docs: update tagline to "s-expressions for the web", rewrite intro
to reflect that SX replaces most JavaScript need, fix "What sx is not"
to acknowledge macros and TCO exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:31:31 +00:00
a3318b4fd7 Fix syntax error: double-escaped apostrophes in essay strings
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:23:05 +00:00
8a945db37b Add "The SX Manifesto" essay to sx docs app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
A satirical essay in the style of The Communist Manifesto, recasting
the historic struggle between bourgeoisie and proletariat as the war
between HTML, JS, and CSS — with frameworks as petty-bourgeois lackeys
and s-expressions as the revolutionary force that abolishes the
language distinction itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:11:59 +00:00
03f9968979 Add @ rules, dynamic generation, and arbitrary values to SX styles plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m0s
Cover @keyframes (defkeyframes special form + built-in animations),
@container queries, dynamic atom construction (no server round-trip
since client has full dictionary), arbitrary bracket values (w-[347px]),
and inline style fallback for truly unique data-driven values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:05:25 +00:00
96132d9cfe Add Phase 2 SX styles plan to cssx.md
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Document the design for native s-expression style primitives
(css :flex :gap-4 ...) to replace Tailwind CSS strings with first-class
SX expressions. Covers style dictionary, resolver, delivery/caching
(localStorage like components), server-side session tracking, and
migration tooling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:00:54 +00:00
baf9f1468d Fix services.get() → services.blog_page attribute access
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
The service registry uses __getattr__, so .get() is interpreted
as looking up a service named "get". Use attribute access instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:57:04 +00:00
c2fe142039 Delete blog sx_components.py — move all rendering to callers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
Move remaining 19 rendering functions from the 2487-line
sx_components.py to their direct callers:

- menu_items/routes.py: menu item form, page search, nav OOB
- post/admin/routes.py: calendar view, associated entries, nav OOB
- sxc/pages/__init__.py: editor panel, post data inspector, preview,
  entries browser, settings form, edit page editor
- bp/blog/routes.py: inline new post page composition

Move load_service_components() call from sx_components module-level
to setup_blog_pages() so .sx files still load at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:43:52 +00:00
f0fbcef3f6 Inline header functions from sx_components into pages/__init__.py
Move _blog_header_sx, _settings_header_sx, _settings_nav_sx, and
_sub_settings_header_sx into the layout module as local helpers.
Eliminates 14 imports from sx_components.py for the layout system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:27:31 +00:00
d7f9afff8e Move home/post detail/like rendering from Python to .sx defcomps
- Home page: inline shared helpers, render_to_sx("blog-home-main")
- Post detail: new ~blog-post-detail-content defcomp with data from service
- Like toggle: call render_to_sx("market-like-toggle-button") directly
- Add post_meta_data() and post_detail_data() to BlogPageService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:24:55 +00:00
f2910ad767 Replace fragment render functions with .sx defcomps
- Snippets list: render_snippets_list → render_to_sx("blog-snippets-content")
- Menu items list: render_menu_items_list → _render_menu_items_list helper
- Features panel: render_features_panel → render_to_sx("blog-features-panel-content")
- Markets panel: render_markets_panel → render_to_sx("blog-markets-panel-content")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:20:47 +00:00
e75c8d16d1 Move blog index rendering from Python to .sx composition defcomps
BlogPageService.index_data() assembles all data (cards, filters, actions)
and 7 new .sx defcomps handle rendering: main content, aside, filter,
actions, tag groups filter, authors filter, and sentinel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:14:23 +00:00
984e2ebed0 Fix cart load_service_components: use os.path instead of Path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
Avoid UnboundLocalError with Path by using os.path directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:57:27 +00:00
d80894dbf5 Fix cart load_service_components path
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The old sx_components.py used os.path.dirname(__file__) to resolve
the app root. When it was deleted, the replacement call in app.py
used the string "cart" which resolves to /app/cart/ (alembic only),
not /app/ where the sx/ directory lives. Use Path(__file__).parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:56:04 +00:00
8e16cc459a Fix Like model import path in SqlLikesService
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
Inside the likes container the model is at models.like not
likes.models.like — the container's Python path is /app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:52:51 +00:00
336a4ad9a1 Lazy-import Like model in SqlLikesService
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The module-level import of likes.models.like.Like caused ImportError
in non-likes services that register SqlLikesService. Move the import
into a lazy helper called per-method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:51:11 +00:00
d6f3250a77 Fix dev_watcher sentinel path for container permissions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
The sentinel was written to shared/_reload_sentinel.py but shared/ is
volume-mounted as root:root, so appuser can't create files there.
Move sentinel to /app/_reload_sentinel.py which is owned by appuser
and still under Hypercorn's --reload watch path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:37:29 +00:00
486ab834de Fix datetime serialization in _dto_to_dict
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Use dto_to_dict() from shared/contracts/dtos.py for dataclass
serialization instead of raw dataclasses.asdict(). This ensures
datetimes are converted to ISO format strings (not RFC 2822 from
jsonify), matching what dto_from_dict() expects on the receiving end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:28:47 +00:00
41e803335a Fix _dto_to_dict for slots=True dataclasses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
The defquery conversion routes inter-service results through
_dto_to_dict which checked __dict__ (absent on slots dataclasses),
producing {"value": obj} instead of proper field dicts. This broke
TicketDTO deserialization in the cart app. Check __dataclass_fields__
first and use dataclasses.asdict() for correct serialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:24:40 +00:00
1f36987f77 Replace inter-service _handlers dicts with declarative sx defquery/defaction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
The inter-service data layer (fetch_data/call_action) was the least
structured part of the codebase — Python _handlers dicts with ad-hoc
param extraction scattered across 16 route files. This replaces them
with declarative .sx query/action definitions that make the entire
inter-service protocol self-describing and greppable.

Infrastructure:
- defquery/defaction special forms in the sx evaluator
- Query/action registry with load, lookup, and schema introspection
- Query executor using async_eval with I/O primitives
- Blueprint factories (create_data_blueprint/create_action_blueprint)
  with sx-first dispatch and Python fallback
- /internal/schema endpoint on every service
- parse-datetime and split-ids primitives for type coercion

Service extractions:
- LikesService (toggle, is_liked, liked_slugs, liked_ids)
- PageConfigService (ensure, get_by_container, get_by_id, get_batch, update)
- RelationsService (wraps module-level functions)
- AccountDataService (user_by_email, newsletters)
- CartItemsService, MarketDataService (raw SQLAlchemy lookups)

50 of 54 handlers converted to sx, 4 Python fallbacks remain
(ghost-sync/push-member, clear-cart-for-order, create-order).
Net: -1,383 lines Python, +251 lines modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:13:50 +00:00
e53e8cc1f7 Eliminate blog settings page helpers — pure .sx defpages with service data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
Convert 6 blog settings pages (settings-home, cache, snippets, menu-items,
tag-groups, tag-group-edit) from Python page helpers to .sx defpages with
(service "blog-page" ...) IO primitives. Create data-driven defcomps that
handle iteration via (map ...) instead of Python loops.

Post-related page helpers (editor, post-admin/data/preview/entries/settings/edit)
remain as Python helpers — they depend on _ensure_post_data and sx_components
rendering functions that need separate conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:50:24 +00:00
418ac9424f Eliminate Python page helpers from account, federation, and cart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s
All three services now fetch page data via (service ...) IO primitives
in .sx defpages instead of Python middleman functions.

- Account: newsletters-data → AccountPageService.newsletters_data
- Federation: 8 page helpers → FederationPageService methods
  (timeline, compose, search, following, followers, notifications)
- Cart: 4 page helpers → CartPageService methods
  (overview, page-cart, admin, payments)
- Serializers moved to service modules, thin delegates kept for routes
- ~520 lines of Python page helpers removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:01:50 +00:00
fb8f115acb Fix orders defpage: length→len primitive, handle _RawHTML in serialize()
- Fix undefined symbol 'length' → use 'len' primitive in orders.sx
- Add _RawHTML handling in serialize() — wraps as (raw! "...") for SX wire format
  instead of falling through to repr() which produced unparseable symbol names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:55:32 +00:00
63b895afd8 Eliminate Python page helpers from orders — pure .sx defpages with IO primitives
Orders defpages now fetch data via (service ...) and generate URLs via
(url-for ...) and (route-prefix) directly in .sx. No Python middleman.

- Add url-for, route-prefix IO primitives to shared/sx/primitives_io.py
- Add generic register()/\_\_getattr\_\_ to ServiceRegistry for dynamic services
- Create OrdersPageService with list_page_data/detail_page_data methods
- Rewrite orders.sx defpages to use IO primitives + defcomp calls
- Remove ~320 lines of Python page helpers from orders/sxc/pages/__init__.py
- Convert :data env merge to use kebab-case keys for SX symbol access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:50:15 +00:00
50b33ab08e Fix page helper results being quoted as string literals in defpage slots
Page helpers return SX source strings from render_to_sx(), but _aser's
serialize() was wrapping them in double quotes. In async_eval_slot_to_sx,
pass string results through directly since they're already SX source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:43:00 +00:00
bd314a0be7 Guard against empty SxExpr in _as_sx and _build_component_ast
Fragment responses with text/sx content-type but empty body create
SxExpr(""), which is truthy but fails to parse. Handle this by
returning None from _as_sx for empty SxExpr sources, and treating
empty SxExpr as NIL in _build_component_ast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:37:27 +00:00
41cdd6eab8 Add sxc/ volume mounts to docker-compose.dev.yml for all services
The sxc/ directories (defpages, layouts, page helpers) were not
bind-mounted, so dev containers used stale code from the Docker image.
This caused the orders.defpage_order_detail BuildError since the
container had old sxc/pages/__init__.py without the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:34:14 +00:00
1a6503782d Phase 4: Delete cart/sx/sx_components.py, move renders to sxc/pages
Move all render functions (orders page/rows/oob, order detail/oob,
checkout error, payments panel), header helpers, and serializers from
cart/sx/sx_components.py into cart/sxc/pages/__init__.py. Update all
route imports from sx.sx_components to sxc.pages. Replace
import sx.sx_components in app.py with load_service_components("cart").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:30:30 +00:00
72997068c6 Fix orders defpage endpoint references — app-level not blueprint
defpages mounted via auto_mount_pages() register endpoints without
blueprint prefix. Fix url_for("orders.defpage_*") → url_for("defpage_*").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:24:14 +00:00
dacb61b0ae Delete orders + federation sx_components.py — rendering inlined to routes
Phase 2 (Orders):
- Checkout error/return renders moved directly into route handlers
- Removed orphaned test_sx_helpers.py

Phase 3 (Federation):
- Auth pages use _render_social_auth_page() helper in routes
- Choose-username render inlined into identity routes
- Timeline/search/follow/interaction renders inlined into social routes
  using serializers imported from sxc.pages
- Added _social_page() to sxc/pages/__init__.py for shared use
- Home page renders inline in app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:22:33 +00:00
400667b15a Delete account/sx/sx_components.py — all rendering now in .sx
Phase 1 of zero-Python rendering: account service.

- Auth pages (login, device, check-email) use _render_auth_page() helper
  calling render_to_sx() + full_page_sx() directly in routes
- Newsletter toggle POST renders inline via render_to_sx()
- Newsletter page helper returns data dict; defpage :data slot fetches,
  :content slot renders via ~account-newsletters-content defcomp
- Fragment page uses (frag ...) IO primitive directly in .sx
- Defpage _eval_slot now uses async_eval_slot_to_sx which expands
  component bodies server-side (executing IO) but serializes tags as SX
- Fix pre-existing OOB ParseError: _eval_slot was producing HTML instead
  of s-expressions for component content slots
- Fix market url_for endpoint: defpage_market_home (app-level, not blueprint)
- Fix events calendar nav: wrap multiple SX parts in fragment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:16:01 +00:00
44503a7d9b Add Client Reactivity and SX Native essays to sx docs app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:11:48 +00:00
e085fe43b4 Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00
0554f8a113 Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code
Extract Node-only string renderer (renderToString, renderStr, etc.) to
sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings,
_evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy
and _sxCssKnown class-list fallback. Compress section banners. sx.js goes
from 2652 to 2279 lines (-14%) with zero browser-side behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:00:58 +00:00
4e5f9ff16c Remove dead render_profile_page from federation sx_components
This function was replaced by defpage-based rendering but never deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:41:19 +00:00
193578ef88 Move SX construction from Python to .sx defcomps (phases 0-4)
Eliminate Python s-expression string building across account, orders,
federation, and cart services. Visual rendering logic now lives entirely
in .sx defcomp components; Python files contain only data serialization,
header/layout wiring, and thin wrappers that call defcomps.

Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/
pluralize/escape/route-prefix primitives.
Phase 1: Account — dashboard, newsletters, login/device/check-email content.
Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps.
Phase 3: Federation — social nav, post cards, timeline, search, actors,
notifications, compose, profile assembled defcomps.
Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin,
payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:36:34 +00:00
03f0929fdf Fix SX nav morphing, retry error modal, and aria-selected CSS extraction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m18s
- Re-read verb URL from element attributes at execution time so morphed
  nav links navigate to the correct destination
- Reset retry backoff on fresh requests; skip error modal when sx-retry
  handles the failure
- Strip attribute selectors in CSS registry so aria-selected:* classes
  resolve correctly for on-demand CSS
- Add @css annotations for dynamic aria-selected variant classes
- Add SX docs integration test suite (102 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:37:17 +00:00
f551fc7453 Convert last Python fragment handlers to SX defhandlers: 100% declarative fragment API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34m5s
- Add dict recursion to _convert_result for service methods returning dict[K, list[DTO]]
- New container-cards.sx: parses post_ids/slugs, calls confirmed-entries-for-posts, emits card-widget markers
- New account-page.sx: dispatches on slug for tickets/bookings panels with status pills and empty states
- Fix blog _parse_card_fragments to handle SxExpr via str() cast
- Remove events Python fragment handlers and simplify app.py to plain auto_mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:42:19 +00:00
e30cb0a992 Auto-mount fragment handlers: eliminate fragment blueprint boilerplate across all 8 services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 16m38s
Fragment read API is now fully declarative — every handler is a defhandler
s-expression dispatched through one shared auto_mount_fragment_handlers()
function. Replaces 8 near-identical blueprint files (~35 lines each) with
a single function call per service. Events Python handlers (container-cards,
account-page) extracted to a standalone module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:13:15 +00:00
293f7713d6 Auto-mount defpages: eliminate Python route stubs across all 9 services
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 16s
Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

Infrastructure:
- async_eval awaits coroutine results from callable dispatch
- auto_mount_pages() mounts all registered defpages on the app
- g._defpage_ctx pattern passes helper data to layout context

Migrated: sx, account, orders, federation, cart, market, events, blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:03:15 +00:00
4ba63bda17 Add server-driven architecture principle and React feature analysis
Documents why sx stays server-driven by default, maps React features
to sx equivalents, and defines targeted escape hatches for the few
interactions that genuinely need client-side state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:48:35 +00:00
0a81a2af01 Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all
social blueprint routes and federation profile to SX content builders,
delete 12 unused Jinja templates and social_lite layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:43:47 +00:00
0c9dbd6657 Add attribute detail pages with live demos for SX reference
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m45s
Per-attribute documentation pages at /reference/attributes/<slug> with:
- Live interactive demos (demo components in reference.sx)
- S-expression source code display
- Server handler code shown as s-expressions (defhandlers in handlers/reference.sx)
- Wire response display via OOB swaps on demo interaction
- Linked attribute names in the reference table

Covers all 20 implemented attributes (sx-get/post/put/delete/patch,
sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/
headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env).

Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link
/reference/attributes, and makes /reference/ an index page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:12:57 +00:00
a4377668be Add isomorphic SX architecture migration plan
Documents the 5-phase plan for making the sx s-expression layer a
universal view language that renders on either client or server, with
pages as cached components and data-only navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:52:12 +00:00
a98354c0f0 Fix duplicate headers on HTMX nav, editor content loading, and double mount
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m14s
- Nest admin header inside post-header-child (layouts.py/helpers.py) so
  full-page DOM matches OOB swap structure, eliminating duplicate headers
- Clear post-header-child on post layout OOB to remove stale admin rows
- Read SX initial content from #sx-content-input instead of
  window.__SX_INITIAL__ to avoid escaping issues through SX pipeline
- Fix client-side SX parser RE_STRING to handle escaped newlines
- Clear root element in SxEditor.mount() to prevent double content on
  HTMX re-mount
- Remove unused ~blog-editor-sx-initial component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:27:47 +00:00
df8b19ccb8 Convert post edit form from raw HTML to SX expressions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m29s
Replace _post_edit_content_sx raw HTML builder with sx_call() pattern
matching render_editor_panel. Add ~blog-editor-edit-form,
~blog-editor-publish-js, ~blog-editor-sx-initial components to
editor.sx. Fixes (~sx-editor-styles) rendering as literal text on
the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:50 +00:00
353 changed files with 50277 additions and 18584 deletions

View File

@@ -84,13 +84,27 @@ jobs:
fi
done
# Deploy swarm stack only on main branch
# Deploy swarm stacks 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
# Deploy sx-web standalone stack (sx-web.org)
SX_REBUILT=false
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q '^sx/'; then
SX_REBUILT=true
fi
if [ \"\$SX_REBUILT\" = true ]; then
echo 'Deploying sx-web stack (sx-web.org)...'
docker stack deploy -c /root/sx-web/docker-compose.yml sx-web
sleep 5
docker stack services sx-web
# Reload Caddy to pick up any Caddyfile changes
docker service update --force caddy_caddy 2>/dev/null || true
fi
else
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
fi

View File

@@ -52,6 +52,65 @@ artdag/
test/ # Integration & e2e tests
```
## SX Language — Canonical Reference
The SX language is defined by a self-hosting specification in `shared/sx/ref/`. **Read these files for authoritative SX semantics** — they supersede any implementation detail in `sx.js` or Python evaluators.
### Specification files
- **`shared/sx/ref/eval.sx`** — Core evaluator: types, trampoline (TCO), `eval-expr` dispatch, special forms (`if`, `when`, `cond`, `case`, `let`, `and`, `or`, `lambda`, `define`, `defcomp`, `defmacro`, `quasiquote`), higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`), macro expansion, function/lambda/component calling.
- **`shared/sx/ref/parser.sx`** — Tokenizer and parser: grammar, string escapes, dict literals `{:key val}`, quote sugar (`` ` ``, `,`, `,@`), serializer.
- **`shared/sx/ref/primitives.sx`** — All ~80 built-in pure functions: arithmetic, comparison, predicates, string ops, collection ops, dict ops, format helpers, CSSX style primitives.
- **`shared/sx/ref/render.sx`** — Three rendering modes: `render-to-html` (server HTML), `render-to-sx`/`aser` (SX wire format for client), `render-to-dom` (browser). HTML tag registry, void elements, boolean attrs.
- **`shared/sx/ref/bootstrap_js.py`** — Transpiler: reads the `.sx` spec files and emits `sx-ref.js`.
### Type system
```
number, string, boolean, nil, symbol, keyword, list, dict,
lambda, component, macro, thunk (TCO deferred eval)
```
### Evaluation rules (from eval.sx)
1. **Literals** (number, string, boolean, nil) — pass through
2. **Symbols** — look up in env, then primitives, then `true`/`false`/`nil`, else error
3. **Keywords** — evaluate to their string name
4. **Dicts** — evaluate all values recursively
5. **Lists** — dispatch on head:
- Special forms (`if`, `when`, `cond`, `case`, `let`, `lambda`, `define`, `defcomp`, `defmacro`, `quote`, `quasiquote`, `begin`/`do`, `set!`, `->`)
- Higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`, `map-indexed`)
- Macros — expand then re-evaluate
- Function calls — evaluate head and args, then: native callable → `apply`, lambda → bind params + TCO thunk, component → parse keyword args + bind params + TCO thunk
### Component calling convention
```lisp
(defcomp ~card (&key title subtitle &rest children)
(div :class "card"
(h2 title)
(when subtitle (p subtitle))
children))
```
- `&key` params are keyword arguments: `(~card :title "Hi" :subtitle "Sub")`
- `&rest children` captures positional args as `children`
- Component body evaluated in merged env: `closure + caller-env + bound-params`
### Rendering modes (from render.sx)
| Mode | Function | Expands components? | Output |
|------|----------|-------------------|--------|
| HTML | `render-to-html` | Yes (recursive) | HTML string |
| SX wire | `aser` | No — serializes `(~name ...)` | SX source text |
| DOM | `render-to-dom` | Yes (recursive) | DOM nodes |
The `aser` (async-serialize) mode evaluates control flow and function calls but serializes HTML tags and component calls as SX source — the client renders them. This is the wire format for HTMX-like responses.
### Platform interface
Each target (JS, Python) must provide: type inspection (`type-of`), constructors (`make-lambda`, `make-component`, `make-macro`, `make-thunk`), accessors, environment operations (`env-has?`, `env-get`, `env-set!`, `env-extend`, `env-merge`), and DOM/HTML rendering primitives.
## Tech Stack
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
@@ -108,6 +167,26 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
- Silent SSO: `prompt=none` OAuth flow for automatic cross-app login
- ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair
### SX Rendering Pipeline
The SX system renders component trees defined in s-expressions. Canonical semantics are in `shared/sx/ref/` (see "SX Language" section above). The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
- `render_to_html(name, **kw)` — server-side, produces HTML. Maps to `render-to-html` in the spec.
- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Maps to `aser` in the spec. Component calls stay **unexpanded**.
- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands known components** then serializes as SX wire format. Used by layout components that need Python context.
- `sx_page(ctx, page_sx)` — produces the full HTML shell (`<!doctype html>...`) with component definitions, CSS, and page SX inlined for client-side boot.
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.
### Service SX Directory Convention
Each service has two SX-related directories:
- **`{service}/sx/`** — service-specific component definitions (`.sx` files with `defcomp`). Loaded at startup by `load_service_components()`. These define layout components, reusable UI fragments, etc.
- **`{service}/sxc/`** — page definitions and Python rendering logic. Contains `defpage` definitions (client-routed pages) and the Python functions that compose headers, layouts, and page content.
Shared components live in `shared/sx/templates/` and are loaded by `load_shared_components()` in the app factory.
### Art DAG
- **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`)
@@ -130,6 +209,10 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
| likes | (internal only) | 8009 |
| orders | orders.rose-ash.com | 8010 |
## Dev Container Mounts
Dev bind mounts in `docker-compose.dev.yml` must mirror the Docker image's COPY paths. When adding a new directory to a service (e.g. `{service}/sx/`), add a corresponding volume mount (`./service/sx:/app/sx`) or the directory won't be visible inside the dev container. Hypercorn `--reload` watches for Python file changes; `.sx` file hot-reload is handled by `reload_if_changed()` in `shared/sx/jinja_bridge.py`.
## Key Config Files
- `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes

4
account/actions.sx Normal file
View File

@@ -0,0 +1,4 @@
;; Account service — inter-service action endpoints
;;
;; ghost-sync-member and ghost-push-member use local service imports —
;; remain as Python fallbacks.

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -8,7 +7,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_account_bp, register_auth_bp, register_fragments
from bp import register_account_bp, register_auth_bp
async def account_context() -> dict:
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# Setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded
# Load .sx component files and setup defpage routes
from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
from sxc.pages import setup_account_pages
setup_account_pages()
@@ -81,11 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_bp())
account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp)
app.register_blueprint(register_fragments())
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "account")
from bp.actions.routes import register as register_actions
app.register_blueprint(register_actions())

View File

@@ -1,3 +1,2 @@
from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp
from .fragments import register_fragments

View File

@@ -7,17 +7,13 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
redirect,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response, sx_call
def register(url_prefix="/"):
@@ -25,8 +21,7 @@ def register(url_prefix="/"):
@account_bp.before_request
async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes."""
# Fetch account nav items for layout (was in context_processor)
"""Fetch account_nav fragments for layout."""
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
@@ -34,48 +29,6 @@ def register(url_prefix="/"):
], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):
@@ -101,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
# Render toggle directly — no sx_components intermediary
from shared.browser.app.csrf import generate_csrf_token
from shared.infrastructure.urls import account_url
nid = un.newsletter_id
url_fn = getattr(g, "_account_url", None) or account_url
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
translate = "translate-x-6" if un.subscribed else "translate-x-1"
checked = "true" if un.subscribed else "false"
return sx_response(sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs={"X-CSRFToken": csrf},
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}",
))
return account_bp

View File

@@ -1,63 +1,33 @@
"""Account app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (blog webhooks) via the internal action client.
All actions remain as Python fallbacks (local service imports).
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER
from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
bp, _handlers = create_action_blueprint("account")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- ghost-sync-member ---
async def _ghost_sync_member():
"""Sync a single Ghost member into db_account."""
data = await request.get_json()
ghost_id = data.get("ghost_id")
if not ghost_id:
return {"error": "ghost_id required"}, 400
from services.ghost_membership import sync_single_member
await sync_single_member(g.s, ghost_id)
return {"ok": True}
_handlers["ghost-sync-member"] = _ghost_sync_member
# --- ghost-push-member ---
async def _ghost_push_member():
"""Push a local user's membership data to Ghost."""
data = await request.get_json()
user_id = data.get("user_id")
if not user_id:
return {"error": "user_id required"}, 400
from services.ghost_membership import sync_member_to_ghost
result_id = await sync_member_to_ghost(g.s, int(user_id))
return {"ok": True, "ghost_id": result_id}

View File

@@ -44,6 +44,17 @@ from .services import (
SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid"
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
"""Render an auth page with root layout — replaces sx_components helpers."""
from shared.sx.helpers import sx_call, full_page_sx, root_header_sx
from shared.sx.page import get_template_context
ctx = await get_template_context()
hdr = await root_header_sx(ctx)
content = sx_call(component, **{k: v for k, v in kwargs.items() if v})
return await full_page_sx(ctx, header_rows=hdr, content=content,
meta_html=f"<title>{title}</title>")
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
@rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input)
if not is_valid:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Please enter a valid email address.", email=email_input,
), 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:
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=None)
return await render_check_email_page(ctx), 200
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email,
), 200
except Exception:
pass # Redis down — allow the request
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email, email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error=error,
), 400
user_id = user.id
except Exception:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Could not sign you in right now. Please try again.",
), 502
assert user_id is not None
@@ -679,11 +687,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.sx.page import get_template_context
from sx.sx_components import render_device_page
code = request.args.get("code", "")
ctx = await get_template_context(code=code)
return await render_device_page(ctx)
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
code=code,
)
@auth_bp.post("/device")
@auth_bp.post("/device/")
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
user_code = (form.get("code") or "").strip().replace("-", "").upper()
if not user_code or len(user_code) != 8:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Please enter a valid 8-character code.", code=form.get("code", ""),
), 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:
from shared.sx.page import get_template_context
from sx.sx_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
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code not found or expired. Please try again.", code=form.get("code", ""),
), 400
if isinstance(device_code, bytes):
device_code = device_code.decode()
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately
ok = await _approve_device(device_code, g.user)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_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_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used.",
), 400
from shared.sx.page import get_template_context
from sx.sx_components import render_device_approved_page
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
@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.sx.page import get_template_context
from sx.sx_components import render_device_page, render_device_approved_page
device_code = request.args.get("code", "")
if not device_code:
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user)
if not ok:
ctx = await get_template_context(
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used. Please start the login process again in your terminal.",
)
return await render_device_page(ctx), 400
), 400
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
return auth_bp

View File

@@ -1,67 +1,14 @@
"""Account app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
All queries are defined in ``account/queries.sx``.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER
from sqlalchemy import select
from shared.models import User
from shared.infrastructure.query_blueprint import create_data_blueprint
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- user-by-email ---
async def _user_by_email():
"""Return user_id for a given email address."""
email = request.args.get("email", "").strip().lower()
if not email:
return None
result = await g.s.execute(
select(User.id).where(User.email.ilike(email))
)
row = result.first()
if not row:
return None
return {"user_id": row[0]}
_handlers["user-by-email"] = _user_by_email
# --- newsletters ---
async def _newsletters():
"""Return all Ghost newsletters (for blog post editor)."""
from shared.models.ghost_membership_entities import GhostNewsletter
result = await g.s.execute(
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
.order_by(GhostNewsletter.name)
)
return [
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
for row in result.all()
]
_handlers["newsletters"] = _newsletters
bp, _handlers = create_data_blueprint("account")
return bp

View File

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

View File

@@ -1,36 +0,0 @@
"""Account app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``account/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("account", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "account", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

9
account/queries.sx Normal file
View File

@@ -0,0 +1,9 @@
;; Account service — inter-service data queries
(defquery user-by-email (&key email)
"Return user_id for a given email address."
(service "account" "user-by-email" :email email))
(defquery newsletters ()
"Return all Ghost newsletters."
(service "account" "newsletters"))

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the account app.
"""Register services for the account app."""
from shared.services.registry import services
from .account_page import AccountPageService
services.register("account_page", AccountPageService())
Account is a consumer-only dashboard app. It has no own domain.
All cross-app data comes via fragments and HTTP data endpoints.
"""
pass
from shared.services.account_impl import SqlAccountDataService
services.register("account", SqlAccountDataService())

View File

@@ -0,0 +1,40 @@
"""Account page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class AccountPageService:
"""Service for account page data, callable via (service "account-page" ...)."""
async def newsletters_data(self, session, **kw):
"""Return newsletter list with user subscription status."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await session.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await session.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
from shared.infrastructure.urls import account_url
return {
"newsletter_list": newsletter_list,
"account_url": account_url(""),
}

View File

@@ -27,3 +27,25 @@
(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.")))
;; Assembled auth page content — replaces Python _login_page_content etc.
(defcomp ~account-login-content (&key error email)
(~auth-login-form
:error (when error (~auth-error-banner :error error))
:action (url-for "auth.start_login")
:csrf-token (csrf-token)
:email (or email "")))
(defcomp ~account-device-content (&key error code)
(~account-device-form
:error (when error (~account-device-error :error error))
:action (url-for "auth.device_submit")
:csrf-token (csrf-token)
:code (or code "")))
(defcomp ~account-check-email-content (&key email email-error)
(~auth-check-email
:email (escape (or email ""))
:error (when email-error
(~auth-check-email-error :error (escape email-error)))))

View File

@@ -41,3 +41,20 @@
name)
logout)
labels)))
;; Assembled dashboard content — replaces Python _account_main_panel_sx
(defcomp ~account-dashboard-content (&key error)
(let* ((user (current-user))
(csrf (csrf-token)))
(~account-main-panel
:error (when error (~account-error-banner :error error))
:email (when (get user "email")
(~account-user-email :email (get user "email")))
:name (when (get user "name")
(~account-user-name :name (get user "name")))
:logout (~account-logout-form :csrf-token csrf)
:labels (when (not (empty? (or (get user "labels") (list))))
(~account-labels-section
:items (map (lambda (label)
(~account-label-item :name (get label "name")))
(get user "labels")))))))

View File

@@ -1,4 +1,5 @@
;; Account auth-menu fragment handler
;; returns: sx
;;
;; Renders the desktop + mobile auth menu (sign-in or user link).

20
account/sx/layouts.sx Normal file
View File

@@ -0,0 +1,20 @@
;; Account layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout("account", ...) in __init__.py.
;; Full page: root header + auth header row in header-child
(defcomp ~account-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (~auth-header-row-auto))))
;; OOB (HTMX): auth row + root header, both with oob=true
(defcomp ~account-layout-oob ()
(<> (~auth-header-row-auto true)
(~root-header-auto true)))
;; Mobile menu: auth section + root nav
(defcomp ~account-layout-mobile ()
(<> (~mobile-menu-section
:label "account" :href "/" :level 1 :colour "sky"
:items (~auth-nav-items-auto))
(~root-mobile-auto)))

View File

@@ -29,3 +29,34 @@
(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")
list)))
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
;; Takes pre-fetched newsletter-list from page helper
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
(let* ((csrf (csrf-token)))
(if (empty? newsletter-list)
(~account-newsletter-empty)
(~account-newsletters-panel
:list (~account-newsletter-list
:items (map (lambda (item)
(let* ((nl (get item "newsletter"))
(un (get item "un"))
(nid (get nl "id"))
(subscribed (get item "subscribed"))
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
(translate (if subscribed "translate-x-6" "translate-x-1"))
(checked (if subscribed "true" "false")))
(~account-newsletter-item
:name (get nl "name")
:desc (when (get nl "description")
(~account-newsletter-desc :description (get nl "description")))
:toggle (~account-newsletter-toggle
:id (str "nl-" nid)
:url toggle-url
:hdrs {:X-CSRFToken csrf}
:target (str "#nl-" nid)
:cls (str "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 (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
newsletter-list))))))

View File

@@ -1,339 +0,0 @@
"""
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.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="account")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_sx(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_sx(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_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_sx = sx_call("account-user-name", name=user.name)
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return sx_call(
"account-main-panel",
error=SxExpr(error_sx) if error_sx else None,
email=SxExpr(user_email_sx) if user_email_sx else None,
name=SxExpr(user_name_sx) if user_name_sx else None,
logout=SxExpr(logout_sx),
labels=SxExpr(labels_sx) if labels_sx else None,
)
# ---------------------------------------------------------------------------
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_sx(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 sx_call(
"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_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
cls="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",
checked="false",
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
)
def _newsletters_panel_sx(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_sx = sx_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
items.append(sx_call(
"account-newsletter-item",
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_sx = sx_call(
"account-newsletter-list",
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_sx = sx_call("account-newsletter-empty")
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
# 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_sx = sx_call("auth-error-banner", error=error) if error else ""
return sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
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_sx = sx_call("account-device-error", error=error) if error else ""
return sx_call(
"account-device-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return sx_call("account-device-approved")
# ---------------------------------------------------------------------------
# Public API: Account dashboard
# ---------------------------------------------------------------------------
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_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_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_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_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_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_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
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_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_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(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_sx(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

@@ -1,13 +1,10 @@
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Account defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_account_pages() -> None:
"""Register account-specific layouts, page helpers, and load page definitions."""
"""Register account-specific layouts and load page definitions."""
_register_account_layouts()
_register_account_helpers()
_load_account_page_files()
@@ -17,89 +14,6 @@ def _load_account_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "account")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_account_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _auth_header_sx
root_hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
return "(<> " + root_hdr + " " + hdr_child + ")"
def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _auth_header_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
from sx.sx_components import _auth_nav_mobile_sx
ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section",
label="account", href="/", level=1, colour="sky",
items=SxExpr(_auth_nav_mobile_sx(ctx)))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
def _inject_account_nav(ctx: dict) -> dict:
"""Ensure account_nav is in ctx from g.account_nav."""
if "account_nav" not in ctx:
from quart import g
ctx = dict(ctx)
ctx["account_nav"] = getattr(g, "account_nav", "")
return ctx
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"account-content": _h_account_content,
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
})
def _h_account_content():
from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({})
def _h_newsletters_content():
from quart import g
d = getattr(g, "newsletters_data", None)
if not d:
from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
return ""
from sx.sx_components import _fragment_content
return _fragment_content(frag)
from shared.sx.layouts import register_sx_layout
register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile")

View File

@@ -8,7 +8,7 @@
:path "/"
:auth :login
:layout :account
:content (account-content))
:content (~account-dashboard-content))
;; ---------------------------------------------------------------------------
;; Newsletters
@@ -18,7 +18,10 @@
:path "/newsletters/"
:auth :login
:layout :account
:content (newsletters-content))
:data (service "account-page" "newsletters-data")
:content (~account-newsletters-content
:newsletter-list newsletter-list
:account-url account-url))
;; ---------------------------------------------------------------------------
;; Fragment pages (tickets, bookings, etc. from events service)
@@ -28,4 +31,10 @@
:path "/<slug>/"
:auth :login
:layout :account
:content (fragment-content))
:content (let* ((user (current-user))
(result (frag "events" "account-page"
:slug slug
:user-id (str (get user "id")))))
(if (or (nil? result) (empty? result))
(abort 404)
result)))

12
blog/actions.sx Normal file
View File

@@ -0,0 +1,12 @@
;; Blog service — inter-service action endpoints
(defaction update-page-config (&key container-type container-id
features sumup-merchant-code
sumup-checkout-prefix sumup-api-key)
"Create or update a PageConfig with features and SumUp settings."
(service "page-config" "update"
:container-type container-type :container-id container-id
:features features
:sumup-merchant-code sumup-merchant-code
:sumup-checkout-prefix sumup-checkout-prefix
:sumup-api-key sumup-api-key))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -16,7 +15,6 @@ from bp import (
register_admin,
register_menu_items,
register_snippets,
register_fragments,
register_data,
register_actions,
)
@@ -108,7 +106,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "blog")
app.register_blueprint(register_data())
app.register_blueprint(register_actions())
@@ -162,6 +162,23 @@ def create_app() -> "Quart":
)
return jsonify(resp)
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "blog")
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_blog_data():
import os
from shared.config import config as get_config
ctx = {
"blog_title": get_config()["blog_title"],
"base_title": get_config()["title"],
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
ctx.update(getattr(g, '_defpage_ctx', {}))
return ctx
# --- debug: url rules ---
@app.get("/__rules")
async def dump_rules():

View File

@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data
from .actions.routes import register as register_actions

View File

@@ -1,96 +1,14 @@
"""Blog app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers via the internal action client.
All actions are defined in ``blog/actions.sx``.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint
from shared.infrastructure.actions import ACTION_HEADER
from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
result = await handler()
return jsonify(result or {"ok": True})
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
bp, _handlers = create_action_blueprint("blog")
return bp

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings",
}
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_settings_home" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
g.settings_content = _settings_main_panel_sx(tctx)
elif "defpage_cache_page" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
g.cache_content = _cache_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/")
@require_admin
async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html)
return redirect(url_for("settings.defpage_cache_page"))
return redirect(url_for("defpage_cache_page"))
return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
@@ -13,9 +11,7 @@ from quart import (
from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_tag_groups_page" in ep:
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
elif "defpage_tag_group_edit" in ep:
tag_id = (request.view_args or {}).get("id")
tg = await g.s.get(TagGroup, tag_id)
if not tg:
from quart import abort
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
@bp.post("/<int:id>/")
@require_admin
async def save(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
form = await request.form
name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
return redirect(url_for("defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
@@ -163,6 +112,6 @@ def register():
await g.s.delete(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
return bp

View File

@@ -21,7 +21,7 @@ from .services.pages_data import pages_data
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response
from shared.sx.helpers import sx_response, sx_call
from shared.utils import host_url
def register(url_prefix, title):
@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request
async def route():
g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor
async def inject_root():
@@ -72,6 +62,19 @@ def register(url_prefix, title):
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
async def _render_new_post_page(tctx):
"""Compose a full page with blog header for new post/page creation."""
from shared.sx.helpers import root_header_sx, full_page_sx
from shared.sx.parser import SxExpr
root_hdr = await root_header_sx(tctx)
blog_hdr = sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr("(div)"),
child_id="blog-header-child")
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
content = tctx.get("editor_html", "")
return await full_page_sx(tctx, header_rows=header_rows, content=content)
SORT_MAP = {
"newest": "published_at DESC",
"oldest": "published_at ASC",
@@ -128,100 +131,83 @@ def register(url_prefix, title):
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
from shared.sx.page import get_template_context
from sx.sx_components import render_home_page, render_home_oob
from shared.sx.helpers import (
sx_call, root_header_sx, full_page_sx, oob_page_sx,
post_header_sx, oob_header_sx, mobile_menu_sx,
post_mobile_nav_sx, mobile_root_nav_sx,
)
from shared.sx.parser import SxExpr
from shared.services.registry import services
tctx = await get_template_context()
tctx.update(ctx)
post = ctx.get("post", {})
content = sx_call("blog-home-main",
html_content=post.get("html", ""),
sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None)
meta_data = services.blog_page.post_meta_data(post, ctx.get("base_title", ""))
meta = sx_call("blog-meta", **meta_data)
if not is_htmx_request():
html = await render_home_page(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
return await make_response(html)
else:
sx_src = await render_home_oob(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content)
return sx_response(sx_src)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
async def index():
"""Blog listing — moved from / to /index."""
q = decode()
content_type = request.args.get("type", "posts")
if content_type == "pages":
data = await pages_data(g.s, q.page, q.search)
context = {
**data,
"content_type": "pages",
"search": q.search,
"selected_tags": (),
"selected_authors": (),
"selected_groups": (),
"sort": None,
"view": None,
"drafts": None,
"draft_count": 0,
"tags": [],
"authors": [],
"tag_groups": [],
"posts": data.get("pages", []),
}
from shared.sx.page import get_template_context
from sx.sx_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_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
sx_src = await render_blog_page_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
show_drafts = bool(q.drafts and g.user)
is_admin = bool((g.get("rights") or {}).get("admin"))
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
# For the draft count badge: admin sees all drafts, non-admin sees own
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
data = await posts_data(
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
from shared.services.registry import services
from shared.sx.helpers import (
sx_call, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx,
)
from shared.sx.parser import SxExpr
context = {
**data,
"content_type": "posts",
"selected_tags": q.selected_tags,
"selected_authors": q.selected_authors,
"selected_groups": q.selected_groups,
"sort": q.sort,
"search": q.search,
"view": q.view,
"drafts": q.drafts if show_drafts else None,
}
def _blog_hdr(ctx, oob=False):
return sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr("(div)"),
child_id="blog-header-child", oob=oob)
data = await services.blog_page.index_data(g.s)
# Render content, aside, and filter via .sx defcomps
content = sx_call("blog-index-main-content", **data)
aside = sx_call("blog-index-aside-content", **data)
filter_sx = sx_call("blog-index-filter-content", **data)
from shared.sx.page import get_template_context
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
tctx = await get_template_context()
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
root_hdr = await root_header_sx(tctx)
blog_hdr = _blog_hdr(tctx)
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
html = await full_page_sx(tctx, header_rows=header_rows,
content=content, aside=aside, filter=filter_sx)
return await make_response(html)
elif q.page > 1:
# Sx wire format — client renders blog cards
sx_src = await render_blog_cards(tctx)
return sx_response(sx_src)
elif data.get("page", 1) > 1:
# Pagination — return just the cards
return sx_response(content)
else:
sx_src = await render_blog_oob(tctx)
root_hdr = await root_header_sx(tctx)
blog_hdr = _blog_hdr(tctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content,
aside=aside, filter=filter_sx)
return sx_response(sx_src)
@blogs_bp.post("/new/")
@@ -243,19 +229,19 @@ def register(url_prefix, title):
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
from sxc.pages.renders import 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)
html = await _render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
from sxc.pages.renders import render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
html = await _render_new_post_page(tctx)
return await make_response(html, 400)
# Create directly in db_blog
@@ -277,7 +263,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/")
@@ -299,21 +285,21 @@ def register(url_prefix, title):
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
from sxc.pages.renders import 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)
html = await _render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
from sxc.pages.renders import 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)
html = await _render_new_post_page(tctx)
return await make_response(html, 400)
# Create directly in db_blog
@@ -335,7 +321,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug)))
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/")

View File

@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict."""
result = {}
for m in _CARD_MARKER_RE.finditer(html):
for m in _CARD_MARKER_RE.finditer(str(html)):
post_id_str = m.group(1)
inner = m.group(2).strip()
if inner:

View File

@@ -1,185 +1,14 @@
"""Blog app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
All queries are defined in ``blog/queries.sx``.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from services import blog_service
from shared.infrastructure.query_blueprint import create_data_blueprint
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-slug"] = _post_by_slug
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await blog_service.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-id"] = _post_by_id
# --- posts-by-ids ---
async def _posts_by_ids():
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await blog_service.get_posts_by_ids(g.s, ids)
return [dto_to_dict(p) for p in posts]
_handlers["posts-by-ids"] = _posts_by_ids
# --- search-posts ---
async def _search_posts():
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
_handlers["search-posts"] = _search_posts
# --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
from shared.models.page_config import PageConfig
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
bp, _handlers = create_data_blueprint("blog")
return bp
def _page_config_dict(pc) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}

View File

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

View File

@@ -1,36 +0,0 @@
"""Blog app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``blog/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("blog", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "blog", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from quart import Blueprint, make_response, request, jsonify, g
from quart import Blueprint, make_response, request, jsonify, g, url_for
from shared.browser.app.authz import require_admin
from .services.menu_items import (
@@ -12,37 +12,217 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from markupsafe import escape
from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr
from shared.browser.app.csrf import generate_csrf_token
def _render_menu_items_list(menu_items):
"""Serialize ORM menu items and render via .sx defcomp."""
csrf = generate_csrf_token()
items = []
for item in menu_items:
items.append({
"feature_image": getattr(item, "feature_image", None),
"label": getattr(item, "label", "") or "",
"url": getattr(item, "url", "") or "",
"sort_order": getattr(item, "position", 0) or 0,
"edit_url": url_for("menu_items.edit_menu_item", item_id=item.id),
"delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id),
})
new_url = url_for("menu_items.new_menu_item")
return sx_call("blog-menu-items-content",
menu_items=items, new_url=new_url, csrf=csrf)
def _render_menu_item_form(menu_item=None) -> str:
"""Render menu item add/edit form."""
csrf = generate_csrf_token()
search_url = url_for("menu_items.search_pages_route")
is_edit = menu_item is not None
if is_edit:
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
action_attr = f'sx-put="{action_url}"'
post_id = str(menu_item.container_id) if menu_item.container_id else ""
label = getattr(menu_item, "label", "") or ""
slug = getattr(menu_item, "slug", "") or ""
fi = getattr(menu_item, "feature_image", None) or ""
else:
action_url = url_for("menu_items.create_menu_item_route")
action_attr = f'sx-post="{action_url}"'
post_id = ""
label = ""
slug = ""
fi = ""
if post_id:
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
else:
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
title = "Edit Menu Item" if is_edit else "Add Menu Item"
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">{title}</h2>
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
<i class="fa fa-times"></i></button>
</div>
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
{selected}
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
sx-include="#selected-post-id"
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{csrf}">
<div class="flex gap-2 pb-3 border-b">
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-save"></i> Save</button>
<button type="button" onclick="{close_js}"
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
</div>
</form>
<div class="mt-4">
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
<input type="text" placeholder="Search for a page... (or leave blank for all)"
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
sx-target="#page-search-results" sx-swap="innerHTML"
name="q" id="page-search-input"
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div id="page-search-results" class="mt-2"></div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {{
var pageOption = e.target.closest('[data-page-id]');
if (pageOption) {{
var postId = pageOption.dataset.pageId;
var postTitle = pageOption.dataset.pageTitle;
var postSlug = pageOption.dataset.pageSlug;
var postImage = pageOption.dataset.pageImage;
document.getElementById('selected-post-id').value = postId;
var display = document.getElementById('selected-page-display');
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
display.classList.remove('hidden');
document.getElementById('page-search-results').innerHTML = '';
}}
}});
</script>'''
return html
def _render_page_search_results(pages, query, page, has_more) -> str:
"""Render page search results."""
if not pages and query:
return sx_call("page-search-empty", query=query)
if not pages:
return ""
items = []
for post in pages:
items.append(sx_call("page-search-item",
id=post.id, title=post.title,
slug=post.slug,
feature_image=post.feature_image or None))
sentinel = ""
if has_more:
search_url = url_for("menu_items.search_pages_route")
sentinel = sx_call("page-search-sentinel",
url=search_url, query=query,
next_page=page + 1)
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("page-search-results",
items=SxExpr(items_sx),
sentinel=sentinel or None)
def _render_menu_items_nav_oob(menu_items) -> str:
"""Render OOB nav update for menu items."""
from quart import request as qrequest
if not menu_items:
return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_button_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3"
)
container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}"
scroll_hs = (
f"on load or scroll"
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
)
item_parts = []
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
href = f"/{item_slug}/"
selected = "true" if item_slug == first_seg else "false"
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img=img_sx, label=label,
))
else:
item_parts.append(sx_call("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img=img_sx, label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
arrow_cls=arrow_cls,
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items):
"""Helper to generate OOB update for root nav menu items"""
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
g.menu_items_content = _menu_items_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["menu-items-page"])
return _render_menu_items_nav_oob(menu_items)
@bp.get("/new/")
@require_admin
async def new_menu_item():
"""Show form to create new menu item"""
from sx.sx_components import render_menu_item_form
return sx_response(render_menu_item_form())
return sx_response(_render_menu_item_form())
@bp.post("/")
@require_admin
@@ -65,8 +245,7 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
html = _render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob)
@@ -81,8 +260,7 @@ def register():
if not menu_item:
return await make_response("Menu item not found", 404)
from sx.sx_components import render_menu_item_form
return sx_response(render_menu_item_form(menu_item=menu_item))
return sx_response(_render_menu_item_form(menu_item=menu_item))
@bp.put("/<int:item_id>/")
@require_admin
@@ -105,8 +283,7 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
html = _render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob)
@@ -126,8 +303,7 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
html = _render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob)
@@ -142,8 +318,7 @@ def register():
pages, total = await search_pages(g.s, query, page, per_page)
has_more = (page * per_page) < total
from sx.sx_components import render_page_search_results
return sx_response(render_page_search_results(pages, query, page, has_more))
return sx_response(_render_page_search_results(pages, query, page, has_more))
@bp.post("/reorder/")
@require_admin
@@ -167,8 +342,7 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
html = _render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob)

View File

@@ -10,10 +10,18 @@ from quart import (
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from markupsafe import escape
from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr, serialize as sx_serialize
from shared.utils import host_url
def _raw_html_sx(html: str) -> str:
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
if not html:
return ""
return "(raw! " + sx_serialize(html) + ")"
def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect.
@@ -52,158 +60,225 @@ def _post_to_edit_dict(post) -> dict:
return d
def _render_features(features, post, result):
"""Render features panel via .sx defcomp."""
slug = post.get("slug", "")
return sx_call("blog-features-panel-content",
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
calendar_checked=bool(features.get("calendar")),
market_checked=bool(features.get("market")),
show_sumup=bool(features.get("calendar") or features.get("market")),
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
merchant_code=result.get("sumup_merchant_code") or "",
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
sumup_configured=result.get("sumup_configured", False),
checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
def _serialize_markets(markets, slug):
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
result = []
for m in markets:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
result.append({
"name": m_name, "slug": m_slug,
"delete_url": host_url(url_for("blog.post.admin.delete_market",
slug=slug, market_slug=m_slug)),
})
return result
def _render_calendar_view(
calendar, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
"""Build calendar month grid HTML."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
esc = escape
csrf = generate_csrf_token()
cal_id = calendar.id
def cal_url(y, m):
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
cur_url = cal_url(year, month)
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
nav = (
f'<header class="flex items-center justify-center mb-4">'
f'<nav class="flex items-center gap-2 text-xl">'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&laquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&lsaquo;</a>'
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&rsaquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&raquo;</a>'
f'</nav></header>'
)
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
cells: list[str] = []
for week in weeks:
for day in week:
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
entry_btns: list[str] = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id)
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
if e_id in associated_entry_ids:
entry_btns.append(
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
f'<span class="truncate flex-1">{e_name}</span>'
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="Remove {e_name} from this post?"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><i class="fa fa-times"></i></button></div>'
)
else:
entry_btns.append(
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
f' data-confirm data-confirm-title="Add entry?"'
f' data-confirm-text="Add {e_name} to this post?"'
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><span class="truncate block">{e_name}</span></button>'
)
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
cells.append(
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
)
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
html = (
f'<div id="calendar-view-{cal_id}"'
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
f'{nav}'
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
f'</div>'
)
return _raw_html_sx(html)
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel."""
from shared.browser.app.csrf import generate_csrf_token
from sxc.pages.helpers import _extract_associated_entries_data
csrf = generate_csrf_token()
entry_data = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug)
return sx_call("blog-associated-entries-from-data",
entries=entry_data, csrf=csrf)
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
"""Render the OOB nav entries swap."""
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
has_items = bool(entries_list or calendars)
if not has_items:
return sx_call("blog-nav-entries-empty")
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-2"
)
post_slug = post.get("slug", "")
scroll_hs = (
"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"
)
item_parts = []
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
entry_path = (
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
date_str = e_start.strftime("%b %d, %Y at %H:%M")
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/{cal_slug}/"
date_str = ""
item_parts.append(sx_call("calendar-entry-nav",
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
))
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/{cal_slug}/"
item_parts.append(sx_call("blog-nav-calendar-item",
href=cal_path, nav_cls=nav_cls, name=cal_name,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
arrow_cls="entries-nav-arrow",
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_post_admin" in ep:
from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
g.post_admin_content = _post_admin_main_panel_sx(tctx)
elif "defpage_post_data" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
g.post_data_content = _post_data_content_sx(tctx)
elif "defpage_post_preview" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
g.post_preview_content = _preview_main_panel_sx(tctx)
elif "defpage_post_entries" in ep:
from sqlalchemy import select
from shared.models.calendars import Calendar
from ..services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
g.post_entries_content = _post_entries_content_sx(tctx)
elif "defpage_post_settings" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
g.post_settings_content = _post_settings_content_sx(tctx)
elif "defpage_post_edit" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
g.post_edit_content = _post_edit_content_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=[
"post-admin", "post-data", "post-preview",
"post-entries", "post-settings", "post-edit",
])
@bp.put("/features/")
@require_admin
async def update_features(slug: str):
@@ -238,14 +313,7 @@ def register():
})
features = result.get("features", {})
from sx.sx_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 "",
)
html = _render_features(features, post, result)
return sx_response(html)
@bp.put("/admin/sumup/")
@@ -278,13 +346,7 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {})
from sx.sx_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 "",
)
html = _render_features(features, post, result)
return sx_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/")
@@ -353,8 +415,7 @@ def register():
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
from sx.sx_components import render_calendar_view
html = render_calendar_view(
html = _render_calendar_view(
calendar_obj, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
@@ -406,11 +467,9 @@ def register():
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
from sx.sx_components import render_associated_entries, render_nav_entries_oob
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)
admin_list = _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = _render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html)
@@ -468,7 +527,7 @@ def register():
except OptimisticLockError:
from urllib.parse import quote
return redirect(
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
host_url(url_for("defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -479,7 +538,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/")
@require_post_author
@@ -504,11 +563,11 @@ def register():
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc)
if not ok:
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +603,7 @@ def register():
)
except OptimisticLockError:
return redirect(
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
host_url(url_for("defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -560,7 +619,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
@@ -585,8 +644,11 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.post("/markets/new/")
@require_admin
@@ -611,8 +673,11 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -631,7 +696,10 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
return bp

View File

@@ -105,27 +105,64 @@ def register():
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
from shared.sx.page import get_template_context
from sx.sx_components import render_post_page, render_post_oob
from shared.sx.helpers import (
sx_call, root_header_sx, full_page_sx, oob_page_sx,
post_header_sx, oob_header_sx, mobile_menu_sx,
post_mobile_nav_sx, mobile_root_nav_sx,
)
from shared.services.registry import services
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import host_url
tctx = await get_template_context()
# Render post content via .sx defcomp
post = tctx.get("post") or {}
user = getattr(g, "user", None)
rights = tctx.get("rights") or {}
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
csrf = generate_csrf_token()
svc = services.blog_page
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
content = sx_call("blog-post-detail-content", **detail_data)
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
meta = sx_call("blog-meta", **meta_data)
if not is_htmx_request():
html = await render_post_page(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
return await make_response(html)
else:
sx_src = await render_post_oob(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
return sx_response(sx_src)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from shared.utils import host_url
from sx.sx_components import render_like_toggle_button
from shared.sx.helpers import sx_call
from shared.browser.app.csrf import generate_csrf_token
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
csrf = generate_csrf_token()
def _like_btn(liked):
return sx_call("blog-like-toggle",
like_url=like_url,
hx_headers={"X-CSRFToken": csrf},
heart="\u2764\ufe0f" if liked else "\U0001f90d")
# Get post_id from g.post_data
if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
return sx_response(_like_btn(False), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -133,9 +170,8 @@ def register():
result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "post", "target_id": post_id,
})
liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url))
return sx_response(_like_btn(result["liked"]))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -1,53 +1,25 @@
from __future__ import annotations
from quart import Blueprint, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from quart import Blueprint, request, g, abort
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.sx.helpers import sx_response, sx_call
from models import Snippet
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
async def _visible_snippets(session):
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return rows
async def _render_snippets():
"""Render snippets list via service data + .sx defcomp."""
from shared.services.registry import services
data = await services.blog_page.snippets_data(g.s)
return sx_call("blog-snippets-content", **data)
def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
g.snippets_content = _snippets_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["snippets-page"])
@bp.delete("/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
@@ -63,9 +35,7 @@ def register():
await g.s.delete(snippet)
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
return sx_response(await _render_snippets())
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -87,8 +57,6 @@ def register():
snippet.visibility = visibility
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return sx_response(await _render_snippets())
return bp

40
blog/queries.sx Normal file
View File

@@ -0,0 +1,40 @@
;; Blog service — inter-service data queries
(defquery post-by-slug (&key slug)
"Fetch a single blog post by its URL slug."
(service "blog" "get-post-by-slug" :slug slug))
(defquery post-by-id (&key id)
"Fetch a single blog post by its primary key."
(service "blog" "get-post-by-id" :id id))
(defquery posts-by-ids (&key ids)
"Fetch multiple blog posts by comma-separated IDs."
(service "blog" "get-posts-by-ids"
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))
(defquery search-posts (&key query page per-page)
"Search blog posts by text query, paginated."
(let ((result (service "blog" "search-posts"
:query query :page page :per-page per-page)))
{"posts" (nth result 0) "total" (nth result 1)}))
(defquery page-config-ensure (&key container-type container-id)
"Get or create a PageConfig for a container."
(service "page-config" "ensure"
:container-type container-type :container-id container-id))
(defquery page-config (&key container-type container-id)
"Return a single PageConfig by container type + id."
(service "page-config" "get-by-container"
:container-type container-type :container-id container-id))
(defquery page-config-by-id (&key id)
"Return a single PageConfig by primary key."
(service "page-config" "get-by-id" :id id))
(defquery page-configs-batch (&key container-type ids)
"Return PageConfigs for multiple container IDs (comma-separated)."
(service "page-config" "get-batch"
:container-type container-type
:ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ",")))))

View File

@@ -71,8 +71,16 @@ def register_domain_services() -> None:
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
# Federation needed for AP shared infrastructure (activitypub blueprint)
from shared.services.registry import services
services.register("blog", blog_service)
from shared.services.page_config_impl import SqlPageConfigService
services.register("page_config", SqlPageConfigService())
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()
from .blog_page import BlogPageService
services.register("blog_page", BlogPageService())

472
blog/services/blog_page.py Normal file
View File

@@ -0,0 +1,472 @@
"""Blog page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from shared.sx.parser import SxExpr
def _sx_content_expr(raw: str) -> SxExpr | None:
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
return SxExpr(raw) if raw else None
class BlogPageService:
"""Service for blog page data, callable via (service "blog-page" ...)."""
async def cache_data(self, session, **kw):
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
return {
"clear_url": qurl("settings.cache_clear"),
"csrf": generate_csrf_token(),
}
async def snippets_data(self, session, **kw):
from quart import g, url_for as qurl
from sqlalchemy import select, or_
from models import Snippet
from shared.browser.app.csrf import generate_csrf_token
uid = g.user.id
is_admin = g.rights.get("admin")
csrf = generate_csrf_token()
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
snippets = []
for s in rows:
s_id = s.id
s_vis = s.visibility or "private"
s_uid = s.user_id
owner = "You" if s_uid == uid else f"User #{s_uid}"
can_delete = s_uid == uid or is_admin
d = {
"id": s_id,
"name": s.name or "",
"visibility": s_vis,
"owner": owner,
"can_delete": can_delete,
}
if is_admin:
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
if can_delete:
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
snippets.append(d)
return {
"snippets": snippets,
"is_admin": bool(is_admin),
"csrf": csrf,
}
async def menu_items_data(self, session, **kw):
from quart import url_for as qurl
from bp.menu_items.services.menu_items import get_all_menu_items
from shared.browser.app.csrf import generate_csrf_token
menu_items = await get_all_menu_items(session)
csrf = generate_csrf_token()
items = []
for mi in menu_items:
i_id = mi.id
label = mi.label or ""
fi = getattr(mi, "feature_image", None)
sort = mi.position or 0
items.append({
"id": i_id,
"label": label,
"url": mi.url or "",
"sort_order": sort,
"feature_image": fi,
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
})
return {
"menu_items": items,
"new_url": qurl("menu_items.new_menu_item"),
"csrf": csrf,
}
async def tag_groups_data(self, session, **kw):
from quart import url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
from shared.browser.app.csrf import generate_csrf_token
groups_rows = list(
(await session.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(session)
groups = []
for g in groups_rows:
groups.append({
"id": g.id,
"name": g.name or "",
"slug": getattr(g, "slug", "") or "",
"feature_image": getattr(g, "feature_image", None),
"colour": getattr(g, "colour", None),
"sort_order": getattr(g, "sort_order", 0) or 0,
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
})
unassigned_tags = []
for t in unassigned:
unassigned_tags.append({
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
})
return {
"groups": groups,
"unassigned_tags": unassigned_tags,
"create_url": qurl("blog.tag_groups_admin.create"),
"csrf": generate_csrf_token(),
}
async def tag_group_edit_data(self, session, *, id=None, **kw):
from quart import abort, url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
from shared.browser.app.csrf import generate_csrf_token
tg = await session.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await session.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_set = set(assigned_rows)
all_tags_rows = list(
(await session.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
all_tags = []
for t in all_tags_rows:
all_tags.append({
"id": t.id,
"name": getattr(t, "name", "") or "",
"feature_image": getattr(t, "feature_image", None),
"checked": t.id in assigned_set,
})
return {
"group": {
"id": tg.id,
"name": tg.name or "",
"colour": getattr(tg, "colour", "") or "",
"sort_order": getattr(tg, "sort_order", 0) or 0,
"feature_image": getattr(tg, "feature_image", "") or "",
},
"all_tags": all_tags,
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
"csrf": generate_csrf_token(),
}
async def index_data(self, session, **kw):
"""Blog index page data — posts or pages listing with filters."""
from quart import g, request, url_for as qurl
from bp.blog.services.posts_data import posts_data
from bp.blog.services.pages_data import pages_data
from bp.blog.filters.qs import decode
from shared.utils import host_url
from shared.browser.app.csrf import generate_csrf_token
q = decode()
content_type = request.args.get("type", "posts")
is_admin = bool((g.get("rights") or {}).get("admin"))
user = getattr(g, "user", None)
csrf = generate_csrf_token()
blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/")
if content_type == "pages":
data = await pages_data(session, q.page, q.search)
posts_list = data.get("pages", [])
tag_groups_raw = []
authors_raw = []
draft_count = 0
selected_tags = ()
selected_authors = ()
selected_groups = ()
else:
show_drafts = bool(q.drafts and user)
drafts_user_id = None if (not show_drafts or is_admin) else user.id
count_drafts_uid = None if (user and is_admin) else (user.id if user else False)
data = await posts_data(
session, q.page, q.search, q.sort, q.selected_tags,
q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
)
posts_list = data.get("posts", [])
tag_groups_raw = data.get("tag_groups", [])
authors_raw = data.get("authors", [])
draft_count = data.get("draft_count", 0)
selected_tags = q.selected_tags
selected_authors = q.selected_authors
selected_groups = q.selected_groups
page_num = data.get("page", q.page)
total_pages = data.get("total_pages", 1)
card_widgets = data.get("card_widgets_html", {})
current_local_href = f"{blog_url_base}/index"
if content_type == "pages":
current_local_href += "?type=pages"
hx_select = "#main-panel"
# Serialize posts for cards
def _format_ts(dt):
if not dt:
return ""
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
cards = []
for p in posts_list:
slug = p.get("slug", "")
href = f"{blog_url_base}/{slug}/"
status = p.get("status", "published")
is_draft = status == "draft"
ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at"))
tags = []
for t in (p.get("tags") or []):
name = t.get("name") or getattr(t, "name", "")
fi = t.get("feature_image") or getattr(t, "feature_image", None)
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
authors = []
for a in (p.get("authors") or []):
name = a.get("name") or getattr(a, "name", "")
img = a.get("profile_image") or getattr(a, "profile_image", None)
authors.append({"name": name, "image": img or ""})
card = {
"slug": slug, "href": href, "hx_select": hx_select,
"title": p.get("title", ""), "feature_image": p.get("feature_image"),
"excerpt": p.get("custom_excerpt") or p.get("excerpt", ""),
"is_draft": is_draft,
"publish_requested": p.get("publish_requested", False) if is_draft else False,
"status_timestamp": ts,
"tags": tags, "authors": authors,
"has_like": bool(user),
}
if user:
card["liked"] = p.get("is_liked", False)
card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/"
card["csrf_token"] = csrf
widget = card_widgets.get(str(p.get("id", "")), "")
if widget:
card["widget"] = widget
# Page-specific fields
features = p.get("features") or {}
if content_type == "pages":
card["has_calendar"] = features.get("calendar", False)
card["has_market"] = features.get("market", False)
card["pub_timestamp"] = ts
cards.append(card)
# Serialize tag groups for filter
tag_groups = []
for grp in tag_groups_raw:
g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "")
g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "")
g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None)
g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None)
g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0)
if g_count <= 0 and g_slug not in selected_groups:
continue
tag_groups.append({
"slug": g_slug, "name": g_name, "feature_image": g_fi,
"colour": g_colour, "post_count": g_count,
"is_selected": g_slug in selected_groups,
})
# Serialize authors for filter
authors_list = []
for a in authors_raw:
a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "")
a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "")
a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None)
a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0)
authors_list.append({
"slug": a_slug, "name": a_name, "profile_image": a_img,
"published_post_count": a_count,
"is_selected": a_slug in selected_authors,
})
# Filter summary names
tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]]
au_summary_names = [a["name"] for a in authors_list if a["is_selected"]]
return {
"content_type": content_type,
"view": q.view,
"cards": cards,
"page": page_num,
"total_pages": total_pages,
"current_local_href": current_local_href,
"hx_select": hx_select,
"is_admin": is_admin,
"has_user": bool(user),
"draft_count": draft_count,
"drafts": bool(q.drafts) if user else False,
"new_post_href": f"{blog_url_base}/new/",
"new_page_href": f"{blog_url_base}/new-page/",
"tag_groups": tag_groups,
"authors": authors_list,
"is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0,
"is_any_author": len(selected_authors) == 0,
"tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "",
"au_summary": ", ".join(au_summary_names) if au_summary_names else "",
"blog_url_base": blog_url_base,
"csrf": csrf,
}
async def post_admin_data(self, session, *, slug=None, **kw):
"""Post admin panel — just needs post loaded into context."""
from quart import g
from sqlalchemy import select
from shared.models.page_config import PageConfig
# _ensure_post_data is called by before_request in defpage context
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
if post.get("is_page"):
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
return {
"features": features,
"sumup_configured": sumup_configured,
}
def post_meta_data(self, post, base_title):
"""Compute SEO meta tag values from post dict."""
import re
from quart import request as req
is_public = post.get("visibility") == "public"
is_published = post.get("status") == "published"
email_only = post.get("email_only", False)
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
desc = (post.get("meta_description") or post.get("og_description") or
post.get("twitter_description") or post.get("custom_excerpt") or
post.get("excerpt") or "")
if not desc and post.get("html"):
desc = re.sub(r'<[^>]+>', '', post["html"])
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
canonical = post.get("canonical_url") or (req.url if req else "")
post_title = post.get("meta_title") or post.get("title") or ""
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
og_title = post.get("og_title") or page_title
tw_title = post.get("twitter_title") or page_title
is_article = not post.get("is_page")
return {
"robots": robots, "page_title": page_title, "desc": desc,
"canonical": canonical,
"og_type": "article" if is_article else "website",
"og_title": og_title, "image": image,
"twitter_card": "summary_large_image" if image else "summary",
"twitter_title": tw_title,
}
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
"""Serialize post detail view data for ~blog-post-detail-content defcomp."""
slug = post.get("slug", "")
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
user_id = getattr(user, "id", None) if user else None
# Tags and authors
tags = []
for t in (post.get("tags") or []):
name = t.get("name") or getattr(t, "name", "")
fi = t.get("feature_image") or getattr(t, "feature_image", None)
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
authors = []
for a in (post.get("authors") or []):
name = a.get("name") or getattr(a, "name", "")
img = a.get("profile_image") or getattr(a, "profile_image", None)
authors.append({"name": name, "image": img or ""})
return {
"slug": slug,
"is_draft": post.get("status") == "draft",
"publish_requested": post.get("publish_requested", False),
"can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id),
"edit_href": f"{blog_url_base}/{slug}/admin/edit/",
"is_page": bool(post.get("is_page")),
"has_user": bool(user),
"liked": post.get("is_liked", False),
"like_url": f"{blog_url_base}/{slug}/like/toggle/",
"csrf": csrf,
"custom_excerpt": post.get("custom_excerpt") or "",
"tags": tags,
"authors": authors,
"feature_image": post.get("feature_image"),
"html_content": post.get("html", ""),
"sx_content": _sx_content_expr(post.get("sx_content", "")),
}
async def preview_data(self, session, *, slug=None, **kw):
"""Build preview data with prettified/rendered content."""
from quart import g
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await session.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
result = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
result["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
result["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
result["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
result["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
result["lex_rendered"] = "<em>Error rendering lexical</em>"
return result

View File

@@ -143,6 +143,80 @@
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
;; Data-driven snippets list (replaces Python _snippets_sx loop)
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~blog-snippets-list
:rows (<> (map (lambda (s)
(let* ((s-id (get s "id"))
(s-name (get s "name"))
(s-uid (get s "user_id"))
(s-vis (get s "visibility"))
(owner (if (= s-uid user-id) "You" (str "User #" s-uid)))
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
(extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
(when (or (= s-uid user-id) is-admin)
(~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" s-name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
(~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls
:visibility s-vis :extra extra)))
(or snippets (list))))))
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
(defcomp ~blog-menu-items-from-data (&key items csrf)
(~blog-menu-items-list
:rows (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
(~blog-menu-item-row
:img img :label (get item "label") :slug (get item "slug")
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
:delete-url (get item "delete_url")
:confirm-text (str "Remove " (get item "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))))
(or items (list))))))
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~blog-tag-groups-list
:items (<> (map (lambda (g)
(let* ((icon (if (get g "feature_image")
(~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
groups))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
:spans (<> (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
unassigned-tags))))))
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
(defcomp ~blog-tag-checkboxes-from-data (&key tags)
(<> (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "tag_id") :checked (get t "checked")
:img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or tags (list)))))
;; Preview panel components
(defcomp ~blog-preview-panel (&key sections)
@@ -169,3 +243,351 @@
(details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content)))
(defcomp ~blog-preview-rendered (&key html)
(div :class "blog-content prose max-w-none" (raw! html)))
(defcomp ~blog-preview-empty ()
(div :class "p-8 text-stone-500" "No content to preview."))
(defcomp ~blog-admin-placeholder ()
(div :class "pb-8"))
;; ---------------------------------------------------------------------------
;; Data-driven content defcomps (called from defpages with service data)
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
"shared" "bg-blue-100 text-blue-700"
"admin" "bg-amber-100 text-amber-700"))
(vis (or (get s "visibility") "private"))
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers {:X-CSRFToken csrf}
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?")
:sx-headers {:X-CSRFToken csrf}
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
:slug (get mi "url")
:sort-order (str (or (get mi "sort_order") 0))
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers {:X-CSRFToken csrf}))
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
:slug (or (get g "slug") "")
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
;; ---------------------------------------------------------------------------
;; Preview content composition — replaces _h_post_preview_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(let* ((sections (list)))
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
(~blog-preview-empty)
(~blog-preview-panel :sections
(<>
(when sx-pretty
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
(when json-pretty
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
(when sx-rendered
(~blog-preview-section :title "SX Rendered"
:content (~blog-preview-rendered :html sx-rendered)))
(when lex-rendered
(~blog-preview-section :title "Lexical Rendered"
:content (~blog-preview-rendered :html lex-rendered))))))))
;; ---------------------------------------------------------------------------
;; Data introspection composition — replaces _h_post_data_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-data-value-cell (&key value value-type)
(if (= value-type "nil")
(span :class "text-neutral-400" "\u2014")
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(if (or (= value-type "date") (= value-type "other"))
(code value)
value))))
(defcomp ~blog-data-scalar-table (&key columns)
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
(th :class "px-3 py-2 text-left font-medium" "Value")))
(tbody
(map (lambda (col)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
(td :class "px-3 py-2 align-top"
(~blog-data-value-cell :value (get col "value") :value-type (get col "type")))))
(or columns (list)))))))
(defcomp ~blog-data-relationship-item (&key index summary children)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
(td :class "px-2 py-1 align-top"
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(code summary))
(when children
(div :class "mt-2 pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get children "columns")
:relationships (get children "relationships")))))))
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value)
(div :class "rounded-xl border border-neutral-200"
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
"Relationship: " (span :class "font-semibold" name)
(span :class "ml-2 text-xs text-neutral-500"
cardinality " \u2192 " class-name
(when (not loaded) " \u2022 " (em "not loaded"))))
(div :class "p-3 text-sm"
(if (not value)
(span :class "text-neutral-400" "\u2014")
(if (get value "is_list")
(<>
(div :class "text-neutral-500 mb-2"
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
(when (get value "items")
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-2 py-1 text-left w-10" "#")
(th :class "px-2 py-1 text-left" "Summary")))
(tbody
(map (lambda (item)
(~blog-data-relationship-item
:index (get item "index")
:summary (get item "summary")
:children (get item "children")))
(get value "items")))))))
;; Single value
(<>
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
(code (get value "summary")))
(when (get value "children")
(div :class "pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get (get value "children") "columns")
:relationships (get (get value "children") "relationships"))))))))))
(defcomp ~blog-data-model-content (&key columns relationships)
(div :class "space-y-4"
(~blog-data-scalar-table :columns columns)
(when (not (empty? (or relationships (list))))
(div :class "space-y-3"
(map (lambda (rel)
(~blog-data-relationship
:name (get rel "name")
:cardinality (get rel "cardinality")
:class-name (get rel "class_name")
:loaded (get rel "loaded")
:value (get rel "value")))
relationships)))))
(defcomp ~blog-data-table-content (&key tablename model-data)
(if (not model-data)
(div :class "px-4 py-8 text-stone-400" "No post data available.")
(div :class "px-4 py-8"
(div :class "mb-6 text-sm text-neutral-500"
"Model: " (code "Post") " \u2022 Table: " (code tablename))
(~blog-data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))
;; ---------------------------------------------------------------------------
;; Calendar month view for browsing/toggling entries (B1)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cal-entry-associated (&key name toggle-url csrf)
(div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
(span :class "truncate flex-1" name)
(button :type "button" :class "flex-shrink-0 hover:text-red-600"
:data-confirm "" :data-confirm-title "Remove entry?"
:data-confirm-text (str "Remove " name " from this post?")
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-post toggle-url :sx-trigger "confirmed"
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(i :class "fa fa-times"))))
(defcomp ~blog-cal-entry-unassociated (&key name toggle-url csrf)
(button :type "button"
:class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
:data-confirm "" :data-confirm-title "Add entry?"
:data-confirm-text (str "Add " name " to this post?")
:data-confirm-icon "question" :data-confirm-confirm-text "Yes, add it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-post toggle-url :sx-trigger "confirmed"
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(span :class "truncate block" name)))
(defcomp ~blog-calendar-view (&key cal-id year month-name
current-url prev-month-url prev-year-url
next-month-url next-year-url
weekday-names days csrf)
(let* ((target (str "#calendar-view-" cal-id)))
(div :id (str "calendar-view-" cal-id)
:sx-get current-url :sx-trigger "entryToggled from:body" :sx-swap "outerHTML"
(header :class "flex items-center justify-center mb-4"
(nav :class "flex items-center gap-2 text-xl"
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get prev-year-url :sx-target target :sx-swap "outerHTML"
(raw! "&laquo;"))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get prev-month-url :sx-target target :sx-swap "outerHTML"
(raw! "&lsaquo;"))
(div :class "px-3 font-medium" (str month-name " " year))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get next-month-url :sx-target target :sx-swap "outerHTML"
(raw! "&rsaquo;"))
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
:sx-get next-year-url :sx-target target :sx-swap "outerHTML"
(raw! "&raquo;"))))
(div :class "rounded border bg-white"
(div :class "hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b"
(map (lambda (wd) (div :class "py-2" wd)) (or weekday-names (list))))
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200"
(map (lambda (day)
(let* ((extra-cls (if (get day "in_month") "" " bg-stone-50 text-stone-400"))
(entries (or (get day "entries") (list))))
(div :class (str "min-h-20 bg-white px-2 py-2 text-xs" extra-cls)
(div :class "font-medium mb-1" (str (get day "day")))
(when (not (empty? entries))
(div :class "space-y-0.5"
(map (lambda (e)
(if (get e "is_associated")
(~blog-cal-entry-associated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
(~blog-cal-entry-unassociated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
entries))))))
(or days (list))))))))
;; ---------------------------------------------------------------------------
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
;; ---------------------------------------------------------------------------
(defcomp ~blog-nav-entries-oob (&key entries calendars)
(let* ((entry-list (or entries (list)))
(cal-list (or calendars (list)))
(has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
(nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
(scroll-hs "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"))
(if (not has-items)
(~blog-nav-entries-empty)
(~scroll-nav-wrapper
:wrapper-id "entries-calendars-nav-wrapper"
:container-id "associated-items-container"
:arrow-cls "entries-nav-arrow"
:left-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
:scroll-hs scroll-hs
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
:items (<>
(map (lambda (e)
(~calendar-entry-nav
:href (get e "href") :nav-class nav-cls
:name (get e "name") :date-str (get e "date_str")))
entry-list)
(map (lambda (c)
(~blog-nav-calendar-item
:href (get c "href") :nav-cls nav-cls
:name (get c "name")))
cal-list))
:oob true))))

View File

@@ -2,8 +2,7 @@
(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 :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
@@ -56,7 +55,7 @@
(when has-like
(~blog-like-button
:like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
:hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
@@ -107,6 +106,43 @@
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
(defcomp ~blog-cards-from-data (&key posts view sentinel)
(<>
(map (lambda (p)
(if (= view "tile")
(~blog-card-tile
:href (get p "href") :hx-select (get p "hx_select")
:feature-image (get p "feature_image") :title (get p "title")
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
(~blog-card
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
:title (get p "title") :feature-image (get p "feature_image")
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
:publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:has-like (get p "has_like") :liked (get p "liked")
:like-url (get p "like_url") :csrf-token (get p "csrf_token")
:tags (get p "tags") :authors (get p "authors")
:widget (when (get p "widget") (~rich-text :html (get p "widget"))))))
(or posts (list)))
sentinel))
;; Data-driven page cards list (replaces Python _page_cards_sx loop)
(defcomp ~page-cards-from-data (&key pages sentinel)
(<>
(map (lambda (pg)
(~blog-page-card
:href (get pg "href") :hx-select (get pg "hx_select")
:title (get pg "title")
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
:pub-timestamp (get pg "pub_timestamp")
:feature-image (get pg "feature_image") :excerpt (get pg "excerpt")))
(or pages (list)))
sentinel))
(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"

View File

@@ -12,10 +12,13 @@
(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"))
edit))
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart))
(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 :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt))
@@ -36,6 +39,37 @@
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
(div :class "pb-8")))
;; ---------------------------------------------------------------------------
;; Data-driven composition — replaces _post_main_panel_sx
;; ---------------------------------------------------------------------------
(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href
is-page has-user liked like-url csrf
custom-excerpt tags authors
feature-image html-content sx-content)
(let* ((hx-select "#main-panel")
(draft-sx (when is-draft
(~blog-detail-draft
:publish-requested publish-requested
:edit (when can-edit
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
(chrome-sx (when (not is-page)
(~blog-detail-chrome
:like (when has-user
(~blog-detail-like
:like-url like-url
:hx-headers {:X-CSRFToken csrf}
:heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors)))))
(~blog-detail-main
:draft draft-sx
:chrome chrome-sx
:feature-image feature-image
:html-content html-content
:sx-content sx-content)))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
(<>
(meta :name "robots" :content robots)

View File

@@ -55,6 +55,104 @@
(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))))
;; Edit form — pre-populated version for /<slug>/admin/edit/
(defcomp ~blog-editor-edit-form (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json
has-sx title-placeholder
status already-emailed
newsletter-options footer-extra)
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value updated-at)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
(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 (str "relative " (if feature-image "" "hidden"))
(img :id "feature-image-preview" :src (or feature-image "") :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 (or feature-image-caption "")
: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 (or title-val "") :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"
(or excerpt-val ""))
;; Editor tabs
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
(button :type "button" :id "editor-tab-sx"
:class (if has-sx active inactive)
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"SX Editor")
(button :type "button" :id "editor-tab-koenig"
:class (if has-sx inactive active)
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"Koenig (Legacy)"))
(div :id "sx-editor" :class "relative w-full bg-transparent"
:style (if has-sx "" "display:none"))
(div :id "lexical-editor" :class "relative w-full bg-transparent"
:style (if has-sx "display:none" ""))
;; Initial lexical JSON
(script :id "lexical-initial-data" :type "application/json" lexical-json)
;; Footer: status + publish mode + newsletter + save + badges
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :id "status-select" :name "status" :class sel-cls
(option :value "draft" :selected (= status "draft") "Draft")
(option :value "published" :selected (= status "published") "Published"))
(select :id "publish-mode-select" :name "publish_mode"
:class (str sel-cls (if (= status "published") "" " hidden")
(if already-emailed " opacity-50 pointer-events-none" ""))
:disabled (if already-emailed true nil)
(option :value "web" :selected true "Web only")
(option :value "email" "Email only")
(option :value "both" "Web + Email"))
(select :id "newsletter-select" :name "newsletter_slug"
:class (str sel-cls " hidden")
:disabled (if already-emailed true nil)
newsletter-options)
(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"
"Save")
(when footer-extra footer-extra)))))
;; Publish-mode show/hide script for edit form
(defcomp ~blog-editor-publish-js (&key already-emailed)
(script
"(function() {"
" var statusSel = document.getElementById('status-select');"
" var modeSel = document.getElementById('publish-mode-select');"
" var nlSel = document.getElementById('newsletter-select');"
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
" function sync() {"
" var isPublished = statusSel.value === 'published';"
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
" }"
" statusSel.addEventListener('change', sync);"
" modeSel.addEventListener('change', sync);"
" sync();"
"})();"))
(defcomp ~blog-editor-styles (&key css-href)
(<> (link :rel "stylesheet" :href css-href)
(style
@@ -205,3 +303,48 @@
;; Drag over editor
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
;; ---------------------------------------------------------------------------
;; Editor panel composition — replaces render_editor_panel (new post/page)
;; ---------------------------------------------------------------------------
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
:create-label create-label)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))
;; ---------------------------------------------------------------------------
;; Edit content composition — replaces _h_post_edit_content (existing post)
;; ---------------------------------------------------------------------------
(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json has-sx
title-placeholder status already-emailed
newsletter-options footer-extra
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-edit-form
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options newsletter-options :footer-extra footer-extra)
(~blog-editor-publish-js :already-emailed already-emailed)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))

View File

@@ -63,3 +63,39 @@
(defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" text))
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select)
(let* ((is-any (empty? (or selected-groups (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-topic :cls any-cls :hx-select hx-select)
(map (lambda (g)
(let* ((slug (get g "slug"))
(name (get g "name"))
(is-on (contains? selected-groups slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (if (get g "feature_image")
(~blog-filter-group-icon-image :src (get g "feature_image") :name name)
(~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
:icon icon :name name :count (get g "count"))))
(or groups (list)))))))
;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select)
(let* ((is-any (empty? (or selected-authors (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-author :cls any-cls :hx-select hx-select)
(map (lambda (a)
(let* ((slug (get a "slug"))
(is-on (contains? selected-authors slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (when (get a "profile_image")
(~blog-filter-author-icon :src (get a "profile_image") :name (get a "name")))))
(~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
:icon icon :name (get a "name") :count (get a "count"))))
(or authors (list)))))))

View File

@@ -1,4 +1,5 @@
;; Blog link-card fragment handler
;; returns: sx
;;
;; Renders link-card(s) for blog posts by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).

View File

@@ -1,4 +1,5 @@
;; Blog nav-tree fragment handler
;; returns: sx
;;
;; Renders the full scrollable navigation menu bar with app icons.
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.

View File

@@ -30,3 +30,224 @@
tag-groups-filter
authors-filter)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
;; ---------------------------------------------------------------------------
;; Data-driven composition defcomps — replace Python sx_components functions
;; ---------------------------------------------------------------------------
;; Helper: CSS class for filter item based on selection state
(defcomp ~blog-filter-cls (&key is-on)
;; Returns nothing — use inline (if is-on ...) instead
nil)
;; Blog index main content — replaces _blog_main_panel_sx
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages
current-local-href hx-select blog-url-base)
(let* ((posts-href (str blog-url-base "/index"))
(pages-href (str posts-href "?type=pages"))
(posts-cls (if (not (= content-type "pages"))
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))
(pages-cls (if (= content-type "pages")
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
(if (= content-type "pages")
;; Pages listing
(~blog-main-panel-pages
:tabs (~blog-content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:cards (<>
(map (lambda (card)
(~blog-page-card
:href (get card "href") :hx-select hx-select
:title (get card "title")
:has-calendar (get card "has_calendar")
:has-market (get card "has_market")
:pub-timestamp (get card "pub_timestamp")
:feature-image (get card "feature_image")
:excerpt (get card "excerpt")))
(or cards (list)))
(if (< page total-pages)
(~sentinel-simple
:id (str "sentinel-" page "-d")
:next-url (str current-local-href
(if (contains? current-local-href "?") "&" "?")
"page=" (+ page 1)))
(if (not (empty? (or cards (list))))
(~end-of-results)
(~blog-no-pages)))))
;; Posts listing
(let* ((grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
"max-w-full px-3 py-3 space-y-3"))
(list-href current-local-href)
(tile-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "view=tile"))
(list-cls (if (not (= view "tile"))
"bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600"))
(tile-cls (if (= view "tile")
"bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600")))
(~blog-main-panel-posts
:tabs (~blog-content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:toggle (~view-toggle
:list-href list-href :tile-href tile-href :hx-select hx-select
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
:list-svg (~list-svg) :tile-svg (~tile-svg))
:grid-cls grid-cls
:cards (<>
(map (lambda (card)
(if (= view "tile")
(~blog-card-tile
:href (get card "href") :hx-select hx-select
:feature-image (get card "feature_image")
:title (get card "title") :is-draft (get card "is_draft")
:publish-requested (get card "publish_requested")
:status-timestamp (get card "status_timestamp")
:excerpt (get card "excerpt")
:tags (get card "tags") :authors (get card "authors"))
(~blog-card
:slug (get card "slug") :href (get card "href") :hx-select hx-select
:title (get card "title") :feature-image (get card "feature_image")
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
:publish-requested (get card "publish_requested")
:status-timestamp (get card "status_timestamp")
:has-like (get card "has_like") :liked (get card "liked")
:like-url (get card "like_url") :csrf-token (get card "csrf_token")
:tags (get card "tags") :authors (get card "authors")
:widget (get card "widget"))))
(or cards (list)))
(~blog-index-sentinel
:page page :total-pages total-pages
:current-local-href current-local-href)))))))
;; Sentinel for blog index infinite scroll
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
(when (< page total-pages)
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
(~sentinel-desktop
:id (str "sentinel-" page "-d")
:next-url next-url
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
;; Blog index action buttons — replaces _action_buttons_sx
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href)
(~blog-action-buttons-wrapper
:inner (<>
(when is-admin
(<>
(~blog-action-button
:href new-post-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
(~blog-action-button
:href new-page-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
(when (and has-user (or draft-count drafts))
(if drafts
(~blog-drafts-button
:href current-local-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
(let* ((on-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
(~blog-drafts-button-amber
:href on-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
;; Tag groups filter — replaces _tag_groups_filter_sx
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
(~blog-filter-nav
:items (<>
(~blog-filter-any-topic
:cls (if is-any-group
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
:hx-select hx-select)
(map (lambda (grp)
(let* ((is-on (get grp "is_selected"))
(cls (if is-on
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(fi (get grp "feature_image"))
(colour (get grp "colour"))
(name (get grp "name"))
(icon (if fi
(~blog-filter-group-icon-image :src fi :name name)
(~blog-filter-group-icon-color
:style (if colour
(str "background-color: " colour "; color: white;")
"background-color: #e7e5e4; color: #57534e;")
:initial (slice (or name "?") 0 1)))))
(~blog-filter-group-li
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
:hx-select hx-select :icon icon
:name name :count (str (get grp "post_count")))))
(or tag-groups (list))))))
;; Authors filter — replaces _authors_filter_sx
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
(~blog-filter-nav
:items (<>
(~blog-filter-any-author
:cls (if is-any-author
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
:hx-select hx-select)
(map (lambda (a)
(let* ((is-on (get a "is_selected"))
(cls (if is-on
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(img (get a "profile_image")))
(~blog-filter-author-li
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
:hx-select hx-select
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
:name (get a "name")
:count (str (get a "published_post_count")))))
(or authors (list))))))
;; Blog index aside — replaces _blog_aside_sx
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author)
(~blog-aside
:search (~search-desktop)
:action-buttons (~blog-index-actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:tag-groups-filter (~blog-index-tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
:authors-filter (~blog-index-authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select)))
;; Blog index mobile filter — replaces _blog_filter_sx
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author
tg-summary au-summary)
(~mobile-filter
:filter-summary (<>
(~search-mobile)
(when (not (= tg-summary ""))
(~blog-filter-summary :text tg-summary))
(when (not (= au-summary ""))
(~blog-filter-summary :text au-summary)))
:action-buttons (~blog-index-actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:filter-details (<>
(~blog-index-tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
(~blog-index-authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select))))

185
blog/sx/layouts.sx Normal file
View File

@@ -0,0 +1,185 @@
;; Blog layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; --- Blog header (invisible row for blog-header-child swap target) ---
(defcomp ~blog-header (&key oob)
(~menu-row-sx :id "blog-row" :level 1
:link-label-content (div)
:child-id "blog-header-child" :oob oob))
;; --- Auto-fetching settings header macro ---
(defmacro ~blog-settings-header-auto (oob)
(quasiquote
(~menu-row-sx :id "root-settings-row" :level 1
:link-href (url-for "settings.defpage_settings_home")
:link-label-content (~blog-admin-label)
:nav (~blog-settings-nav)
:child-id "root-settings-header-child"
:oob (unquote oob))))
;; --- Auto-fetching sub-settings header macro ---
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
(quasiquote
(~menu-row-sx :id (unquote row-id) :level 2
:link-href (url-for (unquote endpoint))
:link-label-content (~blog-sub-settings-label
:icon (str "fa fa-" (unquote icon))
:label (unquote label))
:child-id (unquote child-id)
:oob (unquote oob))))
;; ---------------------------------------------------------------------------
;; Blog layout (root + blog header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-layout-full ()
(<> (~root-header-auto)
(~blog-header)))
(defcomp ~blog-layout-oob ()
(<> (~blog-header :oob true)
(~clear-oob-div :id "blog-header-child")
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Settings layout (root + settings header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)))
(defcomp ~blog-settings-layout-oob ()
(<> (~blog-settings-header-auto true)
(~clear-oob-div :id "root-settings-header-child")
(~root-header-auto true)))
(defcomp ~blog-settings-layout-mobile ()
(~blog-settings-nav))
;; ---------------------------------------------------------------------------
;; Cache layout (root + settings + cache sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cache-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache")))
(defcomp ~blog-cache-layout-oob ()
(<> (~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache" true)
(~clear-oob-div :id "cache-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Snippets layout (root + settings + snippets sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-snippets-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
(defcomp ~blog-snippets-layout-oob ()
(<> (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
(~clear-oob-div :id "snippets-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Menu Items layout (root + settings + menu-items sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-menu-items-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
(defcomp ~blog-menu-items-layout-oob ()
(<> (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
(~clear-oob-div :id "menu_items-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Groups layout (root + settings + tag-groups sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-groups-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
(defcomp ~blog-tag-groups-layout-oob ()
(<> (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-group-edit-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child")))
(defcomp ~blog-tag-group-edit-layout-oob ()
(<> (~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child"
:oob true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; --- Settings nav links — uses IO primitives ---
(defcomp ~blog-settings-nav ()
(let* ((sc (select-colours))
(links (list
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
(dict :endpoint "snippets.defpage_snippets_page" :icon "fa fa-puzzle-piece" :label "Snippets")
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
(<> (map (lambda (lnk)
(~nav-link
:href (url-for (get lnk "endpoint"))
:icon (get lnk "icon")
:label (get lnk "label")
:select-colours (or sc "")))
links))))
;; --- Editor panel wrapper ---
(defcomp ~blog-editor-panel (&key parts)
(<> parts))

View File

@@ -24,3 +24,37 @@
(defcomp ~page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\"")))
;; Data-driven page search results (replaces Python render_page_search_results loop)
(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page)
(if (and (not pages) query)
(~page-search-empty :query query)
(when pages
(~page-search-results
:items (<> (map (lambda (p)
(~page-search-item
:id (get p "id") :title (get p "title")
:slug (get p "slug") :feature-image (get p "feature_image")))
pages))
:sentinel (when has-more
(~page-search-sentinel :url search-url :query query :next-page next-page))))))
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(if (not items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
:arrow-cls arrow-cls
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
:scroll-hs scroll-hs
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
:items (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
(if (= (get item "slug") "cart")
(~blog-nav-item-plain :href (get item "href") :selected (get item "selected")
:nav-cls nav-cls :img img :label (get item "label"))
(~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
items))
:oob true)))

View File

@@ -2,7 +2,7 @@
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :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"
@@ -54,6 +54,43 @@
(button :type "submit"
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
;; ---------------------------------------------------------------------------
;; Data-driven composition defcomps — replace Python render_* functions
;; ---------------------------------------------------------------------------
;; Features panel composition — replaces render_features_panel
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
show-sumup sumup-url merchant-code placeholder
sumup-configured checkout-prefix)
(~blog-features-panel
:form (~blog-features-form
:features-url features-url
:calendar-checked calendar-checked
:market-checked market-checked
:hs-trigger "on change trigger submit on closest <form/>")
:sumup (when show-sumup
(~blog-sumup-form
:sumup-url sumup-url
:merchant-code merchant-code
:placeholder placeholder
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix))))
;; Markets panel composition — replaces render_markets_panel
(defcomp ~blog-markets-panel-content (&key markets create-url)
(~blog-markets-panel
:list (if (empty? (or markets (list)))
(~blog-markets-empty)
(~blog-markets-list
:items (map (lambda (m)
(~blog-market-item
:name (get m "name")
:slug (get m "slug")
:delete-url (get m "delete_url")
:confirm-text (str "Delete market '" (get m "name") "'?")))
(or markets (list)))))
:create-url create-url))
;; Associated entries
(defcomp ~blog-entry-image (&key src title)
@@ -89,3 +126,167 @@
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
content))
;; ---------------------------------------------------------------------------
;; Associated entries composition — replaces _render_associated_entries
;; ---------------------------------------------------------------------------
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
(~blog-associated-entries-panel
:content (if (empty? (or entries (list)))
(~blog-associated-entries-empty)
(~blog-associated-entries-content
:items (map (lambda (e)
(~blog-associated-entry
:confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url")
:hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name")
:date-str (get e "date_str")))
(or entries (list)))))))
;; ---------------------------------------------------------------------------
;; Entries browser composition — replaces _h_post_entries_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-calendar-browser-item (&key name title image view-url)
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
(if image
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
(div :class "flex-1"
(div :class "font-semibold flex items-center gap-2"
(i :class "fa fa-calendar text-stone-500") " " name)
(div :class "text-sm text-stone-600" title)))
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
(div :class "text-sm text-stone-400" "Loading calendar..."))))
(defcomp ~blog-entries-browser-content (&key entries-panel calendars)
(div :id "post-entries-content" :class "space-y-6 p-4"
entries-panel
(div :class "space-y-3"
(h3 :class "text-lg font-semibold" "Browse Calendars")
(if (empty? (or calendars (list)))
(div :class "text-sm text-stone-400" "No calendars found.")
(map (lambda (cal)
(~blog-calendar-browser-item
:name (get cal "name")
:title (get cal "title")
:image (get cal "image")
:view-url (get cal "view_url")))
(or calendars (list)))))))
;; ---------------------------------------------------------------------------
;; Post settings form composition — replaces _h_post_settings_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-field-label (&key text field-for)
(label :for field-for
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
(defcomp ~blog-settings-section (&key title content is-open)
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
title)
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success
slug published-at featured visibility email-only
tags feature-image-alt
meta-title meta-description canonical-url
og-title og-description og-image
twitter-title twitter-description twitter-image
custom-template)
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
(textarea-cls (str input-cls " resize-y"))
(slug-placeholder (if is-page "page-slug" "post-slug"))
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
(featured-label (if is-page "Featured page" "Featured post")))
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
(div :class "space-y-[12px] mt-[16px]"
;; General
(~blog-settings-section :title "General" :is-open true :content
(<>
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug")
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
:placeholder slug-placeholder :class input-cls))
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at")
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
:value (or published-at "") :class input-cls))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" featured-label)))
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility")
(select :name "visibility" :id "settings-visibility" :class input-cls
(option :value "public" :selected (= visibility "public") "Public")
(option :value "members" :selected (= visibility "members") "Members")
(option :value "paid" :selected (= visibility "paid") "Paid")))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" "Email only")))))
;; Tags
(~blog-settings-section :title "Tags" :content
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
:placeholder "news, updates, featured" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
;; Feature Image
(~blog-settings-section :title "Feature Image" :content
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
;; SEO / Meta
(~blog-settings-section :title "SEO / Meta" :content
(<>
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title")
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
:placeholder "SEO title" :maxlength "300" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description")
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
:placeholder "SEO description" :maxlength "500" :class textarea-cls
(or meta-description ""))
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url")
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
;; Facebook / OpenGraph
(~blog-settings-section :title "Facebook / OpenGraph" :content
(<>
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title")
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description")
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
(or og-description "")))
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image")
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
:placeholder "https://..." :class input-cls))))
;; X / Twitter
(~blog-settings-section :title "X / Twitter" :content
(<>
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title")
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
:value (or twitter-title "") :class input-cls))
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description")
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
(or twitter-description "")))
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
;; Advanced
(~blog-settings-section :title "Advanced" :content
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
(input :type "text" :name "custom_template" :id "settings-custom_template"
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"
(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"
"Save settings")
(when save-success
(span :class "text-[14px] text-green-600" "Saved."))))))

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_blog_pages() -> None:
"""Register blog-specific layouts, page helpers, and load page definitions."""
from .layouts import _register_blog_layouts
from .helpers import _register_blog_helpers
_register_blog_layouts()
_register_blog_helpers()
_load_blog_page_files()
@@ -14,265 +14,7 @@ def setup_blog_pages() -> None:
def _load_blog_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
from shared.sx.jinja_bridge import load_service_components
blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
load_service_components(blog_dir, service_name="blog")
load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_custom_layout
# :blog — root + blog header (for new-post, new-page)
register_custom_layout("blog", _blog_full, _blog_oob)
# :blog-settings — root + settings header (with settings nav menu)
register_custom_layout("blog-settings", _settings_full, _settings_oob,
mobile_fn=_settings_mobile)
# Sub-settings layouts (root + settings + sub header)
register_custom_layout("blog-cache", _cache_full, _cache_oob)
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
register_custom_layout("blog-tag-group-edit",
_tag_group_edit_full, _tag_group_edit_oob)
# --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --- Cache ---
def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items ---
def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions)
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("blog", {
"editor-content": _h_editor_content,
"editor-page-content": _h_editor_page_content,
"post-admin-content": _h_post_admin_content,
"post-data-content": _h_post_data_content,
"post-preview-content": _h_post_preview_content,
"post-entries-content": _h_post_entries_content,
"post-settings-content": _h_post_settings_content,
"post-edit-content": _h_post_edit_content,
"settings-content": _h_settings_content,
"cache-content": _h_cache_content,
"snippets-content": _h_snippets_content,
"menu-items-content": _h_menu_items_content,
"tag-groups-content": _h_tag_groups_content,
"tag-group-edit-content": _h_tag_group_edit_content,
})
def _h_editor_content():
from quart import g
return getattr(g, "editor_content", "")
def _h_editor_page_content():
from quart import g
return getattr(g, "editor_page_content", "")
def _h_post_admin_content():
from quart import g
return getattr(g, "post_admin_content", "")
def _h_post_data_content():
from quart import g
return getattr(g, "post_data_content", "")
def _h_post_preview_content():
from quart import g
return getattr(g, "post_preview_content", "")
def _h_post_entries_content():
from quart import g
return getattr(g, "post_entries_content", "")
def _h_post_settings_content():
from quart import g
return getattr(g, "post_settings_content", "")
def _h_post_edit_content():
from quart import g
return getattr(g, "post_edit_content", "")
def _h_settings_content():
from quart import g
return getattr(g, "settings_content", "")
def _h_cache_content():
from quart import g
return getattr(g, "cache_content", "")
def _h_snippets_content():
from quart import g
return getattr(g, "snippets_content", "")
def _h_menu_items_content():
from quart import g
return getattr(g, "menu_items_content", "")
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

View File

@@ -1,5 +1,6 @@
; Blog app defpage declarations
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
; All helpers return data dicts — markup composition in SX.
; --- New post/page editors ---
@@ -7,92 +8,147 @@
:path "/new/"
:auth :admin
:layout :blog
:content (editor-content))
:data (editor-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
(defpage new-page
:path "/new-page/"
:auth :admin
:layout :blog
:content (editor-page-content))
:data (editor-page-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
; --- Post admin pages (nested under /<slug>/admin/) ---
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin
:path "/"
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "admin")
:content (post-admin-content))
:data (post-admin-data slug)
:content (~blog-admin-placeholder))
(defpage post-data
:path "/data/"
:path "/<slug>/admin/data/"
:auth :admin
:layout (:post-admin :selected "data")
:content (post-data-content))
:data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data))
(defpage post-preview
:path "/preview/"
:path "/<slug>/admin/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:content (post-preview-content))
:data (post-preview-data slug)
:content (~blog-preview-content
:sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered))
(defpage post-entries
:path "/entries/"
:path "/<slug>/admin/entries/"
:auth :admin
:layout (:post-admin :selected "entries")
:content (post-entries-content))
:data (post-entries-data slug)
:content (~blog-entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars))
(defpage post-settings
:path "/settings/"
:path "/<slug>/admin/settings/"
:auth :post_author
:layout (:post-admin :selected "settings")
:content (post-settings-content))
:data (post-settings-data slug)
:content (~blog-settings-form-content
:csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug
:published-at published-at :featured featured
:visibility visibility :email-only email-only
:tags tags :feature-image-alt feature-image-alt
:meta-title meta-title :meta-description meta-description
:canonical-url canonical-url :og-title og-title
:og-description og-description :og-image og-image
:twitter-title twitter-title :twitter-description twitter-description
:twitter-image twitter-image :custom-template custom-template))
(defpage post-edit
:path "/edit/"
:path "/<slug>/admin/edit/"
:auth :post_author
:layout (:post-admin :selected "edit")
:content (post-edit-content))
:data (post-edit-data slug)
:content (~blog-edit-content
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options (<>
(option :value "" "Select newsletter\u2026")
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
:footer-extra (when badges
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
:css-href css-href :js-src js-src
:sx-editor-js-src sx-editor-js-src
:init-js init-js :save-error save-error))
; --- Settings pages ---
; --- Settings pages (absolute paths) ---
(defpage settings-home
:path "/"
:path "/settings/"
:auth :admin
:layout :blog-settings
:content (settings-content))
:content (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page
:path "/cache/"
:path "/settings/cache/"
:auth :admin
:layout :blog-cache
:content (cache-content))
:data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets ---
(defpage snippets-page
:path "/"
:path "/settings/snippets/"
:auth :login
:layout :blog-snippets
:content (snippets-content))
:data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items ---
(defpage menu-items-page
:path "/"
:path "/settings/menu_items/"
:auth :admin
:layout :blog-menu-items
:content (menu-items-content))
:data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups ---
(defpage tag-groups-page
:path "/"
:path "/settings/tag-groups/"
:auth :admin
:layout :blog-tag-groups
:content (tag-groups-content))
:data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
(defpage tag-group-edit
:path "/<int:id>/"
:path "/settings/tag-groups/<int:id>/"
:auth :admin
:layout :blog-tag-group-edit
:content (tag-group-edit-content))
:data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))

703
blog/sxc/pages/helpers.py Normal file
View File

@@ -0,0 +1,703 @@
"""Blog page helpers — async functions available in .sx defpage expressions.
All helpers return data values (dicts, lists) — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Shared hydration helpers (kept for auth/g._defpage_ctx side effects)
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_post_data(slug: str | None) -> None:
"""Load post data and set g.post_data + defpage context.
Replicates post bp's hydrate_post_data + context_processor.
"""
from quart import g, abort
if hasattr(g, 'post_data') and g.post_data:
await _inject_post_context(g.post_data)
return
if not slug:
abort(404)
from bp.post.services.post_data import post_data
is_admin = bool((g.get("rights") or {}).get("admin"))
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
# Draft access control
if p_data["post"].get("status") != "published":
if is_admin:
pass
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass
else:
abort(404)
g.post_data = p_data
g.post_slug = slug
await _inject_post_context(p_data)
async def _inject_post_context(p_data: dict) -> None:
"""Add post context_processor data to defpage context."""
from shared.config import config
from shared.infrastructure.fragments import fetch_fragment
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
})
ctx: dict = {
**p_data,
"base_title": config()["title"],
"container_nav": container_nav,
}
if p_data["post"].get("is_page"):
ident = current_cart_identity()
summary_params: dict = {"page_slug": post_slug}
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,
)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
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
)
_add_to_defpage_ctx(**ctx)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("blog", {
"editor-data": _h_editor_data,
"editor-page-data": _h_editor_page_data,
"post-admin-data": _h_post_admin_data,
"post-data-data": _h_post_data_data,
"post-preview-data": _h_post_preview_data,
"post-entries-data": _h_post_entries_data,
"post-settings-data": _h_post_settings_data,
"post-edit-data": _h_post_edit_data,
})
# ---------------------------------------------------------------------------
# Editor helpers
# ---------------------------------------------------------------------------
def _editor_init_js(urls: dict, *, form_id: str = "post-edit-form",
has_initial_json: bool = True) -> str:
"""Build the editor initialization JavaScript string.
URLs dict must contain: upload_image, upload_media, upload_file, oembed,
snippets, unsplash_key.
"""
font_size_preamble = (
"(function() {"
" function applyEditorFontSize() {"
" document.documentElement.style.fontSize = '62.5%';"
" document.body.style.fontSize = '1.6rem';"
" }"
" function restoreDefaultFontSize() {"
" document.documentElement.style.fontSize = '';"
" document.body.style.fontSize = '';"
" }"
" applyEditorFontSize();"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
" if (e.detail.target && e.detail.target.id === 'main-panel') {"
" restoreDefaultFontSize();"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);"
" }"
" });"
)
upload_image = urls["upload_image"]
upload_media = urls["upload_media"]
upload_file = urls["upload_file"]
oembed = urls["oembed"]
unsplash_key = urls["unsplash_key"]
snippets = urls["snippets"]
init_body = (
" function init() {"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
f" var uploadUrl = '{upload_image}';"
" var uploadUrls = {"
" image: uploadUrl,"
f" media: '{upload_media}',"
f" file: '{upload_file}',"
" };"
" var fileInput = document.getElementById('feature-image-file');"
" var addBtn = document.getElementById('feature-image-add-btn');"
" var deleteBtn = document.getElementById('feature-image-delete-btn');"
" var preview = document.getElementById('feature-image-preview');"
" var emptyState = document.getElementById('feature-image-empty');"
" var filledState = document.getElementById('feature-image-filled');"
" var hiddenUrl = document.getElementById('feature-image-input');"
" var hiddenCaption = document.getElementById('feature-image-caption-input');"
" var captionInput = document.getElementById('feature-image-caption');"
" var uploading = document.getElementById('feature-image-uploading');"
" function showFilled(url) {"
" preview.src = url; hiddenUrl.value = url;"
" emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
" }"
" function showEmpty() {"
" preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
" emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
" }"
" function uploadFile(file) {"
" emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
" var fd = new FormData(); fd.append('file', file);"
" fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
" .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
" .then(function(data) {"
" var url = data.images && data.images[0] && data.images[0].url;"
" if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
" })"
" .catch(function(e) { showEmpty(); alert(e.message); });"
" }"
" addBtn.addEventListener('click', function() { fileInput.click(); });"
" preview.addEventListener('click', function() { fileInput.click(); });"
" deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
" fileInput.addEventListener('change', function() {"
" if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = ''; }"
" });"
" captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
" function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
" excerpt.addEventListener('input', autoResize); autoResize();"
)
if has_initial_json:
init_body += (
" var dataEl = document.getElementById('lexical-initial-data');"
" var initialJson = dataEl ? dataEl.textContent.trim() : null;"
" if (initialJson) { var hidden = document.getElementById('lexical-json-input'); if (hidden) hidden.value = initialJson; }"
)
initial_json_arg = "initialJson: initialJson,"
else:
initial_json_arg = "initialJson: null,"
init_body += (
" window.mountEditor('lexical-editor', {"
f" {initial_json_arg}"
" csrfToken: csrfToken,"
" uploadUrls: uploadUrls,"
f" oembedUrl: '{oembed}',"
f" unsplashApiKey: '{unsplash_key}',"
f" snippetsUrl: '{snippets}',"
" });"
" if (typeof SxEditor !== 'undefined') {"
" SxEditor.mount('sx-editor', {"
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
" csrfToken: csrfToken,"
" uploadUrls: uploadUrls,"
f" oembedUrl: '{oembed}',"
" onChange: function(sx) {"
" document.getElementById('sx-content-input').value = sx;"
" }"
" });"
" }"
" document.addEventListener('keydown', function(e) {"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
f" e.preventDefault(); document.getElementById('{form_id}').requestSubmit();"
" }"
" });"
" }"
" if (typeof window.mountEditor === 'function') { init(); }"
" else { var _t = setInterval(function() {"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
" }, 50); }"
"})();"
)
return font_size_preamble + init_body
def _editor_urls() -> dict:
"""Extract editor API URLs and asset paths."""
import os
from quart import url_for as qurl, current_app
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
return {
"upload_image": qurl("blog.editor_api.upload_image"),
"upload_media": qurl("blog.editor_api.upload_media"),
"upload_file": qurl("blog.editor_api.upload_file"),
"oembed": qurl("blog.editor_api.oembed_proxy"),
"snippets": qurl("blog.editor_api.list_snippets"),
"unsplash_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"css_href": asset_url_fn("scripts/editor.css"),
"js_src": asset_url_fn("scripts/editor.js"),
"sx_editor_js_src": asset_url_fn("scripts/sx-editor.js"),
}
def _h_editor_data(**kw) -> dict:
"""New post editor — return data for ~blog-editor-content."""
from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls()
csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return {
"csrf": csrf,
"title-placeholder": "Post title...",
"create-label": "Create Post",
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
}
def _h_editor_page_data(**kw) -> dict:
"""New page editor — return data for ~blog-editor-content."""
from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls()
csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return {
"csrf": csrf,
"title-placeholder": "Page title...",
"create-label": "Create Page",
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
}
# ---------------------------------------------------------------------------
# Post admin helpers
# ---------------------------------------------------------------------------
async def _h_post_admin_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
return {}
# ---------------------------------------------------------------------------
# Data introspection
# ---------------------------------------------------------------------------
def _extract_model_data(obj, depth=0, max_depth=2) -> dict:
"""Recursively extract ORM model data into a nested dict for .sx rendering."""
from markupsafe import escape as esc
# Scalar columns
columns = []
for col in obj.__mapper__.columns:
key = col.key
if key == "_sa_instance_state":
continue
val = getattr(obj, key, None)
if val is None:
columns.append({"key": str(key), "value": "", "type": "nil"})
elif hasattr(val, "isoformat"):
columns.append({"key": str(key), "value": str(esc(val.isoformat())), "type": "date"})
elif isinstance(val, str):
columns.append({"key": str(key), "value": str(esc(val)), "type": "str"})
else:
columns.append({"key": str(key), "value": str(esc(str(val))), "type": "other"})
# Relationships
relationships = []
for rel in obj.__mapper__.relationships:
rel_name = rel.key
loaded = rel_name in obj.__dict__
value = getattr(obj, rel_name, None) if loaded else None
cardinality = "many" if rel.uselist else "one"
cls_name = rel.mapper.class_.__name__
rel_data: dict[str, Any] = {
"name": rel_name,
"cardinality": cardinality,
"class_name": cls_name,
"loaded": loaded,
"value": None,
}
if value is None:
pass # value stays None
elif rel.uselist:
items_list = list(value) if value else []
val_data: dict[str, Any] = {"is_list": True, "count": len(items_list)}
if items_list and depth < max_depth:
items = []
for i, it in enumerate(items_list, 1):
summary = _obj_summary(it)
children = _extract_model_data(it, depth + 1, max_depth) if depth < max_depth else None
items.append({"index": i, "summary": summary, "children": children})
val_data["items"] = items
rel_data["value"] = val_data
else:
child = value
summary = _obj_summary(child)
children = _extract_model_data(child, depth + 1, max_depth) if depth < max_depth else None
rel_data["value"] = {"is_list": False, "summary": summary, "children": children}
relationships.append(rel_data)
return {"columns": columns, "relationships": relationships}
def _obj_summary(obj) -> str:
"""Build a summary string for an ORM object."""
from markupsafe import escape as esc
ident_parts = []
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
if k in obj.__mapper__.c:
v = getattr(obj, k, "")
ident_parts.append(f"{k}={v}")
return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj)))
async def _h_post_data_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
original_post = getattr(g, "post_data", {}).get("original_post")
if original_post is None:
return {"tablename": None, "model-data": None}
tablename = getattr(original_post, "__tablename__", "?")
model_data = _extract_model_data(original_post, 0, 2)
return {"tablename": tablename, "model-data": model_data}
# ---------------------------------------------------------------------------
# Preview content
# ---------------------------------------------------------------------------
async def _h_post_preview_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
from shared.services.registry import services
from shared.sx.helpers import SxExpr
preview = await services.blog_page.preview_data(g.s)
return {
"sx-pretty": SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None,
"json-pretty": SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None,
"sx-rendered": preview.get("sx_rendered") or None,
"lex-rendered": preview.get("lex_rendered") or None,
}
# ---------------------------------------------------------------------------
# Entries browser
# ---------------------------------------------------------------------------
def _extract_associated_entries_data(all_calendars, associated_entry_ids, post_slug: str) -> list:
"""Extract associated entry data for .sx rendering."""
from quart import url_for as qurl
from shared.utils import host_url
entries = []
for calendar in all_calendars:
cal_entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in cal_entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
slug=post_slug, entry_id=e_id))
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entries.append({
"name": e_name,
"confirm_text": f"This will remove {e_name} from this post",
"toggle_url": toggle_url,
"cal_image": cal_fi or "",
"cal_title": cal_title,
"date_str": f"{cal_name} \u2022 {date_str}",
})
return entries
def _extract_calendar_browser_data(all_calendars, post_slug: str) -> list:
"""Extract calendar browser data for .sx rendering."""
from quart import url_for as qurl
from shared.utils import host_url
calendars = []
for cal in all_calendars:
cal_post = getattr(cal, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
cal_name = getattr(cal, "name", "")
view_url = host_url(qurl("blog.post.admin.calendar_view",
slug=post_slug, calendar_id=cal.id))
calendars.append({
"name": cal_name,
"title": cal_title,
"image": cal_fi or "",
"view_url": view_url,
})
return calendars
async def _h_post_entries_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
from sqlalchemy import select
from shared.models.calendars import Calendar
from shared.browser.app.csrf import generate_csrf_token
from bp.post.services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
post_slug = g.post_data["post"]["slug"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
csrf = generate_csrf_token()
entries = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug)
calendars = _extract_calendar_browser_data(all_calendars, post_slug)
return {"entries": entries, "calendars": calendars, "csrf": csrf}
# ---------------------------------------------------------------------------
# Settings form
# ---------------------------------------------------------------------------
async def _h_post_settings_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.browser.app.csrf import generate_csrf_token
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
csrf = generate_csrf_token()
p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = p.get("is_page", False)
gp = ghost_post
# Extract tag names
tags = gp.get("tags") or []
if tags:
tag_names = ", ".join(
getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t))
for t in tags
)
else:
tag_names = ""
# Published at — trim to datetime-local format
pub_at = gp.get("published_at") or ""
pub_at_val = pub_at[:16] if pub_at else ""
return {
"csrf": csrf,
"updated-at": gp.get("updated_at") or "",
"is-page": is_page,
"save-success": save_success,
"settings-slug": gp.get("slug") or "",
"published-at": pub_at_val,
"featured": bool(gp.get("featured")),
"visibility": gp.get("visibility") or "public",
"email-only": bool(gp.get("email_only")),
"tags": tag_names,
"feature-image-alt": gp.get("feature_image_alt") or "",
"meta-title": gp.get("meta_title") or "",
"meta-description": gp.get("meta_description") or "",
"canonical-url": gp.get("canonical_url") or "",
"og-title": gp.get("og_title") or "",
"og-description": gp.get("og_description") or "",
"og-image": gp.get("og_image") or "",
"twitter-title": gp.get("twitter_title") or "",
"twitter-description": gp.get("twitter_description") or "",
"twitter-image": gp.get("twitter_image") or "",
"custom-template": gp.get("custom_template") or "",
}
# ---------------------------------------------------------------------------
# Post edit content
# ---------------------------------------------------------------------------
def _extract_newsletter_options(newsletters) -> list:
"""Extract newsletter data for .sx rendering."""
return [{"slug": getattr(nl, "slug", ""),
"name": getattr(nl, "name", "")} for nl in newsletters]
def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool,
publish_requested: bool, already_emailed: bool) -> list:
"""Extract footer badge data for .sx rendering."""
badges = []
if save_success:
badges.append({"cls": "text-[14px] text-green-600", "text": "Saved."})
if publish_requested:
badges.append({"cls": "text-[14px] text-blue-600",
"text": "Publish requested \u2014 an admin will review."})
if post.get("publish_requested"):
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800",
"text": "Publish requested"})
if already_emailed:
nl_name = ""
newsletter = ghost_post.get("newsletter")
if newsletter:
nl_name = (getattr(newsletter, "name", "")
if not isinstance(newsletter, dict)
else newsletter.get("name", ""))
suffix = f" to {nl_name}" if nl_name else ""
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800",
"text": f"Emailed{suffix}"})
return badges
async def _h_post_edit_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g, request as qrequest
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from shared.browser.app.csrf import generate_csrf_token
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
db_post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(db_post) if db_post else {}
save_success = qrequest.args.get("saved") == "1"
save_error = qrequest.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
csrf = generate_csrf_token()
urls = _editor_urls()
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = post.get("is_page", False)
feature_image = ghost_post.get("feature_image") or ""
feature_image_caption = ghost_post.get("feature_image_caption") or ""
title_val = ghost_post.get("title") or ""
excerpt_val = ghost_post.get("custom_excerpt") or ""
updated_at = ghost_post.get("updated_at") or ""
status = ghost_post.get("status") or "draft"
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
sx_content = ghost_post.get("sx_content") or ""
has_sx = bool(sx_content)
already_emailed = bool(ghost_post and ghost_post.get("email") and
(ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
email_obj = ghost_post.get("email")
if email_obj and not isinstance(email_obj, dict):
already_emailed = bool(getattr(email_obj, "status", None))
title_placeholder = "Page title..." if is_page else "Post title..."
# Return newsletter data as list of dicts (composed in SX)
nl_options = _extract_newsletter_options(newsletters)
# Return footer badge data as list of dicts (composed in SX)
publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False
badges = _extract_footer_badges(ghost_post, post, save_success,
publish_requested, already_emailed)
init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True)
return {
"csrf": csrf,
"updated-at": str(updated_at),
"title-val": title_val,
"excerpt-val": excerpt_val,
"feature-image": feature_image,
"feature-image-caption": feature_image_caption,
"sx-content-val": sx_content,
"lexical-json": lexical_json,
"has-sx": has_sx,
"title-placeholder": title_placeholder,
"status": status,
"already-emailed": already_emailed,
"newsletters": nl_options,
"badges": badges,
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
"save-error": save_error or None,
}

19
blog/sxc/pages/layouts.py Normal file
View File

@@ -0,0 +1,19 @@
"""Blog layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_sx_layout
register_sx_layout("blog", "blog-layout-full", "blog-layout-oob")
register_sx_layout("blog-settings", "blog-settings-layout-full",
"blog-settings-layout-oob", "blog-settings-layout-mobile")
register_sx_layout("blog-cache", "blog-cache-layout-full",
"blog-cache-layout-oob")
register_sx_layout("blog-snippets", "blog-snippets-layout-full",
"blog-snippets-layout-oob")
register_sx_layout("blog-menu-items", "blog-menu-items-layout-full",
"blog-menu-items-layout-oob")
register_sx_layout("blog-tag-groups", "blog-tag-groups-layout-full",
"blog-tag-groups-layout-oob")
register_sx_layout("blog-tag-group-edit", "blog-tag-group-edit-layout-full",
"blog-tag-group-edit-layout-oob")

25
blog/sxc/pages/renders.py Normal file
View File

@@ -0,0 +1,25 @@
"""Blog editor panel rendering."""
from __future__ import annotations
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
"""Build the WYSIWYG editor panel for new post/page creation."""
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_call
from .helpers import _editor_urls, _editor_init_js
urls = _editor_urls()
csrf = generate_csrf_token()
title_placeholder = "Page title..." if is_page else "Post title..."
create_label = "Create Page" if is_page else "Create Post"
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return sx_call("blog-editor-content",
csrf=csrf,
title_placeholder=title_placeholder,
create_label=create_label,
css_href=urls["css_href"],
js_src=urls["js_src"],
sx_editor_js_src=urls["sx_editor_js_src"],
init_js=init_js,
save_error=save_error or None)

10
cart/actions.sx Normal file
View File

@@ -0,0 +1,10 @@
;; Cart service — inter-service action endpoints
(defaction adopt-cart-for-user (&key user-id session-id)
"Transfer anonymous cart items to a logged-in user."
(do
(service "cart" "adopt-cart-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
;; clear-cart-for-order: remains as Python fallback (complex object construction)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from shared.sx.jinja_bridge import load_service_components # noqa: F401
from decimal import Decimal
from pathlib import Path
@@ -17,7 +17,6 @@ from bp import (
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
register_inbox,
@@ -141,7 +140,12 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
import os as _os
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="cart")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
@@ -185,8 +189,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages
setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
@@ -196,21 +198,22 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Cart overview at GET /
# Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
# Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last)
# Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app

View File

@@ -2,7 +2,6 @@ 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
from .inbox import register_inbox

View File

@@ -1,64 +1,26 @@
"""Cart app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (login handler) via the internal action client.
adopt-cart-for-user is defined in ``cart/actions.sx``.
clear-cart-for-order remains as a Python fallback (complex object construction).
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER
from shared.services.registry import services
from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
bp, _handlers = create_action_blueprint("cart")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adopt-cart-for-user ---
async def _adopt_cart():
data = await request.get_json()
await services.cart.adopt_cart_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-cart-for-user"] = _adopt_cart
# --- clear-cart-for-order ---
async def _clear_cart_for_order():
"""Soft-delete cart items after an order is paid. Called by orders service."""
from bp.cart.services.clear_cart_for_order import clear_cart_for_order
from shared.models.order import Order
data = await request.get_json()
user_id = data.get("user_id")
session_id = data.get("session_id")
page_post_id = data.get("page_post_id")
# Build a minimal order-like object with the fields clear_cart_for_order needs
order = type("_Order", (), {
"user_id": user_id,
"session_id": session_id,

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
@bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int):
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0
@@ -145,13 +145,13 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages.renders 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)
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages.renders 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)

View File

@@ -3,24 +3,9 @@
from __future__ import annotations
from quart import Blueprint, g, request
from .services import get_cart_grouped_by_page
from quart import Blueprint
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load overview data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_cart_overview"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
return bp

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load page cart data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_cart_view"):
return
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
g.page_cart_content = _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity()
@@ -93,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages.renders 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)

View File

@@ -1,79 +1,14 @@
"""Cart app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
All queries are defined in ``cart/queries.sx``.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
from shared.infrastructure.query_blueprint import create_data_blueprint
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- cart-summary ---
async def _cart_summary():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
page_slug = request.args.get("page_slug")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
)
return dto_to_dict(summary)
_handlers["cart-summary"] = _cart_summary
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
from shared.models.market import CartItem
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
filters = [CartItem.deleted_at.is_(None)]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
elif session_id is not None:
filters.append(CartItem.session_id == session_id)
else:
return []
result = await g.s.execute(
select(CartItem).where(*filters)
)
items = result.scalars().all()
return [
{
"product_id": item.product_id,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in items
]
_handlers["cart-items"] = _cart_items
bp, _handlers = create_data_blueprint("cart")
return bp

View File

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

View File

@@ -1,36 +0,0 @@
"""Cart app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``cart/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("cart", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "cart", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -57,7 +57,7 @@ def register() -> Blueprint:
if not order:
return await make_response("Order not found", 404)
from shared.sx.page import get_template_context
from sx.sx_components import render_order_page, render_order_oob
from sxc.pages.renders import render_order_page, render_order_oob
ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries")
@@ -122,7 +122,7 @@ def register() -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages.renders 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)

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
@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
orders = result.scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import (
from sxc.pages.renders import (
render_orders_page,
render_orders_rows,
render_orders_oob,
@@ -154,7 +154,7 @@ def register(url_prefix: str) -> Blueprint:
)
resp = await make_response(html)
elif page > 1:
sx_src = await render_orders_rows(
sx_src = render_orders_rows(
ctx, orders, page, total_pages, url_for, qs_fn,
)
resp = sx_response(sx_src)

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render admin content for defpage routes."""
endpoint = request.endpoint or ""
if request.method != "GET":
return
if endpoint.endswith("defpage_cart_admin"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
elif endpoint.endswith("defpage_cart_payments"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@bp.put("/payments/")
@require_admin
async def update_sumup(**kwargs):
@@ -64,7 +47,7 @@ def register():
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
from sxc.pages.renders import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
return sx_response(html)

11
cart/queries.sx Normal file
View File

@@ -0,0 +1,11 @@
;; Cart service — inter-service data queries
(defquery cart-summary (&key user-id session-id page-slug)
"Cart summary for a user or session, optionally filtered by page."
(service "cart" "cart-summary"
:user-id user-id :session-id session-id :page-slug page-slug))
(defquery cart-items (&key user-id session-id)
"Product slugs and quantities in the cart."
(service "cart-data" "cart-items"
:user-id user-id :session-id session-id))

View File

@@ -12,3 +12,9 @@ def register_domain_services() -> None:
from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService()
from shared.services.cart_items_impl import SqlCartItemsService
services.register("cart_data", SqlCartItemsService())
from .cart_page import CartPageService
services.register("cart_page", CartPageService())

226
cart/services/cart_page.py Normal file
View File

@@ -0,0 +1,226 @@
"""Cart page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from typing import Any
def _serialize_cart_item(item: Any) -> dict:
from quart import url_for
from shared.infrastructure.urls import market_product_url
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"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
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 ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
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')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict | None:
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", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"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"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"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": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
class CartPageService:
"""Service for cart page data, callable via (service "cart-page" ...)."""
async def overview_data(self, session, **kw):
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(session)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return {
"page_groups": grp_dicts,
"cart_url_base": cart_url(""),
}
async def page_cart_data(self, session, **kw):
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
post = g.page_post
cart = await get_cart_for_page(session, post.id)
cal_entries = await get_calendar_entries_for_page(session, post.id)
page_tickets = await get_tickets_for_page(session, post.id)
ticket_groups = group_tickets(page_tickets)
# Build summary data
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(page_tickets) if page_tickets else 0
item_count = product_qty + ticket_qty
product_total = total(cart) or 0
cal_total = calendar_total(cal_entries) or 0
tk_total = ticket_total(page_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 = getattr(g, "page_post", None)
summary = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
summary["checkout_action"] = route_prefix() + action
summary["user_email"] = user.email
else:
summary["login_href"] = login_url(request.url)
return {
"cart_items": [_serialize_cart_item(i) for i in cart],
"cal_entries": [_serialize_cal_entry(e) for e in cal_entries],
"ticket_groups": [_serialize_ticket_group(tg) for tg in ticket_groups],
"summary": summary,
}
async def admin_data(self, session, **kw):
"""Populate post context for cart-admin layout headers."""
from quart import g
from shared.infrastructure.fragments import fetch_fragments
post = g.page_post
slug = post.slug if post else ""
post_id = post.id if post else None
# Fetch container_nav for post header
container_nav = ""
if post_id:
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)
container_nav = events_nav + market_nav
return {
"post": {
"id": post_id,
"slug": slug,
"title": (post.title if post else "")[:160],
"feature_image": getattr(post, "feature_image", None),
},
"container_nav": container_nav,
}
async def payments_admin_data(self, session, **kw):
"""Admin data + payments data combined for cart-payments page."""
admin = await self.admin_data(session)
payments = await self.payments_data(session)
return {**admin, **payments}
async def payments_data(self, session, **kw):
from shared.sx.page import get_template_context
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return {"page_config": pc_data}

View File

@@ -1,4 +1,5 @@
;; Cart account-nav-item fragment handler
;; returns: sx
;;
;; Renders the "orders" link for the account dashboard nav.

View File

@@ -1,4 +1,5 @@
;; Cart cart-mini fragment handler
;; returns: sx
;;
;; Renders the cart icon with badge (or logo when empty).

View File

@@ -3,6 +3,11 @@
(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-page-label (&key feature-image title)
(<> (when feature-image
(~cart-page-label-img :src feature-image))
(span title)))
(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"))

View File

@@ -52,3 +52,114 @@
(div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary))))
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
(defcomp ~cart-item-from-data (&key item)
(let* ((slug (or (get item "slug") ""))
(title (or (get item "title") ""))
(image (get item "image"))
(brand (get item "brand"))
(is-deleted (get item "is_deleted"))
(unit-price (get item "unit_price"))
(special-price (get item "special_price"))
(regular-price (get item "regular_price"))
(currency (or (get item "currency") "GBP"))
(symbol (if (= currency "GBP") "\u00a3" currency))
(quantity (or (get item "quantity") 1))
(product-id (get item "product_id"))
(prod-url (or (get item "product_url") ""))
(qty-url (or (get item "qty_url") ""))
(csrf (csrf-token))
(line-total (when unit-price (* unit-price quantity))))
(~cart-item
:id (str "cart-item-" slug)
:img (if image
(~cart-item-img :src image :alt title)
(~img-or-placeholder :src nil
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
:placeholder-text "No image"))
:prod-url prod-url
:title title
:brand (when brand (~cart-item-brand :brand brand))
:deleted (when is-deleted (~cart-item-deleted))
:price (if unit-price
(<>
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
(when (and special-price (!= special-price regular-price))
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
(~cart-item-no-price))
:qty-url qty-url :csrf csrf
:minus (str (- quantity 1))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (when line-total
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
(defcomp ~cart-cal-section-from-data (&key entries)
(when (not (empty? entries))
(~cart-cal-section
:items (map (lambda (e)
(let* ((name (or (get e "name") ""))
(date-str (or (get e "date_str") "")))
(~cart-cal-entry
:name name :date-str date-str
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
entries))))
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
(when (not (empty? ticket-groups))
(let* ((csrf (csrf-token))
(qty-url (url-for "cart_global.update_ticket_quantity")))
(~cart-tickets-section
:items (map (lambda (tg)
(let* ((name (or (get tg "entry_name") ""))
(tt-name (get tg "ticket_type_name"))
(price (or (get tg "price") 0))
(quantity (or (get tg "quantity") 0))
(line-total (or (get tg "line_total") 0))
(entry-id (str (or (get tg "entry_id") "")))
(tt-id (get tg "ticket_type_id"))
(date-str (or (get tg "date_str") "")))
(~cart-ticket-article
:name name
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
:date-str date-str
:price (str "\u00a3" (format-decimal price 2))
:qty-url qty-url :csrf csrf
:entry-id entry-id
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
:minus (str (max (- quantity 1) 0))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
ticket-groups)))))
;; Assembled cart summary — replaces Python _cart_summary_sx
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
(~cart-summary-panel
:item-count (str item-count)
:subtotal (str symbol (format-decimal grand-total 2))
:checkout (if is-logged-in
(~cart-checkout-form
:action checkout-action :csrf (csrf-token)
:label (str " Checkout as " user-email))
(~cart-checkout-signin :href login-href))))
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
(if (and (empty? (or cart-items (list)))
(empty? (or cal-entries (list)))
(empty? (or ticket-groups (list))))
(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"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
(~cart-page-panel
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
:cal (when (not (empty? (or cal-entries (list))))
(~cart-cal-section-from-data :entries cal-entries))
:tickets (when (not (empty? (or ticket-groups (list))))
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
:summary summary)))

136
cart/sx/layouts.sx Normal file
View File

@@ -0,0 +1,136 @@
;; Cart layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; ---------------------------------------------------------------------------
;; Auto-fetching cart page header macros
;; ---------------------------------------------------------------------------
(defmacro ~cart-page-header-auto (oob)
"Cart page header: cart-row + page-cart-row using (cart-page-ctx)."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child")
(~header-child-sx :id "cart-header-child"
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob (unquote oob)))))))
(defmacro ~cart-page-header-oob ()
"Cart page OOB: individual oob rows."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob true)
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child"
:oob true)))))
;; ---------------------------------------------------------------------------
;; cart-page layout: root + cart row + page-cart row
;; ---------------------------------------------------------------------------
(defcomp ~cart-page-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (~cart-page-header-auto))))
(defcomp ~cart-page-layout-oob ()
(<> (~cart-page-header-oob)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; cart-admin layout: root + post header + admin header
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
;; ---------------------------------------------------------------------------
(defcomp ~cart-admin-layout-full (&key selected)
(<> (~root-header-auto)
(~header-child-sx
:inner (~post-header-auto nil))))
(defcomp ~cart-admin-layout-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; orders-within-cart: root + auth-simple + orders
;; ---------------------------------------------------------------------------
(defcomp ~cart-orders-layout-full (&key list-url)
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child"
:inner (~orders-header-row :list-url list-url))))))
(defcomp ~cart-orders-layout-oob (&key list-url)
(<> (~auth-header-row-simple-auto true)
(~oob-header-sx
:parent-id "auth-header-child"
:row (~orders-header-row :list-url list-url))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; order-detail-within-cart: root + auth-simple + orders + order
;; ---------------------------------------------------------------------------
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label)
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child"
:inner (<> (~orders-header-row :list-url list-url)
(~header-child-sx :id "orders-header-child"
:inner (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url
:link-label order-label
:icon "fa fa-gbp"))))))))
(defcomp ~cart-order-detail-layout-oob (&key detail-url order-label)
(<> (~oob-header-sx
:parent-id "orders-header-child"
:row (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url :link-label order-label
:icon "fa fa-gbp" :oob true))
(~root-header-auto true)))
;; --- orders rows wrapper (for infinite scroll) ---
(defcomp ~cart-orders-rows (&key rows next-scroll)
(<> rows next-scroll))
;; Composition defcomp — replaces Python loop in render_orders_rows
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
(~cart-orders-rows
:rows (map (lambda (od)
(~order-row-pair :order od :detail-url-prefix detail-url-prefix))
(or orders (list)))
:next-scroll (if (< page total-pages)
(~infinite-scroll :url next-url :page page
:total-pages total-pages :id-prefix "orders" :colspan 5)
(~order-end-row))))
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url)
(~checkout-error-content
:msg msg
:order (when order-id (~checkout-error-order-id :oid (str "#" order-id)))
:back-url back-url))

View File

@@ -39,3 +39,56 @@
(defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards)))
(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"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
;; Assembled page group card — replaces Python _page_group_card_sx
(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base)
(let* ((post (get grp "post"))
(product-count (or (get grp "product_count") 0))
(calendar-count (or (get grp "calendar_count") 0))
(ticket-count (or (get grp "ticket_count") 0))
(total (or (get grp "total") 0))
(market-place (get grp "market_place"))
(badges (<>
(when (> product-count 0)
(~cart-badge :icon "fa fa-box-open"
:text (str product-count " item" (pluralize product-count))))
(when (> calendar-count 0)
(~cart-badge :icon "fa fa-calendar"
:text (str calendar-count " booking" (pluralize calendar-count))))
(when (> ticket-count 0)
(~cart-badge :icon "fa fa-ticket"
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
(if post
(let* ((slug (or (get post "slug") ""))
(title (or (get post "title") ""))
(feature-image (get post "feature_image"))
(mp-name (if market-place (or (get market-place "name") "") ""))
(display-title (if (!= mp-name "") mp-name title)))
(~cart-group-card
:href (str cart-url-base "/" slug "/")
:img (if feature-image
(~cart-group-card-img :src feature-image :alt title)
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
:placeholder-icon "fa fa-store text-xl"))
:display-title display-title
:subtitle (when (!= mp-name "")
(~cart-mp-subtitle :title title))
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))
(~cart-orphan-card
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))))
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
(defcomp ~cart-overview-content (&key page-groups cart-url-base)
(if (empty? page-groups)
(~cart-empty)
(~cart-overview-panel
:cards (map (lambda (grp)
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base))
page-groups))))

View File

@@ -5,3 +5,27 @@
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
;; Assembled cart admin overview content
(defcomp ~cart-admin-content ()
(let* ((payments-href (url-for "defpage_cart_payments")))
(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") " Payments")
(a :href payments-href :class "text-sm underline" "configure")))))
;; Assembled cart payments content
(defcomp ~cart-payments-content (&key page-config)
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
(merchant-code (or (get page-config "sumup_merchant_code") ""))
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
(placeholder (if sumup-configured "--------" "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"))
(~cart-payments-panel
:update-url (url-for "page_admin.update_sumup")
:csrf (csrf-token)
:merchant-code merchant-code
:placeholder placeholder
:input-cls input-cls
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix)))

View File

@@ -1,807 +0,0 @@
"""
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.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, root_header_sx, post_admin_header_sx,
post_header_sx as _shared_post_header_sx,
search_desktop_sx, search_mobile_sx,
full_page_sx, oob_page_sx, header_child_sx,
sx_call, SxExpr,
)
from shared.infrastructure.urls import market_product_url, cart_url
# Load cart-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="cart")
# ---------------------------------------------------------------------------
# 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_sx)."""
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 if not already present (for post header row)."""
if ctx.get("container_nav"):
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": events_nav + market_nav}
async def _post_header_sx(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_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
"menu-row-sx",
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_sx(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_parts = []
if page_post and page_post.feature_image:
label_parts.append(sx_call("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
child_id="auth-header-child", oob=oob,
)
def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return sx_call(
"menu-row-sx",
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_sx(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
def _page_group_card_sx(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
badge_parts = []
if product_count > 0:
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
if calendar_count > 0:
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
if ticket_count > 0:
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
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 = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="h-16 w-16 rounded-xl",
placeholder_icon="fa fa-store text-xl")
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = sx_call("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title
return sx_call(
"cart-group-card",
href=cart_href, img=SxExpr(img), display_title=display_title,
subtitle=SxExpr(mp_sub) if mp_sub else None,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
else:
# Orphan items
return sx_call(
"cart-orphan-card",
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
def _empty_cart_sx() -> str:
"""Empty cart state."""
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(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"'
f' {empty}))'
)
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return _empty_cart_sx()
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
return _empty_cart_sx()
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
# ---------------------------------------------------------------------------
# Page cart
# ---------------------------------------------------------------------------
def _cart_item_sx(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 = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
placeholder_text="No image")
price_parts = []
if unit_price:
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
if p.special_price and p.special_price != p.regular_price:
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
else:
price_parts.append(sx_call("cart-item-no-price"))
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
line_total_sx = None
if unit_price:
lt = unit_price * item.quantity
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
return sx_call(
"cart-item",
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
brand=SxExpr(brand_sx) if brand_sx else None,
deleted=SxExpr(deleted_sx) if deleted_sx else None,
price=SxExpr(price_sx),
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=str(item.quantity + 1),
line_total=SxExpr(line_total_sx) if line_total_sx else None,
)
def _calendar_entries_sx(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
parts = []
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 ""
parts.append(sx_call(
"cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-cal-section", items=SxExpr(items_sx))
def _ticket_groups_sx(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")
parts = []
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_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
parts.append(sx_call(
"cart-ticket-article",
name=name,
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
date_str=date_str,
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
entry_id=str(entry_id),
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
def _cart_summary_sx(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_sx = sx_call(
"cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_sx = sx_call("cart-checkout-signin", href=href)
return sx_call(
"cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
checkout=SxExpr(checkout_sx),
)
def _page_cart_main_panel_sx(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:
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(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"'
f' {empty})))'
)
item_parts = [_cart_item_sx(item, ctx) for item in cart]
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
cal_sx = _calendar_entries_sx(cal_entries)
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return sx_call(
"cart-page-panel",
items=SxExpr(items_sx),
cal=SxExpr(cal_sx) if cal_sx else None,
tickets=SxExpr(tickets_sx) if tickets_sx else None,
summary=SxExpr(summary_sx),
)
# ---------------------------------------------------------------------------
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_sx(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 = sx_call(
"order-row-desktop",
oid=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, url=detail_url,
)
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
mobile = sx_call(
"order-row-mobile",
oid=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, url=detail_url,
)
return "(<> " + desktop + " " + mobile + ")"
def _orders_rows_sx(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_sx(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(sx_call(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return sx_call("order-empty-state")
return sx_call("order-table", rows=SxExpr(rows_sx))
def _orders_summary_sx(ctx: dict) -> str:
"""Filter section for orders list."""
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_sx(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
parts = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("order-item-no-image")
parts.append(sx_call(
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=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}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _order_summary_sx(order: Any) -> str:
"""Order summary card."""
return sx_call(
"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_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
parts = []
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')}"
parts.append(sx_call(
"order-calendar-entry",
name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_sx(order)
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(cal) if cal else None,
)
def _order_filter_sx(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_sx = None
if status != "paid":
pay_sx = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"order-detail-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
pay=SxExpr(pay_sx) if pay_sx else None,
)
# ---------------------------------------------------------------------------
# Public API: Cart overview
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=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_sx(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_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
)
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=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_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
)) + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=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_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("checkout-error-header")
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = None
if order:
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
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_sx(ctx)
filt = _checkout_error_filter_sx()
content = _checkout_error_content_sx(error, order)
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# ---------------------------------------------------------------------------
def _cart_page_admin_header_sx(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_sx(ctx, slug, oob=oob, selected=selected)
def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.defpage_cart_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") " Payments")'
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
)
def _cart_payments_main_panel_sx(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 sx_call("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)
def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_sx(ctx)

View File

@@ -1,13 +1,11 @@
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Cart defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_cart_pages() -> None:
"""Register cart-specific layouts, page helpers, and load page definitions."""
"""Register cart-specific layouts and load page definitions."""
from .layouts import _register_cart_layouts
_register_cart_layouts()
_register_cart_helpers()
_load_cart_page_files()
@@ -15,107 +13,3 @@ def _load_cart_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "cart")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_cart_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("cart-page", _cart_page_full, _cart_page_oob)
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
root_hdr = root_header_sx(ctx)
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
)
return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
child_oob = sx_call("oob-header-sx",
parent_id="cart-header-child",
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_cart_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("cart", {
"overview-content": _h_overview_content,
"page-cart-content": _h_page_cart_content,
"cart-admin-content": _h_cart_admin_content,
"cart-payments-content": _h_cart_payments_content,
})
def _h_overview_content():
from quart import g
page_groups = getattr(g, "overview_page_groups", [])
from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
# The function reads cart_url from ctx, which we can get from template context
from shared.sx.page import get_template_context
import asyncio
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content():
from quart import g
return getattr(g, "page_cart_content", "")
def _h_cart_admin_content():
from sx.sx_components import _cart_admin_main_panel_sx
from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
from quart import g
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

@@ -1,25 +1,44 @@
;; Cart app defpage declarations.
;; All data fetching via (service ...) IO primitives, no Python helpers.
(defpage cart-overview
:path "/"
:auth :public
:layout :root
:content (overview-content))
:data (service "cart-page" "overview-data")
:content (~cart-overview-content
:page-groups page-groups
:cart-url-base cart-url-base))
(defpage page-cart-view
:path "/"
:path "/<page_slug>/"
:auth :public
:layout :cart-page
:content (page-cart-content))
:data (service "cart-page" "page-cart-data")
:content (~cart-page-cart-content
:cart-items cart-items
:cal-entries cal-entries
:ticket-groups ticket-groups
:summary (~cart-summary-from-data
:item-count (get summary "item_count")
:grand-total (get summary "grand_total")
:symbol (get summary "symbol")
:is-logged-in (get summary "is_logged_in")
:checkout-action (get summary "checkout_action")
:login-href (get summary "login_href")
:user-email (get summary "user_email"))))
(defpage cart-admin
:path "/"
:path "/<page_slug>/admin/"
:auth :admin
:layout :cart-admin
:content (cart-admin-content))
:data (service "cart-page" "admin-data")
:content (~cart-admin-content))
(defpage cart-payments
:path "/payments/"
:path "/<page_slug>/admin/payments/"
:auth :admin
:layout (:cart-admin :selected "payments")
:content (cart-payments-content))
:data (service "cart-page" "payments-admin-data")
:content (~cart-payments-content
:page-config page-config))

View File

@@ -0,0 +1,8 @@
"""Cart layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
def _register_cart_layouts() -> None:
from shared.sx.layouts import register_sx_layout
register_sx_layout("cart-page", "cart-page-layout-full", "cart-page-layout-oob")
register_sx_layout("cart-admin", "cart-admin-layout-full", "cart-admin-layout-oob")

121
cart/sxc/pages/renders.py Normal file
View File

@@ -0,0 +1,121 @@
"""Cart render functions — called from bp routes."""
from __future__ import annotations
from .utils import _serialize_order, _serialize_calendar_entry
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
header_rows = await render_to_sx_with_env("cart-orders-layout-full", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
aside=await search_desktop_sx(ctx), content=content)
def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call
from shared.utils import route_prefix
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
next_url = list_url + qs_fn(page=page + 1) if page < total_pages else ""
return sx_call("cart-orders-rows-content",
orders=order_dicts, detail_url_prefix=detail_url_prefix,
page=page, total_pages=total_pages, next_url=next_url)
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
oobs = await render_to_sx_with_env("cart-orders-layout-oob", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
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)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", {},
list_url=list_url, detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, oob_page_sx
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)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", {},
detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
hdr = await render_to_sx_with_env("layout-root-full", {})
filt = sx_call("checkout-error-header")
content = sx_call("cart-checkout-error-from-data",
msg=err_msg, order_id=order.id if order else None,
back_url=cart_url("/"))
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
def render_cart_payments_panel(ctx):
from shared.sx.helpers import sx_call
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return sx_call("cart-payments-content", page_config=pc_data)

40
cart/sxc/pages/utils.py Normal file
View File

@@ -0,0 +1,40 @@
"""Cart page utilities — serializers and formatters."""
from __future__ import annotations
from typing import Any
def _serialize_order(order: Any) -> dict:
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
items = []
if order.items:
for item in order.items:
items.append({
"product_image": item.product_image,
"product_title": item.product_title or "Unknown product",
"product_id": item.product_id,
"product_slug": item.product_slug,
"product_url": market_product_url(item.product_slug),
"quantity": item.quantity,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
"currency": item.currency or order.currency or "GBP",
})
return {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": created,
"description": order.description or "",
"total_formatted": f"{order.total_amount or 0:.2f}",
"total_amount": float(order.total_amount or 0),
"currency": order.currency or "GBP",
"items": items,
}
def _serialize_calendar_entry(e: Any) -> dict:
st = e.state or ""
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')}"
return {"name": e.name, "state": st, "date_str": ds, "cost_formatted": f"{e.cost or 0:.2f}"}

View File

@@ -10,6 +10,8 @@
x-dev-env: &dev-env
RELOAD: "true"
WORKERS: "1"
SX_USE_REF: "1"
SX_BOUNDARY_STRICT: "1"
x-sibling-models: &sibling-models
# Every app needs all sibling __init__.py + models/ for cross-domain SQLAlchemy imports
@@ -46,6 +48,7 @@ services:
- ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py
- ./blog/sx:/app/sx
- ./blog/sxc:/app/sxc
- ./blog/bp:/app/bp
- ./blog/services:/app/services
- ./blog/templates:/app/templates
@@ -84,6 +87,7 @@ services:
- ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py
- ./market/sx:/app/sx
- ./market/sxc:/app/sxc
- ./market/bp:/app/bp
- ./market/services:/app/services
- ./market/templates:/app/templates
@@ -121,6 +125,7 @@ services:
- ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py
- ./cart/sx:/app/sx
- ./cart/sxc:/app/sxc
- ./cart/bp:/app/bp
- ./cart/services:/app/services
- ./cart/templates:/app/templates
@@ -158,6 +163,7 @@ services:
- ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py
- ./events/sx:/app/sx
- ./events/sxc:/app/sxc
- ./events/bp:/app/bp
- ./events/services:/app/services
- ./events/templates:/app/templates
@@ -195,6 +201,7 @@ services:
- ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py
- ./federation/sx:/app/sx
- ./federation/sxc:/app/sxc
- ./federation/bp:/app/bp
- ./federation/services:/app/services
- ./federation/templates:/app/templates
@@ -232,6 +239,7 @@ services:
- ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py
- ./account/sx:/app/sx
- ./account/sxc:/app/sxc
- ./account/bp:/app/bp
- ./account/services:/app/services
- ./account/templates:/app/templates
@@ -331,6 +339,7 @@ services:
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
- ./orders/sx:/app/sx
- ./orders/sxc:/app/sxc
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates
@@ -392,6 +401,7 @@ services:
- ./sx/bp:/app/bp
- ./sx/services:/app/services
- ./sx/content:/app/content
- ./sx/sx:/app/sx
- ./sx/path_setup.py:/app/path_setup.py
- ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh
- ./sx/__init__.py:/app/__init__.py:ro

View File

@@ -56,6 +56,7 @@ x-app-env: &app-env
AP_DOMAIN_MARKET: market.rose-ash.com
AP_DOMAIN_EVENTS: events.rose-ash.com
EXTERNAL_INBOXES: "artdag|https://celery-artdag.rose-ash.com/inbox"
SX_BOUNDARY_STRICT: "1"
services:
blog:

View File

@@ -105,9 +105,458 @@ Call `load_css_registry()` in `setup_sx_bridge()` after loading components.
5. Inspect `<style id="sx-css">` — should grow as new pages introduce new classes
6. Check non-sx pages still render correctly (full CSS dump fallback)
## Phase 2 (Future)
## Phase 2: S-Expression Styles — Native SX Style Primitives
- **Component-level pre-computation:** Pre-scan classes per component at registration time
- **Own rule generator:** Replace tw.css parsing with a Python rule engine (no Tailwind dependency at all)
- **Header compression:** Use bitfield or hash instead of full class list
- **Critical CSS:** Only inline above-fold CSS, lazy-load rest
### Context
SX eliminated the HTML/JS divide — code is data is DOM. But one foreign language remains: CSS. Components are full of `:class "flex gap-4 items-center p-2 bg-sky-100 rounded"` — opaque strings from a separate language (Tailwind) that requires a separate build step (Tailwind v3 CLI), a separate parser (css_registry.py parsing tw.css), and a separate delivery mechanism (hash-based dedup).
**Goal:** Make styles first-class SX expressions. `(css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded)` replaces `"flex gap-4 items-center p-2 bg-sky-100 rounded"`. Same mental model as Tailwind — atomic utility keywords — but native to the language. No build step. No external CSS framework. Code = data = DOM = styles.
### Surface Syntax
```lisp
;; Before (Tailwind class strings)
(div :class "flex gap-4 items-center p-2 bg-sky-100 rounded" ...)
;; After (SX style expressions)
(div :style (css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded) ...)
;; Responsive + pseudo-classes (variant:atom, parsed as single keyword)
(div :style (css :flex :gap-2 :sm:gap-4 :sm:flex-row :hover:bg-sky-200) ...)
;; Named styles
(defstyle card-base (css :rounded-xl :bg-white :shadow :hover:shadow-md :transition))
(div :style card-base ...)
;; Composition
(div :style (merge-styles card-base (css :p-4 :border :border-stone-200)) ...)
;; Conditional
(div :style (if active (css :bg-sky-500 :text-white) (css :bg-stone-100)) ...)
;; Both :class and :style coexist during migration
(div :class "prose" :style (css :p-4 :max-w-3xl) ...)
```
**Why `(css :flex :gap-4)` not `(flex :gap 4)` or `(style :display :flex :gap "1rem")`?**
- Keywords mirror Tailwind class names 1:1 — migration is mechanical search-replace
- Single `css` primitive, no namespace pollution (hundreds of functions like `flex`, `p`, `bg`)
- Parser already handles `:hover:bg-sky-200` as one keyword (regex `:[a-zA-Z_][a-zA-Z0-9_>:-]*`)
### Architecture
#### Three layers
1. **Style Dictionary** (`style_dict.py`) — maps keyword atoms to CSS declarations. Pure data. Replaces tw.css.
2. **Style Resolver** (`style_resolver.py`) — `(css :flex :gap-4)``StyleValue(class_name="sx-a3f2c1", declarations="display:flex;gap:1rem")`. Memoized.
3. **Style Registry** — generated CSS rules registered into the existing `css_registry.py` delivery system. Same hash-based dedup, same `<style data-sx-css>`, same `SX-Css` header.
#### Output: generated classes (not inline styles)
Inline `style="..."` can't express `:hover`, `:focus`, `@media` breakpoints, or combinators. Generated classes preserve all Tailwind functionality. The `css` primitive produces a `StyleValue` with a content-addressed class name. The renderer emits `class="sx-a3f2c1"` and registers the CSS rule for on-demand delivery.
### @ Rules (Animations, Keyframes, Containers)
`@media` breakpoints are handled via responsive variants (`:sm:flex-row`), but CSS has other @ rules that need first-class support:
#### `@keyframes` — via `defkeyframes`
```lisp
;; Define a keyframes animation
(defkeyframes fade-in
(from (css :opacity-0))
(to (css :opacity-100)))
(defkeyframes slide-up
("0%" (css :translate-y-4 :opacity-0))
("100%" (css :translate-y-0 :opacity-100)))
;; Use it — animate-[name] atom references the keyframes
(div :style (css :animate-fade-in :duration-300) ...)
```
**Implementation:** `defkeyframes` is a special form that:
1. Evaluates each step's `(css ...)` body to get declarations
2. Builds a `@keyframes fade-in { from { opacity:0 } to { opacity:1 } }` rule
3. Registers the `@keyframes` rule in `css_registry.py` via `register_generated_rule()`
4. Binds the name so `animate-fade-in` can reference it
**Built-in animations** in `style_dict.py`:
```python
# Keyframes registered at dictionary load time
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# Animation atoms reference keyframes by name
STYLE_ATOMS |= {
"animate-spin": "animation:spin 1s linear infinite",
"animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite",
"animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
"animate-bounce": "animation:bounce 1s infinite",
"animate-none": "animation:none",
"duration-75": "animation-duration:75ms",
"duration-100": "animation-duration:100ms",
"duration-150": "animation-duration:150ms",
"duration-200": "animation-duration:200ms",
"duration-300": "animation-duration:300ms",
"duration-500": "animation-duration:500ms",
"duration-700": "animation-duration:700ms",
"duration-1000": "animation-duration:1000ms",
}
```
When the resolver encounters `animate-spin`, it emits both the class rule AND ensures the `@keyframes spin` rule is registered. The `@keyframes` rules flow through the same `_REGISTRY``lookup_rules()``SX-Css` delta pipeline.
#### `@container` queries
```lisp
;; Container context
(div :style (css :container :container-name-sidebar) ...)
;; Container query variant (like responsive but scoped to container)
(div :style (css :flex-col :@sm/sidebar:flex-row) ...)
```
Variant prefix `@sm/sidebar``@container sidebar (min-width: 640px)`. Parsed the same way as responsive variants but emits `@container` instead of `@media`.
#### `@font-face`
Not needed as atoms — font loading stays in `basics.css` or a dedicated `(load-font ...)` primitive. Fonts are infrastructure, not component styles.
### Dynamic Class Generation
#### Static atoms (common case)
```lisp
(css :flex :gap-4 :bg-sky-100)
```
All atoms are keywords known at parse time. Server and client both resolve from the dictionary. No issues.
#### Dynamic atoms (runtime-computed)
```lisp
;; Color from data
(let ((color (get item "color")))
(div :style (css :p-4 :rounded (str "bg-" color "-100")) ...))
;; Numeric from computation
(div :style (css :flex (str "gap-" (if compact "1" "4"))) ...)
```
The `css` primitive accepts both keywords and strings. When it receives a string like `"bg-sky-100"`, it looks it up in `STYLE_ATOMS` the same way. This works on both server and client because both have the full dictionary in memory.
**No server round-trip needed** — the client has the complete style dictionary cached in localStorage. Dynamic atom lookup is a local hash table read, same as static atoms.
#### Arbitrary values (escape hatch)
For values not in the dictionary — truly custom measurements, colors, etc.:
```lisp
;; Arbitrary value syntax (mirrors Tailwind's bracket notation)
(css :w-[347px] :h-[calc(100vh-4rem)] :bg-[#ff6b35])
```
**Pattern-based generator** in the resolver (both server and client):
```python
ARBITRARY_PATTERNS: list[tuple[re.Pattern, Callable]] = [
# w-[value] → width:value
(re.compile(r"w-\[(.+)\]"), lambda v: f"width:{v}"),
# h-[value] → height:value
(re.compile(r"h-\[(.+)\]"), lambda v: f"height:{v}"),
# bg-\[value] → background-color:value
(re.compile(r"bg-\[(.+)\]"), lambda v: f"background-color:{v}"),
# p-[value] → padding:value
(re.compile(r"p-\[(.+)\]"), lambda v: f"padding:{v}"),
# text-[value] → font-size:value
(re.compile(r"text-\[(.+)\]"), lambda v: f"font-size:{v}"),
# top/right/bottom/left-[value]
(re.compile(r"(top|right|bottom|left)-\[(.+)\]"), lambda d, v: f"{d}:{v}"),
# grid-cols-[value] → grid-template-columns:value
(re.compile(r"grid-cols-\[(.+)\]"), lambda v: f"grid-template-columns:{v}"),
# min/max-w/h-[value]
(re.compile(r"(min|max)-(w|h)-\[(.+)\]"),
lambda mm, dim, v: f"{'width' if dim=='w' else 'height'}:{v}" if mm=='max' else f"min-{'width' if dim=='w' else 'height'}:{v}"),
]
```
Resolution order: dictionary lookup → pattern match → error (unknown atom).
The generator runs client-side too (it's just regex + string formatting), so arbitrary values never cause a server round-trip. The generated class and CSS rule are injected into `<style id="sx-css">` on the client, same as dictionary-resolved atoms.
#### Fully dynamic (data-driven colors/sizes)
For cases where the CSS property and value are both runtime data (e.g., user-chosen brand colors stored in the database):
```lisp
;; Inline style fallback — when value is truly unknown
(div :style (str "background-color:" brand-color) ...)
;; Or a raw-css escape hatch
(div :style (raw-css "background-color" brand-color) ...)
```
These emit inline `style="..."` attributes, bypassing the class generation system. This is correct — these values are unique per-entity, so generating a class would be wasteful (class never reused). Inline styles are the right tool for truly unique values.
### Style Delivery & Caching
#### Current system (CSS classes)
1. **Full page load**: Server scans rendered SX for class names → `lookup_rules()` gets CSS for those classes → embeds in `<style id="sx-css">` + stores hash in `<meta name="sx-css-classes">`
2. **Subsequent SX requests**: Client sends `SX-Css: {8-char-hash}` header → server resolves hash to known class set → computes delta (new classes only) → sends `<style data-sx-css>{new rules}</style>` inline in response + `SX-Css-Hash` response header with updated cumulative hash
3. **Client accumulates**: `sx.js` extracts `<style data-sx-css>` blocks, appends rules to `<style id="sx-css">`, updates its `_sxCssHash`
#### Current system (components)
- Components cached in **localStorage** by content hash
- Server checks `sx-comp-hash` cookie → if client has current hash, omits component source from response body
- Client loads from localStorage on cache hit, downloads on miss
#### New system (SX styles) — same pattern as components
**Key insight**: The style dictionary (`STYLE_ATOMS`) is a fixed dataset, like component definitions. It changes only on deployment, not per-request. Cache it in localStorage like components, not per-request like CSS class deltas.
**Server side:**
- At startup, hash the full style dictionary → `sx-style-dict-hash`
- Check `sx-style-hash` cookie on each request
- If client has current hash: omit dictionary from response
- If client is stale/missing: include `<script type="text/sx-styles" data-hash="{hash}">{serialized dict}</script>` in full-page response
- Generated CSS rules (from `(css ...)` evaluation) are tracked the same way current CSS classes are — server sends only new rules client doesn't have
**Client side (`sx.js`):**
- On full page load: check `<script type="text/sx-styles" data-hash="{hash}">`
- If hash matches localStorage `sx-styles-hash`: load from localStorage (skip download)
- If hash differs or no cache: parse inline dict, store in localStorage, set cookie
- Style dictionary lives in memory as a JS object for `css` primitive lookups
- Generated CSS rules injected into `<style id="sx-css">` (same as current system)
**Per-request style delivery** (for SX responses after initial page):
- `(css ...)` produces `StyleValue` on server → renderer emits `class="sx-a3f2c1"`
- Server registers generated rule in `_REGISTRY``lookup_rules()` picks it up
- Existing `SX-Css` hash mechanism sends only new CSS rules to client
- No change needed to the delta delivery pipeline — generated class names flow through `lookup_rules()` exactly like Tailwind class names do today
**Server-side session tracking** (optimization):
- Server maintains `dict[client_id, set[str]]` mapping client IDs to known style rule hashes
- Client ID = session cookie or device ID (already exists in rose-ash auth system)
- On each response, server records which style rules were sent to this client
- On subsequent requests, server checks its record before computing delta
- Falls back to hash-based negotiation if server-side record is missing (restart, eviction)
- This avoids the round-trip cost of the client needing to tell the server what it knows — the server already knows
**Data transfer optimization:**
- Style dictionary: ~15-20KB serialized, sent once, cached in localStorage indefinitely (until hash changes on deploy)
- Per-request: only delta CSS rules (typically 0-500 bytes for navigation to a new page type)
- Preamble (resets, FontAwesome, basics.css): sent once on full page load, same as today
- Total initial download actually decreases: style dict (~20KB) < tw.css sent as rules (~40KB+ for pages using many classes)
### Implementation Phases
#### Phase 2.0: Style Dictionary
**New file: `shared/sx/style_dict.py`**
Pure data mapping ~500 keyword atoms (the ones actually used across the codebase) to CSS declarations:
```python
STYLE_ATOMS: dict[str, str] = {
"flex": "display:flex",
"hidden": "display:none",
"block": "display:block",
"flex-col": "flex-direction:column",
"flex-row": "flex-direction:row",
"items-center": "align-items:center",
"justify-between": "justify-content:space-between",
"gap-1": "gap:0.25rem",
"gap-2": "gap:0.5rem",
"gap-4": "gap:1rem",
"p-2": "padding:0.5rem",
"px-4": "padding-left:1rem;padding-right:1rem",
"bg-sky-100": "background-color:rgb(224 242 254)",
"rounded": "border-radius:0.25rem",
"rounded-xl": "border-radius:0.75rem",
"text-sm": "font-size:0.875rem;line-height:1.25rem",
"font-semibold": "font-weight:600",
"shadow": "box-shadow:0 1px 3px 0 rgb(0 0 0/0.1),0 1px 2px -1px rgb(0 0 0/0.1)",
"transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4,0,0.2,1);transition-duration:150ms",
# ... ~500 entries total
}
PSEUDO_VARIANTS: dict[str, str] = {
"hover": ":hover", "focus": ":focus", "active": ":active",
"disabled": ":disabled", "first": ":first-child", "last": ":last-child",
"group-hover": ":is(.group:hover) &",
}
RESPONSIVE_BREAKPOINTS: dict[str, str] = {
"sm": "(min-width:640px)", "md": "(min-width:768px)",
"lg": "(min-width:1024px)", "xl": "(min-width:1280px)",
}
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS
ARBITRARY_PATTERNS: list[tuple[str, str]] = [
# pattern → CSS template ({0} = captured value)
(r"w-\[(.+)\]", "width:{0}"),
(r"h-\[(.+)\]", "height:{0}"),
(r"bg-\[(.+)\]", "background-color:{0}"),
(r"p-\[(.+)\]", "padding:{0}"),
(r"m-\[(.+)\]", "margin:{0}"),
(r"text-\[(.+)\]", "font-size:{0}"),
(r"(top|right|bottom|left)-\[(.+)\]", "{0}:{1}"),
(r"(min|max)-(w|h)-\[(.+)\]", "{0}-{1}:{2}"),
(r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"),
(r"gap-\[(.+)\]", "gap:{0}"),
]
```
Generated by: scanning all `:class "..."` across 64 .sx files to find used atoms, then extracting their CSS from the existing tw.css via `css_registry.py`'s parsed `_REGISTRY`.
#### Phase 2.1: StyleValue type + `css` primitive + resolver
**Modify: `shared/sx/types.py`** — add StyleValue:
```python
@dataclass(frozen=True)
class StyleValue:
class_name: str # "sx-a3f2c1"
declarations: str # "display:flex;gap:1rem"
media_rules: tuple = () # ((query, decls), ...)
pseudo_rules: tuple = () # ((selector, decls), ...)
keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...)
container_rules: tuple = () # (("sidebar (min-width:640px)", decls), ...)
```
**New file: `shared/sx/style_resolver.py`** — memoized resolver:
- Takes tuple of atom strings (e.g., `("flex", "gap-4", "hover:bg-sky-200", "sm:flex-row")`)
- Splits variant prefixes (`hover:bg-sky-200` → variant=`hover`, atom=`bg-sky-200`)
- Looks up declarations in STYLE_ATOMS
- Falls back to `ARBITRARY_PATTERNS` for bracket notation (`w-[347px]``width:347px`)
- Detects `animate-*` atoms → includes associated `@keyframes` rules
- Groups into base / pseudo / media / keyframes / container
- Hashes declarations → deterministic class name `sx-{hash[:6]}`
- Returns `StyleValue`
- Dict cache keyed on input tuple
- Accepts both keywords and runtime strings (for dynamic atom construction)
**Modify: `shared/sx/primitives.py`** — add `css` and `merge-styles`:
```python
@register_primitive("css")
def prim_css(*args):
from .style_resolver import resolve_style
return resolve_style(tuple(str(a) for a in args if a))
@register_primitive("merge-styles")
def prim_merge_styles(*styles):
from .style_resolver import merge_styles
return merge_styles([s for s in styles if isinstance(s, StyleValue)])
```
#### Phase 2.2: Server-side rendering + delivery integration
**Modify: `shared/sx/html.py`** — in `_render_element()` (line ~482):
- When `:style` evaluates to a `StyleValue`: emit its `class_name` as a CSS class (appended to any existing `:class`), register the rule with `register_generated_rule()`, don't emit `:style` attribute
- When `:style` is a string: existing behavior (inline style attribute)
**Modify: `shared/sx/async_eval.py`** — same change in `_arender_element()` (line ~641)
**Modify: `shared/sx/css_registry.py`** — add `register_generated_rule(style_val)`:
- Builds CSS rule: `.sx-a3f2c1{display:flex;gap:1rem}`
- Plus pseudo rules: `.sx-a3f2c1:hover{background-color:...}`
- Plus media rules: `@media(min-width:640px){.sx-a3f2c1{flex-direction:row}}`
- Inserts into `_REGISTRY` so existing `lookup_rules()` works transparently
- Generated rules flow through the same `SX-Css` hash delta mechanism — no new delivery protocol needed
**Modify: `shared/sx/helpers.py`** — style dictionary delivery:
- In `sx_page_shell()` (full page): include style dictionary as `<script type="text/sx-styles" data-hash="{hash}">` with localStorage caching (same pattern as component caching)
- Check `sx-style-hash` cookie: if client has current hash, omit dictionary source
- In `sx_response()` (SX fragment responses): no change — generated CSS rules already flow through `<style data-sx-css>`
**Modify: `shared/infrastructure/factory.py`** — add `sx-style-hash` to allowed headers in CORS config
#### Phase 2.3: Client-side (sx.js)
**Modify: `shared/static/scripts/sx.js`**:
- Add `StyleValue` type (`{_style: true, className, declarations, pseudoRules, mediaRules}`)
- Add `css` primitive to PRIMITIVES (accepts both keywords and dynamic strings)
- Add resolver logic (split variants, lookup from in-memory dict, arbitrary pattern fallback, hash, memoize)
- In `renderElement()`: when `:style` value is StyleValue, add className to element and inject CSS rule into `<style id="sx-css">` (same target as server-sent rules)
- Add `merge-styles` primitive
- Add `defstyle` to SPECIAL_FORMS
- Add style dictionary localStorage caching (same pattern as components):
- On init: check `<script type="text/sx-styles" data-hash="{hash}">`
- Cache hit (hash matches localStorage): load dict from localStorage, skip inline parse
- Cache miss: parse inline dict, store in localStorage, set `sx-style-hash` cookie
- Dict lives in `_styleAtoms` var for `css` primitive to look up at render time
**No separate `sx-styles.js`** — the style dictionary is delivered inline in the full-page shell (like components) and cached in localStorage. No extra HTTP request.
#### Phase 2.4: `defstyle` and `defkeyframes` special forms
**Modify: `shared/sx/evaluator.py`** — add `defstyle` and `defkeyframes`:
```lisp
(defstyle card-base (css :rounded-xl :bg-white :shadow))
(defkeyframes fade-in
(from (css :opacity-0))
(to (css :opacity-100)))
```
`defstyle`: evaluates the body → StyleValue, binds to name in env. Essentially `define` but semantically distinct for tooling.
`defkeyframes`: evaluates each step's `(css ...)` body, builds a `@keyframes` CSS rule, registers it via `register_generated_rule()`, and binds the animation name so `animate-[name]` atoms can reference it.
**Mirror in `shared/sx/async_eval.py`** and `sx.js`.
#### Phase 2.5: Migration tooling + gradual conversion
**New: `shared/sx/tools/class_to_css.py`** — converter script:
- `:class "flex gap-4 p-2"``:style (css :flex :gap-4 :p-2)`
- `(str "base " conditional)` → leave as `:class` or split into static `:style` + dynamic `:class`
- `(if cond "classes-a" "classes-b")``(if cond (css :classes-a) (css :classes-b))`
**Dynamic class construction** (2-3 occurrences in `layout.sx`):
- `(str "bg-" c "-" shade)``(css (str "bg-" c "-" shade))``css` accepts runtime strings, resolves from dictionary client-side (no server round-trip)
- Truly unique values (user brand colors from DB) → inline `style="..."` or `(raw-css "background-color" brand-color)`
#### Phase 2.6: Remove Tailwind
- Delete `tailwind.config.js`, remove tw.css build step
- Remove tw.css parsing from `load_css_registry()`
- Keep extra CSS (basics.css, cards.css, blog-content.css, FontAwesome)
- `css_registry.py` becomes pure runtime registry for generated + extra CSS
### Phase 2 Files
| File | Change |
|------|--------|
| `shared/sx/style_dict.py` | **New** — keyword → CSS declaration mapping (~500 atoms) |
| `shared/sx/style_resolver.py` | **New** — resolve (css ...) → StyleValue, memoized |
| `shared/sx/types.py` | Add `StyleValue` dataclass |
| `shared/sx/primitives.py` | Add `css`, `merge-styles` primitives |
| `shared/sx/html.py` | Handle StyleValue in `:style` attribute rendering |
| `shared/sx/async_eval.py` | Same StyleValue handling in async render path |
| `shared/sx/css_registry.py` | Add `register_generated_rule()` |
| `shared/sx/helpers.py` | Style dict delivery in page shell, cookie check, localStorage caching protocol |
| `shared/sx/evaluator.py` | Add `defstyle` special form |
| `shared/infrastructure/factory.py` | Add `sx-style-hash` cookie/header to CORS |
| `shared/static/scripts/sx.js` | StyleValue, css/merge-styles, defstyle, dict caching, style injection |
| `shared/sx/tools/class_to_css.py` | **New** — migration converter |
### Phase 2 Verification
- **Phase 2.1**: Unit test — `(css :flex :gap-4 :p-2)` returns correct StyleValue
- **Phase 2.2**: Render test — `(div :style (css :flex :gap-4))``<div class="sx-a3f2c1">` + CSS rule registered
- **Phase 2.3**: Browser test — client renders `:style (css ...)` with injected `<style>` rules
- **Phase 2.5**: Convert one .sx file, diff HTML output to verify identical rendering
- **Throughout**: existing `:class "..."` continues to work unchanged

View File

@@ -358,3 +358,89 @@ Each service migrates independently, no coordination needed:
3. Enable SSR for bots (Phase 2) — per-page opt-in
4. Client data primitives (Phase 4) — global once sx.js updated
5. Data-only navigation (Phase 5) — automatic for any `defpage` route
---
## Why: Architectural Rationale
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
### Benefits
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
### Performance and WASM
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
If performance ever becomes a concern, WASM is the escape hatch at three levels:
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
### Server-Driven by Default: The React Question
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
For most of our apps, that's a very short list:
- Toggle a mobile nav panel
- Gallery image switching
- Quantity steppers
- Live search-as-you-type
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
#### What sx has vs React
| React feature | SX status | Verdict |
|---|---|---|
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
| Macros | `defmacro` | Done — React has nothing like this |
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
| Reactive client state | None | **By design.** Server is source of truth. |
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
#### Targeted escape hatches (not a general state system)
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
- `(toggle! el "class")` — CSS class toggle, no server trip
- `(set-attr! el "attr" value)` — attribute manipulation
- `(on-event el "click" handler)` — declarative event binding within sx
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.

65
events/actions.sx Normal file
View File

@@ -0,0 +1,65 @@
;; Events service — inter-service action endpoints
;;
;; Each defaction replaces a Python handler in bp/actions/routes.py.
;; The (service ...) primitive calls the registered CalendarService method
;; with g.s (async session) + keyword args.
(defaction adjust-ticket-quantity (&key entry-id count user-id session-id ticket-type-id)
"Add or remove tickets for a calendar entry."
(do
(service "calendar" "adjust-ticket-quantity"
:entry-id entry-id :count count
:user-id user-id :session-id session-id
:ticket-type-id ticket-type-id)
{"ok" true}))
(defaction claim-entries-for-order (&key order-id user-id session-id page-post-id)
"Claim pending calendar entries for an order."
(do
(service "calendar" "claim-entries-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction claim-tickets-for-order (&key order-id user-id session-id page-post-id)
"Claim pending tickets for an order."
(do
(service "calendar" "claim-tickets-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction confirm-entries-for-order (&key order-id user-id session-id)
"Confirm calendar entries after payment."
(do
(service "calendar" "confirm-entries-for-order"
:order-id order-id :user-id user-id :session-id session-id)
{"ok" true}))
(defaction confirm-tickets-for-order (&key order-id)
"Confirm tickets after payment."
(do
(service "calendar" "confirm-tickets-for-order" :order-id order-id)
{"ok" true}))
(defaction toggle-entry-post (&key entry-id content-type content-id)
"Toggle association between a calendar entry and a content item."
(let ((is-associated (service "calendar" "toggle-entry-post"
:entry-id entry-id
:content-type content-type
:content-id content-id)))
{"is_associated" is-associated}))
(defaction adopt-entries-for-user (&key user-id session-id)
"Transfer anonymous calendar entries to a logged-in user."
(do
(service "calendar" "adopt-entries-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
(defaction adopt-tickets-for-user (&key user-id session-id)
"Transfer anonymous tickets to a logged-in user."
(do
(service "calendar" "adopt-tickets-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, abort, request
@@ -9,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data
async def events_context() -> dict:
@@ -112,7 +111,9 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets",
)
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "events")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
@@ -171,19 +172,25 @@ def create_app() -> "Quart":
"markets": markets,
}
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "events")
# Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets
tickets_bp = register_tickets()
from shared.sx.pages import mount_pages
mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"])
app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp)
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_events_data():
return getattr(g, '_defpage_ctx', {})
# --- oEmbed endpoint ---
@app.get("/oembed")
async def oembed():

View File

@@ -3,6 +3,5 @@ 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 .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -1,139 +1,15 @@
"""Events app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (cart, blog) via the internal action client.
All actions are defined declaratively in ``events/actions.sx`` and
dispatched via the sx query registry. No Python fallbacks needed.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.query_blueprint import create_action_blueprint
from shared.infrastructure.actions import ACTION_HEADER
from shared.services.registry import services
from quart import Blueprint
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adjust-ticket-quantity ---
async def _adjust_ticket_quantity():
data = await request.get_json()
await services.calendar.adjust_ticket_quantity(
g.s,
data["entry_id"],
data["count"],
user_id=data.get("user_id"),
session_id=data.get("session_id"),
ticket_type_id=data.get("ticket_type_id"),
)
return {"ok": True}
_handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity
# --- claim-entries-for-order ---
async def _claim_entries():
data = await request.get_json()
await services.calendar.claim_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-entries-for-order"] = _claim_entries
# --- claim-tickets-for-order ---
async def _claim_tickets():
data = await request.get_json()
await services.calendar.claim_tickets_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-tickets-for-order"] = _claim_tickets
# --- confirm-entries-for-order ---
async def _confirm_entries():
data = await request.get_json()
await services.calendar.confirm_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
)
return {"ok": True}
_handlers["confirm-entries-for-order"] = _confirm_entries
# --- confirm-tickets-for-order ---
async def _confirm_tickets():
data = await request.get_json()
await services.calendar.confirm_tickets_for_order(
g.s, data["order_id"],
)
return {"ok": True}
_handlers["confirm-tickets-for-order"] = _confirm_tickets
# --- toggle-entry-post ---
async def _toggle_entry_post():
data = await request.get_json()
is_associated = await services.calendar.toggle_entry_post(
g.s,
data["entry_id"],
data["content_type"],
data["content_id"],
)
return {"is_associated": is_associated}
_handlers["toggle-entry-post"] = _toggle_entry_post
# --- adopt-entries-for-user ---
async def _adopt_entries():
data = await request.get_json()
await services.calendar.adopt_entries_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-entries-for-user"] = _adopt_entries
# --- adopt-tickets-for-user ---
async def _adopt_tickets():
data = await request.get_json()
await services.calendar.adopt_tickets_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-tickets-for-user"] = _adopt_tickets
bp, _handlers = create_action_blueprint("events")
return bp

View File

@@ -11,7 +11,7 @@ Routes:
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
@@ -67,7 +67,7 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
from shared.sx.page import get_template_context
from sx.sx_components import render_all_events_page, render_all_events_oob
from sxc.pages.renders import render_all_events_page, render_all_events_oob
ctx = await get_template_context()
if is_htmx_request():
@@ -84,8 +84,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
from sx.sx_components import render_all_events_cards
sx_src = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
from sxc.pages.renders import render_all_events_cards
sx_src = render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
return sx_response(sx_src)
@bp.post("/all-tickets/adjust")
@@ -125,7 +125,7 @@ def register() -> Blueprint:
if ident["session_id"] is not None:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
from sxc.pages.tickets 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 sx_response(widget_html + (mini_html or ""))

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
Blueprint, g, request,
)
@@ -15,22 +15,10 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["calendar-admin"])
@bp.get("/description/")
@require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit
from sxc.pages.renders import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar)
return sx_response(html)
@@ -46,7 +34,7 @@ def register():
g.calendar.description = description
await g.s.flush()
from sx.sx_components import render_calendar_description
from sxc.pages.renders import render_calendar_description
html = render_calendar_description(g.calendar, oob=True)
return sx_response(html)
@@ -54,7 +42,7 @@ def register():
@bp.get("/description/view/")
@require_admin
async def calendar_description_view(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description
from sxc.pages.renders import render_calendar_description
html = render_calendar_description(g.calendar)
return sx_response(html)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession
request, make_response, Blueprint, g, abort, session as qsession
)
@@ -158,7 +158,7 @@ def register():
confirmed_entries = visible.confirmed_entries
from shared.sx.page import get_template_context
from sx.sx_components import render_calendar_page, render_calendar_oob
from sxc.pages.renders import render_calendar_page, render_calendar_oob
tctx = await get_template_context()
tctx.update(dict(
@@ -199,7 +199,7 @@ def register():
await update_calendar_description(g.calendar, description)
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
from sxc.pages.calendar import _calendar_admin_main_panel_html
ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx)
return sx_response(html)
@@ -218,13 +218,13 @@ def register():
# If we have post context (blog-embedded mode), update nav
post_data = getattr(g, "post_data", None)
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
from sxc.pages.renders 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 sx.sx_components import render_post_nav_entries_oob
from sxc.pages.entries import render_post_nav_entries_oob
post_id = (post_data.get("post") or {}).get("id")
cals = (

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal
from quart import (
request, render_template, make_response,
request, make_response,
Blueprint, g, redirect, url_for, jsonify,
)
@@ -258,7 +258,7 @@ def register():
"styles": styles,
}
from sx.sx_components import render_day_main_panel
from sxc.pages.renders 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 sx_response(html + (mini_html or ""))
@@ -279,12 +279,12 @@ def register():
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
from sx.sx_components import render_entry_add_form
from sxc.pages.entries import render_entry_add_form
return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots))
@bp.get("/add-button/")
async def add_button(day: int, month: int, year: int, **kwargs):
from sx.sx_components import render_entry_add_button
from sxc.pages.entries import render_entry_add_button
return sx_response(render_entry_add_button(g.calendar, day, month, year))

View File

@@ -1,23 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
g.entry_admin_content = _entry_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-admin"])
return bp

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
request, make_response, Blueprint, g, jsonify
)
from ..calendar_entries.services.entries import (
svc_update_entry,
@@ -111,7 +111,7 @@ def register():
)
# Render OOB nav
from sx.sx_components import render_day_entries_nav_oob
from sxc.pages.entries import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
async def get_post_nav_oob(entry_id: int):
@@ -148,7 +148,7 @@ def register():
).scalars().all()
# Render OOB nav for this post
from sx.sx_components import render_post_nav_entries_oob
from sxc.pages.entries import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oobs.append(nav_oob)
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav,
}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html, _entry_nav_html
ctx = await get_template_context()
g.entry_content = _entry_main_panel_html(ctx)
g.entry_menu = _entry_nav_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(entry_id: int, **rest):
@@ -269,7 +256,7 @@ def register():
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
from sx.sx_components import render_entry_edit_form
from sxc.pages.entries import render_entry_edit_form
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
@bp.put("/")
@@ -433,7 +420,7 @@ def register():
nav_oob = await get_day_nav_oob(year, month, day)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
from sxc.pages.entries import _entry_main_panel_html
tctx = await get_template_context()
html = _entry_main_panel_html(tctx)
@@ -461,7 +448,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@@ -486,7 +473,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@@ -511,7 +498,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@@ -555,7 +542,7 @@ def register():
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_tickets_config
from sxc.pages.entries import render_entry_tickets_config
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
return sx_response(html)
@@ -571,7 +558,7 @@ def register():
total_pages = math.ceil(total / per_page) if total > 0 else 0
va = request.view_args or {}
from sx.sx_components import render_post_search_results
from sxc.pages.entries import render_post_search_results
return sx_response(render_post_search_results(
search_posts, query, page, total_pages,
g.entry, g.calendar,
@@ -605,7 +592,7 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)
@@ -627,7 +614,7 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)

Some files were not shown because too many files have changed in this diff Show More