21 Commits

Author SHA1 Message Date
1a5969202e Fix back-button DOM restoration: process OOB swaps on popstate, disable editor font overrides
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m43s
- Process sx-swap-oob and hx-swap-oob elements in the popstate handler
  so sidebar, filter, menu, and headers are restored on back navigation
- Disable the 62.5% base font-size hack that leaked globally and caused
  all fonts to shrink when navigating to/from the editor
- Cache-bust sx.js to v=20260301d

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:14:32 +00:00
3bc5de126d Add cache busting instruction for sx.js to CLAUDE.md
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:03:51 +00:00
1447122a0c Add on-demand CSS: registry, pre-computed component classes, header compression
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 42s
- Parse tw.css into per-class lookup registry at startup
- Pre-scan component CSS classes at registration time (avoid per-request regex)
- Compress SX-Css header: 8-char hash replaces full class list (LRU cache)
- Add ;@css comment annotation for dynamically constructed class names
- Safelist bg-sky-{100..400} in Tailwind config for menu-row-sx dynamic shades
- Client sends/receives hash, falls back gracefully on cache miss

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:39:57 +00:00
ab45e21c7c Cache-bust sx.js and disable static file caching
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:46:26 +00:00
c0d369eb8e Refactor SX templates: shared components, Python migration, cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:34:34 +00:00
755313bd29 Add market admin CRUD: list, create, and delete marketplaces
Replaces placeholder "Market admin" text with a functional admin panel
that lists marketplaces for a page and supports create/delete via sx,
mirroring the events calendar admin pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:16:39 +00:00
01a67029f0 Replace Tailwind CDN with pre-built CSS via standalone CLI
- Add shared/static/styles/tailwind.css as Tailwind v4 input with
  explicit @source paths for all service templates and safelisted
  dynamic classes (bg-{colour}-{shade}, text-{size})
- Build to shared/static/styles/tw.css (93KB minified)
- Replace <script src="cdn.tailwindcss.com"> with <link> to tw.css
  in sx page shell, Jinja _head.html, and ~base-shell component
- Add build-tw.sh convenience script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:23:20 +00:00
b54f7b4b56 Fix SX history, OOB header swaps, cross-service nav components
- Always re-fetch on popstate (drop LRU cache) for fresh content on back/forward
- Save/restore scroll position via pushState
- Add id="root-header-child" to ~app-body so OOB swaps can target it
- Fix OOB renderers: nest root-row inside root-header-child swap instead of
  separate OOB that clobbers it
- Fix 3+ header rows dropped: wrap all headers in single fragment instead of
  concatenating outside (<> ...)
- Strip <script data-components> from text/sx responses before renderToString
- Fall back to location.assign for cross-origin pushState (SecurityError)
- Move blog/sx/nav.sx to shared/sx/templates/ so all services have nav components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:17:39 +00:00
5ede32e21c Activate regular script tags after sx swap operations
Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
Add _activateScripts() to _swapContent that recreates script tags
(without type or type=text/javascript) as live elements. This fixes
editor.js not loading when navigating to edit pages via sx-get.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:39:07 +00:00
7aea1f1be9 Activate script tags in raw! DOM output
Scripts inserted via innerHTML (template.content) don't execute.
When raw! renders HTML containing <script> tags, recreate them as
live elements so the browser fetches and executes them. Fixes
editor.js not loading on HTMX navigation to edit pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:25:52 +00:00
0ef4a93a92 Wrap raw Jinja HTML in (raw! "...") for sx source embedding
Post edit, data, entries, and settings pages pass raw Jinja HTML
as content to full_page_sx/oob_page_sx, which wraps it in SxExpr().
This injects unescaped HTML directly into sx source, breaking the
parser. Fix by serializing the HTML into a (raw! "...") expression
that the sx evaluator renders unescaped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:16:42 +00:00
48696498ef Wrap multi-expression sx returns in fragments to prevent kwarg truncation
When multiple sx expressions are concatenated and passed as a kwarg
value via SxExpr(), the parser only sees the first as the value — the
rest become extra args silently dropped by the component. Wrap in (<>)
fragments in render_editor_panel() and _page_cards_sx().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:56:05 +00:00
b7d95a8b4e Fix sx.js component kwarg evaluation: distinguish expressions from data
Three issues with the eager kwarg evaluation in renderComponentDOM and
renderStrComponent:

1. Data arrays (e.g. tags list of dicts) were being passed to sxEval
   which tried to call a dict as a function — causing blank pages.
   Fix: only evaluate arrays with a Symbol head (actual expressions);
   pass data arrays through as-is.

2. Expression arrays like (get t "src") inside map lambdas lost their
   scope when deferred — causing "get,t,src" URLs. Fix: eagerly evaluate
   these Symbol-headed expressions in the caller's env.

3. Bare symbol `t` used as boolean in editor.sx threw "Undefined symbol".
   Fix: use `true` literal instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:51:07 +00:00
e7d5c6734b Fix renderDOM swallowing pre-rendered DOM nodes as empty dicts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
renderComponentDOM now eagerly renders kwarg values that are render
expressions (HTML tags, <>, ~components) into DOM nodes. But renderDOM
treated any non-array object as a dict and returned an empty fragment,
silently discarding pre-rendered content. Add a nodeType check to pass
DOM nodes through unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:41:51 +00:00
e4a6d2dfc8 Fix renderStrComponent with same eager-eval pattern as renderComponentDOM
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m18s
The string renderer's component call had the same deferred-evaluation
bug — and this is the path actually used for blog card rendering via
renderToString. Apply the same _isRenderExpr check to route render-only
forms through renderStr while data expressions go through sxEval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:50:42 +00:00
0a5562243b Fix renderComponentDOM: route render-only forms through renderDOM
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
The previous fix eagerly evaluated all kwarg expressions via sxEval,
which broke render-only forms (<>, raw!, HTML tags, ~components) that
only exist in the render pipeline. Now detect render expressions by
checking if the head symbol is an HTML/SVG tag, <>, raw!, or ~component,
and route those through renderDOM while data expressions still go
through sxEval for correct scope resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:45:43 +00:00
2b41aaa6ce Fix renderComponentDOM evaluating kwarg expressions in wrong scope
renderComponentDOM was deferring evaluation of complex expressions
(arrays) passed as component kwargs, storing raw AST instead.  When the
component body later used these values as attributes, the caller's env
(with lambda params like t, a) was no longer available, producing
stringified arrays like "get,t,src" as attribute values — which browsers
interpreted as relative URLs.

Evaluate all non-literal kwarg values eagerly in the caller's env,
matching the behavior of callComponent and the Python-side renderer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:40:50 +00:00
cfe66e5342 Fix back_populates typo in Post.authors relationship
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:36:18 +00:00
382d1b7c7a Decouple blog models and BlogService from shared layer
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
Move Post/Author/Tag/PostAuthor/PostTag/PostUser models from
shared/models/ghost_content.py to blog/models/content.py so blog-domain
models no longer live in the shared layer. Replace the shared
SqlBlogService + BlogService protocol with a blog-local singleton
(blog_service), and switch entry_associations.py from direct DB access
to HTTP fetch_data("blog", "post-by-id") to respect the inter-service
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:28:11 +00:00
a580a53328 Fix alembic revision IDs to match existing naming convention
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:38:43 +00:00
0f9af31ffe Phase 0+1: native post writes, Ghost no longer write-primary
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
- Final sync script with HTML verification + author→user migration
- Make ghost_id nullable on posts/authors/tags, add UUID/timestamp defaults
- Add user profile fields (bio, slug, profile_image, etc.) to User model
- New PostUser M2M table (replaces post_authors for new posts)
- PostWriter service: direct DB CRUD with Lexical rendering, optimistic
  locking, AP federation, tag upsert
- Rewrite create/edit/settings routes to use PostWriter (no Ghost API calls)
- Neuter Ghost webhooks (post/page/author/tag → 204 no-op)
- Disable Ghost startup sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:33:37 +00:00
96 changed files with 5958 additions and 1681 deletions

View File

@@ -5,6 +5,7 @@ Cooperative web platform: federated content, commerce, events, and media process
## Deployment
- **Do NOT push** until explicitly told to. Pushes reload code to dev automatically.
- **Cache busting:** After editing `sx.js`, bump the `?v=` query string in `shared/sx/helpers.py` (search for `sx.js?v=`).
## Project Structure

View File

@@ -0,0 +1,43 @@
"""Add author profile fields to users table.
Merges Ghost Author profile data into User — bio, profile_image, cover_image,
website, location, facebook, twitter, slug, is_admin.
Revision ID: 0003
Revises: 0002_hash_oauth_tokens
"""
from alembic import op
import sqlalchemy as sa
revision = "acct_0003"
down_revision = "acct_0002"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("users", sa.Column("slug", sa.String(191), nullable=True))
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
op.add_column("users", sa.Column("profile_image", sa.Text(), nullable=True))
op.add_column("users", sa.Column("cover_image", sa.Text(), nullable=True))
op.add_column("users", sa.Column("website", sa.Text(), nullable=True))
op.add_column("users", sa.Column("location", sa.Text(), nullable=True))
op.add_column("users", sa.Column("facebook", sa.Text(), nullable=True))
op.add_column("users", sa.Column("twitter", sa.Text(), nullable=True))
op.add_column("users", sa.Column(
"is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false"),
))
op.create_index("ix_users_slug", "users", ["slug"], unique=True)
def downgrade():
op.drop_index("ix_users_slug")
op.drop_column("users", "is_admin")
op.drop_column("users", "twitter")
op.drop_column("users", "facebook")
op.drop_column("users", "location")
op.drop_column("users", "website")
op.drop_column("users", "cover_image")
op.drop_column("users", "profile_image")
op.drop_column("users", "bio")
op.drop_column("users", "slug")

View File

@@ -1,23 +1,5 @@
;; Auth page components (login, device, check email)
(defcomp ~account-login-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error)))
(defcomp ~account-login-form (&key error action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
;; Auth page components (device auth — account-specific)
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
(defcomp ~account-device-error (&key error)
(when error
@@ -45,14 +27,3 @@
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
(defcomp ~account-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
error)))
(defcomp ~account-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
error))

View File

@@ -41,8 +41,3 @@
name)
logout)
labels)))
;; Header child wrapper
(defcomp ~account-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
inner))

View File

@@ -10,12 +10,6 @@
:class cls :role "switch" :aria-checked checked
(span :class knob-cls))))
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
(div :id id :class "flex items-center"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
:role "switch" :aria-checked "false"
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"))))
(defcomp ~account-newsletter-item (&key name desc toggle)
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"

View File

@@ -137,10 +137,13 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
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-off",
"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",
)
@@ -196,10 +199,10 @@ def _login_page_content(ctx: dict) -> str:
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_sx = sx_call("account-login-error", error=error) if error else ""
error_sx = sx_call("auth-error-banner", error=error) if error else ""
return sx_call(
"account-login-form",
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
@@ -347,11 +350,11 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
from markupsafe import escape
error_sx = sx_call(
"account-check-email-error", error=str(escape(email_error))
"auth-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"account-check-email",
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)

View File

@@ -2,7 +2,7 @@ from alembic import context
from shared.db.alembic_env import run_alembic
MODELS = [
"shared.models.ghost_content",
"blog.models.content",
"shared.models.kv",
"shared.models.menu_item",
"shared.models.menu_node",
@@ -13,6 +13,7 @@ MODELS = [
TABLES = frozenset({
"posts", "authors", "post_authors", "tags", "post_tags",
"post_users",
"snippets", "tag_groups", "tag_group_tags",
"menu_items", "menu_nodes", "kv",
"page_configs",

View File

@@ -0,0 +1,67 @@
"""Make ghost_id nullable, add defaults, create post_users M2M table.
Revision ID: 0004
Revises: 0003_add_page_configs
"""
from alembic import op
import sqlalchemy as sa
revision = "blog_0004"
down_revision = "blog_0003"
branch_labels = None
depends_on = None
def upgrade():
# Make ghost_id nullable
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=True)
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=True)
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=True)
# Add server defaults for Post
op.alter_column(
"posts", "uuid",
existing_type=sa.String(64),
server_default=sa.text("gen_random_uuid()"),
)
op.alter_column(
"posts", "updated_at",
existing_type=sa.DateTime(timezone=True),
server_default=sa.text("now()"),
)
op.alter_column(
"posts", "created_at",
existing_type=sa.DateTime(timezone=True),
server_default=sa.text("now()"),
)
# Create post_users M2M table (replaces post_authors for new posts)
op.create_table(
"post_users",
sa.Column("post_id", sa.Integer, sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
sa.Column("user_id", sa.Integer, primary_key=True),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("ix_post_users_user_id", "post_users", ["user_id"])
# Backfill post_users from post_authors for posts that already have user_id.
# This maps each post's authors to the post's user_id (primary author).
# Multi-author mapping requires the full sync script.
op.execute("""
INSERT INTO post_users (post_id, user_id, sort_order)
SELECT p.id, p.user_id, 0
FROM posts p
WHERE p.user_id IS NOT NULL
AND p.deleted_at IS NULL
ON CONFLICT DO NOTHING
""")
def downgrade():
op.drop_table("post_users")
op.alter_column("posts", "created_at", existing_type=sa.DateTime(timezone=True), server_default=None)
op.alter_column("posts", "updated_at", existing_type=sa.DateTime(timezone=True), server_default=None)
op.alter_column("posts", "uuid", existing_type=sa.String(64), server_default=None)
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=False)
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=False)
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=False)

View File

@@ -134,7 +134,7 @@ def create_app() -> "Quart":
async def oembed():
from urllib.parse import urlparse
from quart import jsonify
from shared.services.registry import services
from services import blog_service
from shared.infrastructure.urls import blog_url
from shared.infrastructure.oembed import build_oembed_response
@@ -147,7 +147,7 @@ def create_app() -> "Quart":
if not slug:
return jsonify({"error": "could not extract slug"}), 404
post = await services.blog.get_post_by_slug(g.s, slug)
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return jsonify({"error": "not found"}), 404

View File

@@ -14,7 +14,6 @@ from quart import (
url_for,
)
from .ghost_db import DBClient # adjust import path
from shared.db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
@@ -47,33 +46,9 @@ def register(url_prefix, title):
@blogs_bp.before_app_serving
async def init():
from .ghost.ghost_sync import sync_all_content_from_ghost
from sqlalchemy import text
import logging
logger = logging.getLogger(__name__)
# Advisory lock prevents multiple Hypercorn workers from
# running the sync concurrently (which causes PK conflicts).
async with get_session() as s:
got_lock = await s.scalar(text("SELECT pg_try_advisory_lock(900001)"))
if not got_lock:
await s.rollback() # clean up before returning connection to pool
return
try:
await sync_all_content_from_ghost(s)
await s.commit()
except Exception:
logger.exception("Ghost sync failed — will retry on next deploy")
try:
await s.rollback()
except Exception:
pass
finally:
try:
await s.execute(text("SELECT pg_advisory_unlock(900001)"))
await s.commit()
except Exception:
pass # lock auto-releases when session closes
# Ghost startup sync disabled (Phase 1) — blog service owns content
# directly. The final_ghost_sync.py script was run before cutover.
pass
@blogs_bp.before_request
def route():
@@ -258,9 +233,8 @@ def register(url_prefix, title):
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
from .ghost.ghost_posts import create_post
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_post
from services.post_writer import create_post as writer_create
form = await request.form
title = form.get("title", "").strip() or "Untitled"
@@ -290,35 +264,24 @@ def register(url_prefix, title):
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost
ghost_post = await create_post(
# Create directly in db_blog
post = await writer_create(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB
await sync_single_post(g.s, ghost_post["id"])
await g.s.flush()
# Set user_id on the newly created post
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_post["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the edit page (post is likely a draft, so public detail would 404)
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
# Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug)))
@blogs_bp.get("/new-page/")
@@ -340,9 +303,8 @@ def register(url_prefix, title):
@blogs_bp.post("/new-page/")
@require_admin
async def new_page_save():
from .ghost.ghost_posts import create_page
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_page
from services.post_writer import create_page as writer_create_page
form = await request.form
title = form.get("title", "").strip() or "Untitled"
@@ -374,35 +336,24 @@ def register(url_prefix, title):
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost (as page)
ghost_page = await create_page(
# Create directly in db_blog
page = await writer_create_page(
g.s,
title=title,
lexical_json=lexical_raw,
status=status,
user_id=g.user.id,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB (uses pages endpoint)
await sync_single_page(g.s, ghost_page["id"])
await g.s.flush()
# Set user_id on the newly created page
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_page["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"])))
return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug)))
@blogs_bp.get("/drafts/")

View File

@@ -1,15 +1,11 @@
# suma_browser/webhooks.py
# Ghost webhooks — neutered (Phase 1).
#
# Post/page/author/tag handlers return 204 no-op.
# Member webhook remains active (membership sync handled by account service).
from __future__ import annotations
import os
from quart import Blueprint, request, abort, Response, g
from quart import Blueprint, request, abort, Response
from ..ghost.ghost_sync import (
sync_single_page,
sync_single_post,
sync_single_author,
sync_single_tag,
)
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
@@ -32,6 +28,7 @@ def _extract_id(data: dict, key: str) -> str | None:
@csrf_exempt
@ghost_webhooks.route("/member/", methods=["POST"])
async def webhook_member() -> Response:
"""Member webhook still active — delegates to account service."""
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
@@ -39,7 +36,6 @@ async def webhook_member() -> Response:
if not ghost_id:
abort(400, "no member id")
# Delegate to account service (membership data lives in db_account)
from shared.infrastructure.actions import call_action
try:
await call_action(
@@ -52,61 +48,25 @@ async def webhook_member() -> Response:
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
return Response(status=204)
# --- Neutered handlers: Ghost no longer writes content ---
@csrf_exempt
@ghost_webhooks.post("/post/")
@clear_cache(tag='blog')
async def webhook_post() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "post")
if not ghost_id:
abort(400, "no post id")
await sync_single_post(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/page/")
@clear_cache(tag='blog')
async def webhook_page() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "page")
if not ghost_id:
abort(400, "no page id")
await sync_single_page(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/author/")
@clear_cache(tag='blog')
async def webhook_author() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
if not ghost_id:
abort(400, "no author id")
await sync_single_author(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/tag/")
@clear_cache(tag='blog')
async def webhook_tag() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "tag")
if not ghost_id:
abort(400, "no tag id")
await sync_single_tag(g.s, ghost_id)
return Response(status=204)

View File

@@ -9,7 +9,7 @@ from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
from services import blog_service
def register() -> Blueprint:
@@ -36,7 +36,7 @@ def register() -> Blueprint:
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await services.blog.get_post_by_slug(g.s, slug)
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
@@ -46,7 +46,7 @@ def register() -> Blueprint:
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await services.blog.get_post_by_id(g.s, post_id)
post = await blog_service.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
@@ -59,7 +59,7 @@ def register() -> Blueprint:
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await services.blog.get_posts_by_ids(g.s, ids)
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
@@ -69,7 +69,7 @@ def register() -> Blueprint:
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await services.blog.search_posts(g.s, query, page, per_page)
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

View File

@@ -59,9 +59,10 @@ def register():
href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
selected = "true" if (item.slug == first_seg
or item.slug == app_name) else "false"
img = sx_call("blog-nav-item-image",
img = sx_call("img-or-placeholder",
src=getattr(item, "feature_image", None),
label=getattr(item, "label", item.slug))
alt=getattr(item, "label", item.slug),
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
@@ -72,7 +73,8 @@ def register():
href = artdag_url("/")
selected = "true" if ("artdag" == first_seg
or "artdag" == app_name) else "false"
img = sx_call("blog-nav-item-image", src=None, label="art-dag")
img = sx_call("img-or-placeholder", src=None, alt="art-dag",
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
@@ -101,13 +103,15 @@ def register():
right_hs = ("on click set #" + container_id
+ ".scrollLeft to #" + container_id + ".scrollLeft + 200")
return sx_call("blog-nav-wrapper",
arrow_cls=arrow_cls,
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper",
container_id=container_id,
arrow_cls=arrow_cls,
left_hs=left_hs,
scroll_hs=scroll_hs,
right_hs=right_hs,
items=SxExpr(items_frag))
items=SxExpr(items_frag),
oob=True)
_handlers["nav-tree"] = _nav_tree_handler
@@ -125,7 +129,7 @@ def register():
data_app="blog")
async def _link_card_handler():
from shared.services.registry import services
from services import blog_service
from shared.infrastructure.urls import blog_url
slug = request.args.get("slug", "")
@@ -137,7 +141,7 @@ def register():
parts = []
for s in slugs:
parts.append(f"<!-- fragment:{s} -->")
post = await services.blog.get_post_by_slug(g.s, s)
post = await blog_service.get_post_by_slug(g.s, s)
if post:
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
return "\n".join(parts)
@@ -145,7 +149,7 @@ def register():
# Single mode
if not slug:
return ""
post = await services.blog.get_post_by_slug(g.s, slug)
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return ""
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))

View File

@@ -15,6 +15,43 @@ from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.utils import host_url
def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect.
The templates were written for Ghost Admin API responses, so we mimic
that structure (dot-access on dicts via Jinja) from ORM columns.
"""
d: dict = {}
for col in (
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
"feature_image", "feature_image_alt", "feature_image_caption",
"excerpt", "custom_excerpt", "visibility", "status", "featured",
"is_page", "email_only", "canonical_url",
"meta_title", "meta_description",
"og_image", "og_title", "og_description",
"twitter_image", "twitter_title", "twitter_description",
"custom_template", "reading_time", "comment_id",
):
d[col] = getattr(post, col, None)
# Timestamps as ISO strings (templates do [:16] slicing)
for ts in ("published_at", "updated_at", "created_at"):
val = getattr(post, ts, None)
d[ts] = val.isoformat() if val else ""
# Tags as list of dicts with .name (for Jinja map(attribute='name'))
if hasattr(post, "tags") and post.tags:
d["tags"] = [{"name": t.name, "slug": t.slug, "id": t.id} for t in post.tags]
else:
d["tags"] = []
# email/newsletter — not available without Ghost, set safe defaults
d["email"] = None
d["newsletter"] = None
return d
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@@ -227,7 +264,7 @@ def register():
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
associated_entry_ids = await get_post_entry_ids(post_id)
html = await render_template(
"_types/post/admin/_calendar_view.html",
@@ -256,7 +293,7 @@ def register():
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
associated_entry_ids = await get_post_entry_ids(post_id)
# Load ALL calendars (not just this post's calendars)
result = await g.s.execute(
@@ -295,7 +332,7 @@ def register():
from quart import jsonify
post_id = g.post_data["post"]["id"]
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
is_associated, error = await toggle_entry_association(post_id, entry_id)
if error:
return jsonify({"message": error, "errors": {}}), 400
@@ -303,7 +340,7 @@ def register():
await g.s.flush()
# Return updated association status
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
associated_entry_ids = await get_post_entry_ids(post_id)
# Load ALL calendars
result = await g.s.execute(
@@ -318,7 +355,7 @@ def register():
await g.s.refresh(calendar, ["entries", "post"])
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, post_id)
associated_entries = await get_associated_entries(post_id)
# Load calendars for this post (for nav display)
calendars = (
@@ -341,11 +378,18 @@ def register():
@bp.get("/settings/")
@require_post_author
async def settings(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
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
@@ -368,12 +412,10 @@ def register():
@bp.post("/settings/")
@require_post_author
async def settings_save(slug: str):
from ...blog.ghost.ghost_posts import update_post_settings
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from services.post_writer import update_post_settings, OptimisticLockError
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
post_id = g.post_data["post"]["id"]
form = await request.form
updated_at = form.get("updated_at", "")
@@ -406,49 +448,55 @@ def register():
kwargs["featured"] = form.get("featured") == "on"
kwargs["email_only"] = form.get("email_only") == "on"
# Tags — comma-separated string → list of {"name": "..."} dicts
# Tags — comma-separated string → list of names
tags_str = form.get("tags", "").strip()
if tags_str:
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
else:
kwargs["tags"] = []
tag_names = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
# Update in Ghost
await update_post_settings(
ghost_id=ghost_id,
updated_at=updated_at,
is_page=is_page,
**kwargs,
)
try:
post = await update_post_settings(
g.s,
post_id=post_id,
expected_updated_at=updated_at,
tag_names=tag_names,
**kwargs,
)
except OptimisticLockError:
from urllib.parse import quote
return redirect(
host_url(url_for("blog.post.admin.settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
# Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1")
@bp.get("/edit/")
@require_post_author
async def edit(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
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
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
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", "")
# Newsletters live in db_account — fetch via HTTP
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
# Convert dicts to objects with .name/.ghost_id attributes for template compat
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
@@ -475,20 +523,16 @@ def register():
@require_post_author
async def edit_save(slug: str):
import json
from ...blog.ghost.ghost_posts import update_post
from ...blog.ghost.lexical_validator import validate_lexical
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from services.post_writer import update_post as writer_update, OptimisticLockError
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
post_id = g.post_data["post"]["id"]
form = await request.form
title = form.get("title", "").strip()
lexical_raw = form.get("lexical", "")
updated_at = form.get("updated_at", "")
status = form.get("status", "draft")
publish_mode = form.get("publish_mode", "web")
newsletter_slug = form.get("newsletter_slug", "").strip() or None
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
@@ -504,76 +548,51 @@ def register():
if not ok:
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
# Update in Ghost (content save — no status change yet)
ghost_post = await update_post(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=title or None,
updated_at=updated_at,
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
is_page=is_page,
)
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
publish_requested_msg = None
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
if already_emailed and publish_mode in ("email", "both"):
publish_mode = "web"
# Determine effective status
effective_status: str | None = None
current_status = g.post_data["post"].get("status", "draft")
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
# Non-admin requesting publish: don't send status to Ghost, set local flag
if status == "published" and current_status != "published" and not is_admin:
# Non-admin requesting publish: keep as draft, set local flag
publish_requested_msg = "Publish requested — an admin will review."
elif status and status != ghost_post.get("status"):
# Status is changing — determine email params based on publish_mode
email_kwargs: dict = {}
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
email_kwargs["newsletter_slug"] = newsletter_slug
email_kwargs["email_segment"] = "all"
if publish_mode == "email":
email_kwargs["email_only"] = True
elif status and status != current_status:
effective_status = status
from ...blog.ghost.ghost_posts import update_post as _up
ghost_post = await _up(
ghost_id=ghost_id,
try:
post = await writer_update(
g.s,
post_id=post_id,
lexical_json=lexical_raw,
title=None,
updated_at=ghost_post["updated_at"],
status=status,
is_page=is_page,
**email_kwargs,
title=title or None,
expected_updated_at=updated_at,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
status=effective_status,
)
except OptimisticLockError:
return redirect(
host_url(url_for("blog.post.admin.edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
# Handle publish_requested flag
if publish_requested_msg:
post.publish_requested = True
elif status == "published" and is_admin:
post.publish_requested = False
await g.s.flush()
# Handle publish_requested flag on the local post
from models.ghost_content import Post
from sqlalchemy import select as sa_select
local_post = (await g.s.execute(
sa_select(Post).where(Post.ghost_id == ghost_id)
)).scalar_one_or_none()
if local_post:
if publish_requested_msg:
local_post.publish_requested = True
elif status == "published" and is_admin:
local_post.publish_requested = False
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
# Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from shared.contracts.dtos import MarketPlaceDTO
from shared.infrastructure.actions import call_action, ActionError
from shared.services.registry import services
from services import blog_service
class MarketError(ValueError):
@@ -33,7 +33,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = await services.blog.get_post_by_id(sess, post_id)
post = await blog_service.get_post_by_id(sess, post_id)
if not post:
raise MarketError(f"Post {post_id} does not exist.")
@@ -57,7 +57,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
post = await blog_service.get_post_by_slug(sess, post_slug)
if not post:
return False

View File

@@ -1,4 +1,4 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
from .content import Post, Author, Tag, PostAuthor, PostTag, PostUser
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag

View File

@@ -19,7 +19,7 @@ class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -50,8 +50,8 @@ class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, server_default=func.gen_random_uuid())
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
@@ -89,8 +89,8 @@ class Post(Base):
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
user_id: Mapped[Optional[int]] = mapped_column(
@@ -136,7 +136,7 @@ class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -192,3 +192,15 @@ class PostTag(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
class PostUser(Base):
"""Multi-author M2M: links posts to users (cross-DB, no FK on user_id)."""
__tablename__ = "post_users"
post_id: Mapped[int] = mapped_column(
ForeignKey("posts.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(
Integer, primary_key=True, index=True,
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View File

@@ -1,3 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag,
from .content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostUser,
)

View File

@@ -0,0 +1,469 @@
#!/usr/bin/env python3
"""Final Ghost → db_blog sync with HTML verification + author→user migration.
Run once before cutting over to native writes (Phase 1).
Usage:
cd blog && python -m scripts.final_ghost_sync
Requires GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY, DATABASE_URL,
and DATABASE_URL_ACCOUNT env vars.
"""
from __future__ import annotations
import asyncio
import difflib
import logging
import os
import re
import sys
import httpx
from sqlalchemy import select, func, delete
# Ensure project root is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "shared"))
from shared.db.base import Base # noqa: E402
from shared.db.session import get_session, get_account_session, _engine # noqa: E402
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt # noqa: E402
from blog.models.content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402
from shared.models.user import User # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("final_ghost_sync")
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
def _slugify(name: str) -> str:
s = name.strip().lower()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_]+", "-", s)
return s.strip("-")
# ---------------------------------------------------------------------------
# Ghost API fetch
# ---------------------------------------------------------------------------
async def _fetch_all(endpoint: str) -> list[dict]:
"""Fetch all items from a Ghost Admin API endpoint."""
url = (
f"{GHOST_ADMIN_API_URL}/{endpoint}/"
"?include=authors,tags&limit=all"
"&formats=html,plaintext,mobiledoc,lexical"
)
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(url, headers=_auth_header())
resp.raise_for_status()
key = endpoint # "posts" or "pages"
return resp.json().get(key, [])
async def fetch_all_ghost_content() -> list[dict]:
"""Fetch all posts + pages from Ghost."""
posts, pages = await asyncio.gather(
_fetch_all("posts"),
_fetch_all("pages"),
)
for p in pages:
p["page"] = True
return posts + pages
# ---------------------------------------------------------------------------
# Author → User migration
# ---------------------------------------------------------------------------
async def migrate_authors_to_users(author_bucket: dict[str, dict]) -> dict[str, int]:
"""Ensure every Ghost author has a corresponding User in db_account.
Returns mapping of ghost_author_id → user_id.
"""
ghost_id_to_user_id: dict[str, int] = {}
async with get_account_session() as sess:
async with sess.begin():
for ghost_author_id, ga in author_bucket.items():
email = (ga.get("email") or "").strip().lower()
if not email:
log.warning(
"Author %s (%s) has no email — skipping user creation",
ghost_author_id, ga.get("name"),
)
continue
# Find existing user by email
user = (await sess.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
if user is None:
# Auto-create user for this Ghost author
user = User(
email=email,
name=ga.get("name"),
slug=ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0]),
bio=ga.get("bio"),
profile_image=ga.get("profile_image"),
cover_image=ga.get("cover_image"),
website=ga.get("website"),
location=ga.get("location"),
facebook=ga.get("facebook"),
twitter=ga.get("twitter"),
is_admin=True, # Ghost authors are admins
)
sess.add(user)
await sess.flush()
log.info("Created user %d for author %s (%s)", user.id, ga.get("name"), email)
else:
# Update profile fields from Ghost author (fill in blanks)
if not user.slug:
user.slug = ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0])
if not user.name and ga.get("name"):
user.name = ga["name"]
if not user.bio and ga.get("bio"):
user.bio = ga["bio"]
if not user.profile_image and ga.get("profile_image"):
user.profile_image = ga["profile_image"]
if not user.cover_image and ga.get("cover_image"):
user.cover_image = ga["cover_image"]
if not user.website and ga.get("website"):
user.website = ga["website"]
if not user.location and ga.get("location"):
user.location = ga["location"]
if not user.facebook and ga.get("facebook"):
user.facebook = ga["facebook"]
if not user.twitter and ga.get("twitter"):
user.twitter = ga["twitter"]
await sess.flush()
log.info("Updated user %d profile from author %s", user.id, ga.get("name"))
ghost_id_to_user_id[ghost_author_id] = user.id
return ghost_id_to_user_id
async def populate_post_users(
data: list[dict],
ghost_author_to_user: dict[str, int],
) -> int:
"""Populate post_users M2M and set user_id on all posts from author mapping.
Returns number of post_users rows created.
"""
rows_created = 0
async with get_session() as sess:
async with sess.begin():
for gp in data:
ghost_post_id = gp["id"]
post = (await sess.execute(
select(Post).where(Post.ghost_id == ghost_post_id)
)).scalar_one_or_none()
if not post:
continue
# Set primary user_id from primary author
pa = gp.get("primary_author")
if pa and pa["id"] in ghost_author_to_user:
post.user_id = ghost_author_to_user[pa["id"]]
# Build post_users from all authors
await sess.execute(
delete(PostUser).where(PostUser.post_id == post.id)
)
seen_user_ids: set[int] = set()
for idx, a in enumerate(gp.get("authors") or []):
uid = ghost_author_to_user.get(a["id"])
if uid and uid not in seen_user_ids:
seen_user_ids.add(uid)
sess.add(PostUser(post_id=post.id, user_id=uid, sort_order=idx))
rows_created += 1
await sess.flush()
return rows_created
# ---------------------------------------------------------------------------
# Content sync (reuse ghost_sync upsert logic)
# ---------------------------------------------------------------------------
async def run_sync() -> dict:
"""Run full Ghost content sync and author→user migration."""
from bp.blog.ghost.ghost_sync import (
_upsert_author,
_upsert_tag,
_upsert_post,
)
log.info("Fetching all content from Ghost...")
data = await fetch_all_ghost_content()
log.info("Received %d posts/pages from Ghost", len(data))
# Collect authors and tags
author_bucket: dict[str, dict] = {}
tag_bucket: dict[str, dict] = {}
for p in data:
for a in p.get("authors") or []:
author_bucket[a["id"]] = a
if p.get("primary_author"):
author_bucket[p["primary_author"]["id"]] = p["primary_author"]
for t in p.get("tags") or []:
tag_bucket[t["id"]] = t
if p.get("primary_tag"):
tag_bucket[p["primary_tag"]["id"]] = p["primary_tag"]
# Step 1: Upsert content into db_blog (existing Ghost sync logic)
async with get_session() as sess:
async with sess.begin():
author_map: dict[str, Author] = {}
for ga in author_bucket.values():
a = await _upsert_author(sess, ga)
author_map[ga["id"]] = a
log.info("Upserted %d authors (legacy table)", len(author_map))
tag_map: dict[str, Tag] = {}
for gt in tag_bucket.values():
t = await _upsert_tag(sess, gt)
tag_map[gt["id"]] = t
log.info("Upserted %d tags", len(tag_map))
for gp in data:
await _upsert_post(sess, gp, author_map, tag_map)
log.info("Upserted %d posts/pages", len(data))
# Step 2: Migrate authors → users in db_account
log.info("")
log.info("--- Migrating Ghost authors → Users ---")
ghost_author_to_user = await migrate_authors_to_users(author_bucket)
log.info("Mapped %d Ghost authors to User records", len(ghost_author_to_user))
# Step 3: Populate post_users M2M and set user_id
log.info("")
log.info("--- Populating post_users M2M ---")
pu_rows = await populate_post_users(data, ghost_author_to_user)
log.info("Created %d post_users rows", pu_rows)
n_posts = sum(1 for p in data if not p.get("page"))
n_pages = sum(1 for p in data if p.get("page"))
return {
"posts": n_posts,
"pages": n_pages,
"authors": len(author_bucket),
"tags": len(tag_bucket),
"users_mapped": len(ghost_author_to_user),
"post_users_rows": pu_rows,
}
# ---------------------------------------------------------------------------
# HTML rendering verification
# ---------------------------------------------------------------------------
def _normalize_html(html: str | None) -> str:
if not html:
return ""
return re.sub(r"\s+", " ", html.strip())
async def verify_html_rendering() -> dict:
"""Re-render all posts from lexical and compare with stored HTML."""
from bp.blog.ghost.lexical_renderer import render_lexical
import json
diffs_found = 0
posts_checked = 0
posts_no_lexical = 0
async with get_session() as sess:
result = await sess.execute(
select(Post).where(
Post.deleted_at.is_(None),
Post.status == "published",
)
)
posts = result.scalars().all()
for post in posts:
if not post.lexical:
posts_no_lexical += 1
continue
posts_checked += 1
try:
rendered = render_lexical(json.loads(post.lexical))
except Exception as e:
log.error(
"Render failed for post %d (%s): %s",
post.id, post.slug, e,
)
diffs_found += 1
continue
ghost_html = _normalize_html(post.html)
our_html = _normalize_html(rendered)
if ghost_html != our_html:
diffs_found += 1
diff = difflib.unified_diff(
ghost_html.splitlines(keepends=True),
our_html.splitlines(keepends=True),
fromfile=f"ghost/{post.slug}",
tofile=f"rendered/{post.slug}",
n=2,
)
diff_text = "".join(diff)
if len(diff_text) > 2000:
diff_text = diff_text[:2000] + "\n... (truncated)"
log.warning(
"HTML diff for post %d (%s):\n%s",
post.id, post.slug, diff_text,
)
return {
"checked": posts_checked,
"no_lexical": posts_no_lexical,
"diffs": diffs_found,
}
# ---------------------------------------------------------------------------
# Verification queries
# ---------------------------------------------------------------------------
async def run_verification() -> dict:
async with get_session() as sess:
total_posts = await sess.scalar(
select(func.count(Post.id)).where(Post.deleted_at.is_(None))
)
null_html = await sess.scalar(
select(func.count(Post.id)).where(
Post.deleted_at.is_(None),
Post.status == "published",
Post.html.is_(None),
)
)
null_lexical = await sess.scalar(
select(func.count(Post.id)).where(
Post.deleted_at.is_(None),
Post.status == "published",
Post.lexical.is_(None),
)
)
# Posts with user_id set
has_user = await sess.scalar(
select(func.count(Post.id)).where(
Post.deleted_at.is_(None),
Post.user_id.isnot(None),
)
)
no_user = await sess.scalar(
select(func.count(Post.id)).where(
Post.deleted_at.is_(None),
Post.user_id.is_(None),
)
)
return {
"total_posts": total_posts,
"published_null_html": null_html,
"published_null_lexical": null_lexical,
"posts_with_user": has_user,
"posts_without_user": no_user,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def main():
log.info("=" * 60)
log.info("Final Ghost Sync — Cutover Preparation")
log.info("=" * 60)
# Step 1: Full sync + author→user migration
log.info("")
log.info("--- Step 1: Full sync from Ghost + author→user migration ---")
stats = await run_sync()
log.info(
"Sync complete: %d posts, %d pages, %d authors, %d tags, %d users mapped",
stats["posts"], stats["pages"], stats["authors"], stats["tags"],
stats["users_mapped"],
)
# Step 2: Verification queries
log.info("")
log.info("--- Step 2: Verification queries ---")
vq = await run_verification()
log.info("Total non-deleted posts/pages: %d", vq["total_posts"])
log.info("Published with NULL html: %d", vq["published_null_html"])
log.info("Published with NULL lexical: %d", vq["published_null_lexical"])
log.info("Posts with user_id: %d", vq["posts_with_user"])
log.info("Posts WITHOUT user_id: %d", vq["posts_without_user"])
if vq["published_null_html"] > 0:
log.warning("WARN: Some published posts have no HTML!")
if vq["published_null_lexical"] > 0:
log.warning("WARN: Some published posts have no Lexical JSON!")
if vq["posts_without_user"] > 0:
log.warning("WARN: Some posts have no user_id — authors may lack email!")
# Step 3: HTML rendering verification
log.info("")
log.info("--- Step 3: HTML rendering verification ---")
html_stats = await verify_html_rendering()
log.info(
"Checked %d posts, %d with diffs, %d without lexical",
html_stats["checked"], html_stats["diffs"], html_stats["no_lexical"],
)
if html_stats["diffs"] > 0:
log.warning(
"WARN: %d posts have HTML rendering differences — "
"review diffs above before cutover",
html_stats["diffs"],
)
# Summary
log.info("")
log.info("=" * 60)
log.info("SUMMARY")
log.info(" Posts synced: %d", stats["posts"])
log.info(" Pages synced: %d", stats["pages"])
log.info(" Authors synced: %d", stats["authors"])
log.info(" Tags synced: %d", stats["tags"])
log.info(" Users mapped: %d", stats["users_mapped"])
log.info(" post_users rows: %d", stats["post_users_rows"])
log.info(" HTML diffs: %d", html_stats["diffs"])
log.info(" Published null HTML: %d", vq["published_null_html"])
log.info(" Published null lex: %d", vq["published_null_lexical"])
log.info(" Posts without user: %d", vq["posts_without_user"])
log.info("=" * 60)
if (html_stats["diffs"] == 0
and vq["published_null_html"] == 0
and vq["posts_without_user"] == 0):
log.info("All checks passed — safe to proceed with cutover.")
else:
log.warning("Review warnings above before proceeding.")
await _engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,6 +1,68 @@
"""Blog app service registration."""
from __future__ import annotations
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.contracts.dtos import PostDTO
from models.content import Post
def _post_to_dto(post: Post) -> PostDTO:
return PostDTO(
id=post.id,
slug=post.slug,
title=post.title,
status=post.status,
visibility=post.visibility,
is_page=post.is_page,
feature_image=post.feature_image,
html=post.html,
excerpt=post.excerpt,
custom_excerpt=post.custom_excerpt,
published_at=post.published_at,
)
class SqlBlogService:
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.slug == slug))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.id == id))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
if not ids:
return []
result = await session.execute(select(Post).where(Post.id.in_(ids)))
return [_post_to_dto(p) for p in result.scalars().all()]
async def search_posts(
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
) -> tuple[list[PostDTO], int]:
if query:
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
total = (await session.execute(count_stmt)).scalar() or 0
offset = (page - 1) * per_page
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
return [_post_to_dto(p) for p in result.scalars().all()], total
# Module-level singleton — import this in blog code.
blog_service = SqlBlogService()
def register_domain_services() -> None:
"""Register services for the blog app.
@@ -8,12 +70,8 @@ def register_domain_services() -> None:
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
services.blog = SqlBlogService()
# Federation needed for AP shared infrastructure (activitypub blueprint)
from shared.services.registry import services
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -0,0 +1,457 @@
"""Native post/page CRUD — replaces Ghost Admin API writes.
All operations go directly to db_blog. Ghost is never called.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any, Optional
import nh3
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, Tag, PostTag, PostUser
from shared.browser.app.utils import utcnow
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _slugify(name: str) -> str:
s = name.strip().lower()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_]+", "-", s)
return s.strip("-")
def _reading_time(plaintext: str | None) -> int:
"""Estimate reading time in minutes (word count / 265, min 1)."""
if not plaintext:
return 0
words = len(plaintext.split())
return max(1, round(words / 265))
def _extract_plaintext(html: str) -> str:
"""Strip HTML tags to get plaintext."""
text = re.sub(r"<[^>]+>", "", html)
text = re.sub(r"\s+", " ", text).strip()
return text
def _sanitize_html(html: str | None) -> str | None:
if not html:
return html
return nh3.clean(
html,
tags={
"a", "abbr", "acronym", "b", "blockquote", "br", "code",
"div", "em", "figcaption", "figure", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p",
"pre", "span", "strong", "sub", "sup", "table", "tbody",
"td", "th", "thead", "tr", "ul", "video", "source",
"picture", "iframe", "audio",
},
attributes={
"*": {"class", "id", "style"},
"a": {"href", "title", "target"},
"img": {"src", "alt", "title", "width", "height", "loading"},
"video": {"src", "controls", "width", "height", "poster"},
"audio": {"src", "controls"},
"source": {"src", "type"},
"iframe": {"src", "width", "height", "frameborder", "allowfullscreen"},
"td": {"colspan", "rowspan"},
"th": {"colspan", "rowspan"},
},
link_rel="noopener noreferrer",
url_schemes={"http", "https", "mailto"},
)
def _render_and_extract(lexical_json: str) -> tuple[str, str, int]:
"""Render HTML from Lexical JSON, extract plaintext, compute reading time.
Returns (html, plaintext, reading_time).
"""
from bp.blog.ghost.lexical_renderer import render_lexical
doc = json.loads(lexical_json) if isinstance(lexical_json, str) else lexical_json
html = render_lexical(doc)
html = _sanitize_html(html)
plaintext = _extract_plaintext(html or "")
rt = _reading_time(plaintext)
return html, plaintext, rt
async def _ensure_slug_unique(sess: AsyncSession, slug: str, exclude_post_id: int | None = None) -> str:
"""Append -2, -3, etc. if slug already taken."""
base_slug = slug
counter = 1
while True:
q = select(Post.id).where(Post.slug == slug, Post.deleted_at.is_(None))
if exclude_post_id:
q = q.where(Post.id != exclude_post_id)
existing = await sess.scalar(q)
if existing is None:
return slug
counter += 1
slug = f"{base_slug}-{counter}"
async def _upsert_tags_by_name(sess: AsyncSession, tag_names: list[str]) -> list[Tag]:
"""Find or create tags by name. Returns Tag objects in order."""
tags: list[Tag] = []
for name in tag_names:
name = name.strip()
if not name:
continue
tag = (await sess.execute(
select(Tag).where(Tag.name == name, Tag.deleted_at.is_(None))
)).scalar_one_or_none()
if tag is None:
tag = Tag(
name=name,
slug=_slugify(name),
visibility="public",
)
sess.add(tag)
await sess.flush()
tags.append(tag)
return tags
async def _rebuild_post_tags(sess: AsyncSession, post_id: int, tags: list[Tag]) -> None:
"""Replace all post_tags for a post."""
await sess.execute(delete(PostTag).where(PostTag.post_id == post_id))
seen: set[int] = set()
for idx, tag in enumerate(tags):
if tag.id not in seen:
seen.add(tag.id)
sess.add(PostTag(post_id=post_id, tag_id=tag.id, sort_order=idx))
await sess.flush()
async def _rebuild_post_users(sess: AsyncSession, post_id: int, user_ids: list[int]) -> None:
"""Replace all post_users for a post."""
await sess.execute(delete(PostUser).where(PostUser.post_id == post_id))
seen: set[int] = set()
for idx, uid in enumerate(user_ids):
if uid not in seen:
seen.add(uid)
sess.add(PostUser(post_id=post_id, user_id=uid, sort_order=idx))
await sess.flush()
async def _fire_ap_publish(
sess: AsyncSession,
post: Post,
old_status: str | None,
tag_objs: list[Tag],
) -> None:
"""Fire AP federation activity on status transitions."""
if post.is_page or not post.user_id:
return
from bp.blog.ghost.ghost_sync import _build_ap_post_data
from shared.services.federation_publish import try_publish
from shared.infrastructure.urls import app_url
post_url = app_url("blog", f"/{post.slug}/")
if post.status == "published":
activity_type = "Create" if old_status != "published" else "Update"
await try_publish(
sess,
user_id=post.user_id,
activity_type=activity_type,
object_type="Note",
object_data=_build_ap_post_data(post, post_url, tag_objs),
source_type="Post",
source_id=post.id,
)
elif old_status == "published" and post.status != "published":
await try_publish(
sess,
user_id=post.user_id,
activity_type="Delete",
object_type="Tombstone",
object_data={
"id": post_url,
"formerType": "Note",
},
source_type="Post",
source_id=post.id,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def create_post(
sess: AsyncSession,
*,
title: str,
lexical_json: str,
status: str = "draft",
user_id: int,
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
tag_names: list[str] | None = None,
is_page: bool = False,
) -> Post:
"""Create a new post or page directly in db_blog."""
html, plaintext, reading_time = _render_and_extract(lexical_json)
slug = await _ensure_slug_unique(sess, _slugify(title or "untitled"))
now = utcnow()
post = Post(
title=title or "Untitled",
slug=slug,
lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
html=html,
plaintext=plaintext,
reading_time=reading_time,
status=status,
is_page=is_page,
feature_image=feature_image,
feature_image_caption=_sanitize_html(feature_image_caption),
custom_excerpt=custom_excerpt,
user_id=user_id,
visibility="public",
created_at=now,
updated_at=now,
published_at=now if status == "published" else None,
)
sess.add(post)
await sess.flush()
# Tags
if tag_names:
tags = await _upsert_tags_by_name(sess, tag_names)
await _rebuild_post_tags(sess, post.id, tags)
if tags:
post.primary_tag_id = tags[0].id
# Post users (author)
await _rebuild_post_users(sess, post.id, [user_id])
# PageConfig for pages
if is_page:
from shared.models.page_config import PageConfig
existing = (await sess.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post.id,
)
)).scalar_one_or_none()
if existing is None:
sess.add(PageConfig(
container_type="page",
container_id=post.id,
features={},
))
await sess.flush()
# AP federation
if status == "published":
tag_objs = (await _upsert_tags_by_name(sess, tag_names)) if tag_names else []
await _fire_ap_publish(sess, post, None, tag_objs)
return post
async def create_page(
sess: AsyncSession,
*,
title: str,
lexical_json: str,
status: str = "draft",
user_id: int,
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
tag_names: list[str] | None = None,
) -> Post:
"""Create a new page. Convenience wrapper around create_post."""
return await create_post(
sess,
title=title,
lexical_json=lexical_json,
status=status,
user_id=user_id,
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
tag_names=tag_names,
is_page=True,
)
async def update_post(
sess: AsyncSession,
*,
post_id: int,
lexical_json: str,
title: str | None = None,
expected_updated_at: datetime | str,
feature_image: str | None = ..., # type: ignore[assignment]
custom_excerpt: str | None = ..., # type: ignore[assignment]
feature_image_caption: str | None = ..., # type: ignore[assignment]
status: str | None = None,
) -> Post:
"""Update post content. Optimistic lock via expected_updated_at.
Fields set to ... (sentinel) are left unchanged. None clears the field.
Raises ValueError on optimistic lock conflict (409).
"""
_SENTINEL = ...
post = await sess.get(Post, post_id)
if post is None:
raise ValueError(f"Post {post_id} not found")
# Optimistic lock
if isinstance(expected_updated_at, str):
expected_updated_at = datetime.fromisoformat(
expected_updated_at.replace("Z", "+00:00")
)
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
raise OptimisticLockError(
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
)
old_status = post.status
# Render content
html, plaintext, reading_time = _render_and_extract(lexical_json)
post.lexical = lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json)
post.html = html
post.plaintext = plaintext
post.reading_time = reading_time
if title is not None:
post.title = title
if feature_image is not _SENTINEL:
post.feature_image = feature_image
if custom_excerpt is not _SENTINEL:
post.custom_excerpt = custom_excerpt
if feature_image_caption is not _SENTINEL:
post.feature_image_caption = _sanitize_html(feature_image_caption)
if status is not None:
post.status = status
if status == "published" and not post.published_at:
post.published_at = utcnow()
post.updated_at = utcnow()
await sess.flush()
# AP federation on status change
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
await _fire_ap_publish(sess, post, old_status, tags)
return post
_SETTINGS_FIELDS = (
"slug", "published_at", "featured", "visibility", "email_only",
"custom_template", "meta_title", "meta_description", "canonical_url",
"og_image", "og_title", "og_description",
"twitter_image", "twitter_title", "twitter_description",
"feature_image_alt",
)
async def update_post_settings(
sess: AsyncSession,
*,
post_id: int,
expected_updated_at: datetime | str,
tag_names: list[str] | None = None,
**kwargs: Any,
) -> Post:
"""Update post settings (slug, tags, SEO, social, etc.).
Optimistic lock via expected_updated_at.
"""
post = await sess.get(Post, post_id)
if post is None:
raise ValueError(f"Post {post_id} not found")
# Optimistic lock
if isinstance(expected_updated_at, str):
expected_updated_at = datetime.fromisoformat(
expected_updated_at.replace("Z", "+00:00")
)
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
raise OptimisticLockError(
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
)
old_status = post.status
for field in _SETTINGS_FIELDS:
val = kwargs.get(field)
if val is not None:
if field == "slug":
val = await _ensure_slug_unique(sess, val, exclude_post_id=post.id)
if field == "featured":
val = bool(val)
if field == "email_only":
val = bool(val)
if field == "published_at":
if isinstance(val, str):
val = datetime.fromisoformat(val.replace("Z", "+00:00"))
setattr(post, field, val)
# Tags
if tag_names is not None:
tags = await _upsert_tags_by_name(sess, tag_names)
await _rebuild_post_tags(sess, post.id, tags)
post.primary_tag_id = tags[0].id if tags else None
post.updated_at = utcnow()
await sess.flush()
# AP federation if visibility/status changed
tags_obj = list(post.tags) if hasattr(post, "tags") and post.tags else []
await _fire_ap_publish(sess, post, old_status, tags_obj)
return post
async def delete_post(sess: AsyncSession, post_id: int) -> None:
"""Soft-delete a post via deleted_at."""
post = await sess.get(Post, post_id)
if post is None:
return
old_status = post.status
post.deleted_at = utcnow()
post.status = "deleted"
await sess.flush()
# Fire AP Delete if was published
if old_status == "published" and post.user_id:
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
await _fire_ap_publish(sess, post, old_status, tags)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class OptimisticLockError(Exception):
"""Raised when optimistic lock check fails (stale updated_at)."""
pass

View File

@@ -14,12 +14,6 @@
(h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" list)))
(defcomp ~blog-snippets-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-puzzle-piece text-4xl mb-2")
(p "No snippets yet. Create one from the blog editor."))))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
@@ -28,16 +22,6 @@
(defcomp ~blog-snippet-option (&key value selected label)
(option :value value :selected selected label))
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
(i :class "fa fa-trash") " Delete"))
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0"
@@ -58,16 +42,6 @@
(div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" list)))
(defcomp ~blog-menu-items-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-inbox text-4xl mb-2")
(p "No menu items yet. Add one to get started!"))))
(defcomp ~blog-menu-item-image (&key src label)
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
@@ -80,14 +54,9 @@
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit")
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
(i :class "fa fa-trash") " Delete"))))
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
:title "Delete menu item?" :text confirm-text
:sx-headers hx-headers))))
(defcomp ~blog-menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
@@ -123,9 +92,6 @@
(defcomp ~blog-tag-groups-list (&key items)
(ul :class "space-y-2" items))
(defcomp ~blog-tag-groups-empty ()
(p :class "text-stone-500 text-sm" "No tag groups yet."))
(defcomp ~blog-unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))

View File

@@ -52,9 +52,3 @@
(defcomp ~blog-home-main (&key html-content)
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
(defcomp ~blog-admin-empty ()
(div :class "pb-8"))
(defcomp ~blog-settings-empty ()
(div :class "max-w-2xl mx-auto px-4 py-6"))

View File

@@ -8,6 +8,7 @@
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value "")
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
@@ -34,11 +35,22 @@
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")
(div :id "lexical-editor" :class "relative w-full bg-transparent")
;; Editor tabs: SX (primary) and Koenig (legacy)
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
(button :type "button" :id "editor-tab-sx"
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent"
: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 "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"
: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")
(div :id "lexical-editor" :class "relative w-full bg-transparent" :style "display:none")
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :name "status"
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
(option :value "draft" :selected t "Draft")
(option :value "draft" :selected true "Draft")
(option :value "published" "Published"))
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
@@ -50,5 +62,146 @@
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key js-src init-js)
(<> (script :src js-src) (script init-js)))
(defcomp ~blog-editor-scripts (&key js-src sx-editor-js-src init-js)
(<> (script :src js-src)
(when sx-editor-js-src (script :src sx-editor-js-src))
(script init-js)))
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
(defcomp ~sx-editor-styles ()
(style
;; Editor container
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
".sx-blocks-container { display: flex; flex-direction: column; min-height: 300px; cursor: text; padding-left: 48px; }"
;; Block base
".sx-block { position: relative; margin: 1px 0; }"
".sx-block-content { padding: 2px 0; }"
".sx-editable { outline: none; min-height: 1.6em; }"
".sx-editable:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
;; Text block styles
".sx-heading { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 700; }"
".sx-block[data-sx-tag='h2'] .sx-heading { font-size: 2em; line-height: 1.25; margin: 0.5em 0 0.25em; }"
".sx-block[data-sx-tag='h3'] .sx-heading { font-size: 1.5em; line-height: 1.3; margin: 0.4em 0 0.2em; }"
".sx-quote { border-left: 3px solid #d6d3d1; padding-left: 16px; font-style: italic; color: #57534e; }"
;; HR
".sx-block-hr { padding: 12px 0; }"
".sx-hr { border: none; border-top: 1px solid #e7e5e4; }"
;; Code blocks
".sx-block-code { background: #1c1917; border-radius: 6px; padding: 16px; margin: 8px 0; }"
".sx-code-header { margin-bottom: 8px; }"
".sx-code-lang { background: transparent; border: none; color: #a8a29e; font-size: 12px; outline: none; width: 120px; font-family: monospace; }"
".sx-code-textarea { width: 100%; background: transparent; border: none; color: #fafaf9; font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace; font-size: 14px; line-height: 1.5; resize: none; outline: none; min-height: 60px; tab-size: 2; }"
;; List blocks
".sx-list-content { padding-left: 0; }"
".sx-list-item { padding: 2px 0 2px 24px; position: relative; outline: none; min-height: 1.6em; }"
".sx-list-item:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
".sx-list-item:before { content: '\\2022'; position: absolute; left: 6px; color: #78716c; }"
".sx-block[data-sx-tag='ol'] { counter-reset: sx-list; }"
".sx-block[data-sx-tag='ol'] .sx-list-item { counter-increment: sx-list; }"
".sx-block[data-sx-tag='ol'] .sx-list-item:before { content: counter(sx-list) '.'; font-size: 14px; }"
;; Card blocks
".sx-block-card { margin: 12px 0; border-radius: 4px; position: relative; transition: box-shadow 0.15s; }"
".sx-block-card:hover { box-shadow: 0 0 0 1px #d6d3d1; }"
".sx-block-card.sx-card-editing { box-shadow: 0 0 0 2px #3b82f6; }"
".sx-card-preview { cursor: pointer; }"
".sx-card-preview img { max-width: 100%; }"
".sx-card-fallback { padding: 24px; text-align: center; color: #a8a29e; font-style: italic; background: #fafaf9; border-radius: 4px; }"
".sx-card-caption { padding: 8px 0; font-size: 14px; color: #78716c; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-card-caption:empty:before { content: attr(data-placeholder); color: #d6d3d1; }"
;; Card toolbar
".sx-card-toolbar { position: absolute; top: -36px; right: 0; z-index: 10; display: none; gap: 4px; }"
".sx-block-card:hover .sx-card-toolbar { display: flex; }"
".sx-card-editing .sx-card-toolbar { display: flex; }"
".sx-card-tool-btn { width: 28px; height: 28px; border-radius: 4px; border: 1px solid #d6d3d1; background: white; color: #78716c; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.1s; }"
".sx-card-tool-btn:hover { background: #fafaf9; color: #dc2626; border-color: #dc2626; }"
;; Card edit panel
".sx-card-edit { padding: 16px; background: #fafaf9; border-radius: 4px; }"
;; Plus button (Koenig-style floating)
".sx-plus-container { position: absolute; z-index: 40; display: flex; align-items: center; }"
".sx-plus-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #d6d3d1; background: white; color: #a8a29e; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; }"
".sx-plus-btn:hover { border-color: #1c1917; color: #1c1917; }"
".sx-plus-btn-open { transform: rotate(45deg); border-color: #1c1917; color: #1c1917; }"
".sx-plus-btn svg { width: 14px; height: 14px; }"
;; Plus menu
".sx-plus-menu { position: absolute; top: 36px; left: -8px; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 8px; width: 320px; max-height: 420px; overflow-y: auto; }"
".sx-plus-menu-section { margin-bottom: 4px; }"
".sx-plus-menu-heading { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #a8a29e; padding: 6px 8px 2px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-plus-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 8px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-plus-menu-item:hover { background: #f5f5f4; }"
".sx-plus-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
".sx-plus-menu-label { font-weight: 500; white-space: nowrap; }"
".sx-plus-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
;; Slash command menu
".sx-slash-menu { position: absolute; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 4px; width: 320px; max-height: 300px; overflow-y: auto; }"
".sx-slash-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-slash-menu-item:hover { background: #f5f5f4; }"
".sx-slash-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
".sx-slash-menu-label { font-weight: 500; white-space: nowrap; }"
".sx-slash-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
;; Format toolbar
".sx-format-bar { position: absolute; z-index: 100; display: flex; gap: 1px; background: #1c1917; border-radius: 6px; padding: 2px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-format-btn { border: none; background: none; color: #e7e5e4; cursor: pointer; padding: 6px 10px; border-radius: 4px; font-size: 14px; line-height: 1; }"
".sx-format-btn:hover { background: #44403c; color: white; }"
".sx-fmt-bold { font-weight: 700; }"
".sx-fmt-italic { font-style: italic; }"
;; Card edit UI elements
".sx-edit-controls { display: flex; flex-direction: column; gap: 8px; }"
".sx-edit-row { display: flex; align-items: center; gap: 8px; }"
".sx-edit-label { font-size: 12px; font-weight: 600; color: #78716c; min-width: 80px; text-transform: uppercase; letter-spacing: 0.03em; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-edit-input { flex: 1; padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-edit-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }"
".sx-edit-select { padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; background: white; }"
".sx-edit-btn { padding: 8px 16px; border: 1px solid #d6d3d1; border-radius: 6px; font-size: 14px; cursor: pointer; background: white; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; transition: all 0.1s; }"
".sx-edit-btn:hover { background: #f5f5f4; border-color: #a8a29e; }"
".sx-edit-btn-sm { padding: 4px 12px; font-size: 12px; }"
".sx-edit-info { font-size: 12px; color: #a8a29e; }"
".sx-edit-status { font-size: 13px; color: #78716c; margin-top: 4px; }"
".sx-edit-url-input { font-family: monospace; }"
".sx-edit-url-display { font-size: 12px; color: #78716c; font-family: monospace; margin: 4px 0; word-break: break-all; }"
".sx-edit-checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 14px; color: #44403c; cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-edit-img-preview { max-width: 100%; max-height: 300px; object-fit: contain; border-radius: 4px; margin-bottom: 12px; }"
".sx-edit-embed-preview { margin-bottom: 8px; }"
".sx-edit-embed-preview iframe { max-width: 100%; }"
".sx-edit-audio-player { width: 100%; margin-bottom: 12px; }"
".sx-edit-video-player { width: 100%; max-height: 300px; margin-bottom: 12px; border-radius: 4px; }"
".sx-edit-html-textarea { width: 100%; min-height: 120px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; padding: 12px; border: 1px solid #d6d3d1; border-radius: 4px; resize: vertical; outline: none; background: #fafaf9; line-height: 1.5; tab-size: 2; }"
".sx-edit-html-textarea:focus { border-color: #3b82f6; }"
".sx-edit-callout-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; }"
".sx-edit-toggle-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; margin-top: 4px; }"
".sx-edit-emoji-input { width: 60px; text-align: center; font-size: 20px; padding: 4px; margin-bottom: 8px; }"
;; Color picker
".sx-edit-color-row { display: flex; gap: 6px; margin-bottom: 8px; }"
".sx-edit-color-swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: all 0.1s; }"
".sx-edit-color-swatch:hover { transform: scale(1.15); }"
".sx-edit-color-swatch.active { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }"
;; Gallery grid
".sx-edit-gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }"
".sx-edit-gallery-thumb { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 4px; }"
".sx-edit-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }"
".sx-edit-gallery-remove { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.1s; }"
".sx-edit-gallery-thumb:hover .sx-edit-gallery-remove { opacity: 1; }"
;; Upload area
".sx-upload-area { padding: 40px 24px; border: 2px dashed #d6d3d1; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s; }"
".sx-upload-area:hover, .sx-upload-dragover { border-color: #3b82f6; background: rgba(59,130,246,0.03); }"
".sx-upload-icon { font-size: 32px; color: #a8a29e; margin-bottom: 8px; }"
".sx-upload-msg { font-size: 14px; color: #78716c; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
".sx-upload-progress { font-size: 13px; color: #3b82f6; margin-top: 8px; }"
;; Drag over editor
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))

View File

@@ -1,8 +1,5 @@
;; Blog header components
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" container-nav))

View File

@@ -1,55 +1,8 @@
;; Blog index components
(defcomp ~blog-end-of-results ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-8 text-stone-400"
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
(defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~blog-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"

View File

@@ -1,67 +0,0 @@
;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id)
(div :id wrapper-id :sx-swap-oob "outerHTML"))
(defcomp ~blog-nav-item-image (&key src label)
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:aria-selected selected :class nav-cls
img (span label))))
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
(div (a :href href :aria-selected selected :class nav-cls
img (span label))))
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "menu-items-nav-wrapper" :sx-swap-oob "outerHTML"
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll left"
:_ left-hs (i :class "fa fa-chevron-left"))
(div :id container-id
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" items))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll right"
:_ right-hs (i :class "fa fa-chevron-right"))))
;; Nav entries
(defcomp ~blog-nav-entries-empty ()
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
(a :href href :class nav-cls
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" date-str))))
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
(a :href href :class nav-cls
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" :sx-swap-oob "true"
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll left"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
(i :class "fa fa-chevron-left"))
(div :id "associated-items-container"
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" items))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll right"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
(i :class "fa fa-chevron-right"))))

View File

@@ -18,33 +18,11 @@
(i :class "fa fa-shopping-bag text-green-600 mr-1")
" Market \u2014 enable product catalog on this page"))))
(defcomp ~blog-sumup-connected ()
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
(defcomp ~blog-sumup-key-hint ()
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint checkout-prefix connected)
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
(div :class "mt-4 pt-4 border-t border-stone-100"
(h4 :class "text-sm font-medium text-stone-700"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put sumup-url :sx-target "#features-panel" :sx-swap "outerHTML" :class "space-y-3"
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
key-hint)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(button :type "submit"
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
connected)))
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
:placeholder placeholder :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :panel-id "features-panel")))
(defcomp ~blog-features-panel (&key form sumup)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"

View File

@@ -13,6 +13,7 @@ from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize as sx_serialize
from shared.sx.helpers import (
SxExpr, sx_call,
call_url, get_asset_url,
@@ -31,6 +32,12 @@ from shared.sx.helpers import (
load_service_components(os.path.dirname(os.path.dirname(__file__)))
def _ctx_csrf(ctx: dict) -> str:
"""Get CSRF token from context, handling Jinja callable globals."""
val = ctx.get("csrf_token", "")
return val() if callable(val) else val
# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------
@@ -44,10 +51,9 @@ _oob_header_sx = oob_header_sx
def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Blog header row — empty child of root."""
label_sx = sx_call("blog-header-label")
return sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr(label_sx),
link_label_content=SxExpr("(div)"),
child_id="blog-header-child", oob=oob,
)
@@ -153,7 +159,7 @@ def _blog_sentinel_sx(ctx: dict) -> str:
total_pages = int(total_pages)
if page >= total_pages:
return sx_call("blog-end-of-results")
return sx_call("end-of-results")
current_local_href = ctx.get("current_local_href", "/index")
next_url = f"{current_local_href}?page={page + 1}"
@@ -183,9 +189,9 @@ def _blog_sentinel_sx(ctx: dict) -> str:
)
return (
sx_call("blog-sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
sx_call("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
+ " "
+ sx_call("blog-sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
+ sx_call("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
)
@@ -268,7 +274,7 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
if user:
kwargs["liked"] = post.get("is_liked", False)
kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
kwargs["csrf_token"] = ctx.get("csrf_token", "")
kwargs["csrf_token"] = _ctx_csrf(ctx)
if tags:
kwargs["tags"] = tags
@@ -356,15 +362,15 @@ def _page_cards_sx(ctx: dict) -> str:
if page_num < total_pages:
current_local_href = ctx.get("current_local_href", "/index?type=pages")
next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}"
parts.append(sx_call("blog-page-sentinel",
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url,
))
elif pages:
parts.append(sx_call("blog-end-of-results"))
parts.append(sx_call("end-of-results"))
else:
parts.append(sx_call("blog-no-pages"))
return "".join(parts)
return "(<> " + " ".join(parts) + ")" if parts else ""
def _page_card_sx(page: dict, ctx: dict) -> str:
@@ -400,12 +406,12 @@ def _view_toggle_sx(ctx: dict) -> str:
list_href = f"{current_local_href}"
tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile"
list_svg_sx = sx_call("blog-list-svg")
tile_svg_sx = sx_call("blog-tile-svg")
list_svg_sx = sx_call("list-svg")
tile_svg_sx = sx_call("tile-svg")
return sx_call("blog-view-toggle",
return sx_call("view-toggle",
list_href=list_href, tile_href=tile_href, hx_select=hx_select,
list_cls=list_cls, tile_cls=tile_cls,
list_cls=list_cls, tile_cls=tile_cls, storage_key="blog_view",
list_svg=SxExpr(list_svg_sx), tile_svg=SxExpr(tile_svg_sx),
)
@@ -691,7 +697,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
like_sx = sx_call("blog-detail-like",
like_url=like_url,
hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}',
hx_headers=f'{{"X-CSRFToken": "{_ctx_csrf(ctx)}"}}',
heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
@@ -775,7 +781,7 @@ def _home_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _post_admin_main_panel_sx(ctx: dict) -> str:
return sx_call("blog-admin-empty")
return '(div :class "pb-8")'
# ---------------------------------------------------------------------------
@@ -783,13 +789,13 @@ def _post_admin_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _settings_main_panel_sx(ctx: dict) -> str:
return sx_call("blog-settings-empty")
return '(div :class "max-w-2xl mx-auto px-4 py-6")'
def _cache_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
clear_url = qurl("settings.cache_clear")
return sx_call("blog-cache-panel", clear_url=clear_url, csrf=csrf)
@@ -809,12 +815,12 @@ def _snippets_list_sx(ctx: dict) -> str:
snippets = ctx.get("snippets") or []
is_admin = ctx.get("is_admin", False)
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
user = getattr(g, "user", None)
user_id = getattr(user, "id", None)
if not snippets:
return sx_call("blog-snippets-empty")
return sx_call("empty-state", icon="fa fa-puzzle-piece", message="No snippets yet. Create one from the blog editor.")
badge_colours = {
"private": "bg-stone-200 text-stone-700",
@@ -849,10 +855,12 @@ def _snippets_list_sx(ctx: dict) -> str:
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
extra += sx_call("blog-snippet-delete-button",
confirm_text=f'Delete \u201c{s_name}\u201d?',
delete_url=del_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
extra += sx_call("delete-btn",
url=del_url, trigger_target="#snippets-list",
title="Delete snippet?",
text=f'Delete \u201c{s_name}\u201d?',
sx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
cls="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0",
)
row_parts.append(sx_call("blog-snippet-row",
@@ -880,10 +888,10 @@ def _menu_items_list_sx(ctx: dict) -> str:
from quart import url_for as qurl
menu_items = ctx.get("menu_items") or []
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
if not menu_items:
return sx_call("blog-menu-items-empty")
return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!")
row_parts = []
for item in menu_items:
@@ -896,7 +904,8 @@ def _menu_items_list_sx(ctx: dict) -> str:
edit_url = qurl("menu_items.edit_menu_item", item_id=i_id)
del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id)
img_sx = sx_call("blog-menu-item-image", src=fi, label=label)
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-12 h-12 rounded-full object-cover flex-shrink-0")
row_parts.append(sx_call("blog-menu-item-row",
img=SxExpr(img_sx), label=label, slug=slug,
@@ -918,7 +927,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
create_url = qurl("blog.tag_groups_admin.create")
form_sx = sx_call("blog-tag-groups-create-form",
@@ -951,7 +960,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
))
groups_sx = sx_call("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
else:
groups_sx = sx_call("blog-tag-groups-empty")
groups_sx = sx_call("empty-state", message="No tag groups yet.", cls="text-stone-500 text-sm")
# Unassigned tags
unassigned_sx = ""
@@ -978,7 +987,7 @@ def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
group = ctx.get("group")
all_tags = ctx.get("all_tags") or []
assigned_tag_ids = ctx.get("assigned_tag_ids") or set()
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
g_id = getattr(group, "id", None) or group.get("id") if group else None
g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "")
@@ -1045,12 +1054,12 @@ async def render_home_page(ctx: dict) -> str:
async def render_home_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
post_oob = _oob_header_sx("root-header-child", "post-header-child",
_post_header_sx(ctx))
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _home_main_panel_sx(ctx)
oobs = "(<> " + root_hdr + " " + post_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
return oob_page_sx(oobs=header_oob, content=content)
# ---- Blog index ----
@@ -1067,15 +1076,15 @@ async def render_blog_page(ctx: dict) -> str:
async def render_blog_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
blog_oob = _oob_header_sx("root-header-child", "blog-header-child",
_blog_header_sx(ctx))
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_sx(ctx)
nav = ctx.get("nav_sx", "") or ""
oobs = "(<> " + root_hdr + " " + blog_oob + ")"
return oob_page_sx(oobs=oobs, content=content, aside=aside,
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
filter=filter_sx, menu=nav)
@@ -1106,6 +1115,7 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
editor_css = asset_url_fn("scripts/editor.css")
editor_js = asset_url_fn("scripts/editor.js")
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
upload_image_url = qurl("blog.editor_api.upload_image")
upload_media_url = qurl("blog.editor_api.upload_media")
@@ -1130,27 +1140,21 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
)
parts.append(form_html)
# Editor CSS + inline styles
# Editor CSS + inline styles + sx editor styles
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
parts.append(sx_call("sx-editor-styles"))
# Editor JS + init script
init_js = (
"console.log('[EDITOR-DEBUG] init script running');\n"
"(function() {\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
" }\n"
" function restoreDefaultFontSize() {\n"
" document.documentElement.style.fontSize = '';\n"
" document.body.style.fontSize = '';\n"
" }\n"
" applyEditorFontSize();\n"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
" restoreDefaultFontSize();\n"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
" }\n"
" });\n"
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
" // Font size overrides disabled — caused global font shrinking\n"
" // function applyEditorFontSize() {\n"
" // document.documentElement.style.fontSize = '62.5%';\n"
" // document.body.style.fontSize = '1.6rem';\n"
" // }\n"
" // applyEditorFontSize();\n"
"\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
@@ -1248,6 +1252,18 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" if (typeof SxEditor !== 'undefined') {\n"
" SxEditor.mount('sx-editor', {\n"
" initialSx: window.__SX_INITIAL__ || null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
" onChange: function(sx) {\n"
" document.getElementById('sx-content-input').value = sx;\n"
" }\n"
" });\n"
" }\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
@@ -1265,9 +1281,12 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
" }\n"
"})();\n"
)
parts.append(sx_call("blog-editor-scripts", js_src=editor_js, init_js=init_js))
parts.append(sx_call("blog-editor-scripts",
js_src=editor_js,
sx_editor_js_src=sx_editor_js,
init_js=init_js))
return "".join(parts)
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---- New post/page ----
@@ -1281,11 +1300,12 @@ async def render_new_post_page(ctx: dict) -> str:
async def render_new_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
blog_oob = _blog_header_sx(ctx, oob=True)
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = ctx.get("editor_html", "")
oobs = "(<> " + root_hdr + " " + blog_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
return oob_page_sx(oobs=header_oob, content=content)
# ---- Post detail ----
@@ -1302,12 +1322,13 @@ async def render_post_page(ctx: dict) -> str:
async def render_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
post_oob = _oob_header_sx("root-header-child", "post-header-child",
_post_header_sx(ctx))
root_hdr = root_header_sx(ctx) # non-OOB (nested inside root-header-child)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _post_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
oobs = "(<> " + root_hdr + " " + post_oob + ")"
oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu)
@@ -1317,7 +1338,7 @@ async def render_post_admin_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
@@ -1340,14 +1361,14 @@ async def render_post_data_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="data")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
content = ctx.get("data_html", "")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("data_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_data_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data")
content = ctx.get("data_html", "")
content = _raw_html_sx(ctx.get("data_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
@@ -1357,32 +1378,38 @@ async def render_post_entries_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
content = ctx.get("entries_html", "")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("entries_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_entries_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries")
content = ctx.get("entries_html", "")
content = _raw_html_sx(ctx.get("entries_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post edit ----
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) + ")"
async def render_post_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
content = ctx.get("edit_html", "")
body_end = ctx.get("body_end_html", "")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("edit_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_edit_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit")
content = ctx.get("edit_html", "")
content = _raw_html_sx(ctx.get("edit_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
@@ -1392,14 +1419,14 @@ async def render_post_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
content = ctx.get("settings_html", "")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("settings_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_settings_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings")
content = ctx.get("settings_html", "")
content = _raw_html_sx(ctx.get("settings_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
@@ -1416,13 +1443,13 @@ async def render_settings_page(ctx: dict) -> str:
async def render_settings_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
settings_oob = _oob_header_sx("root-header-child", "root-settings-header-child",
_settings_header_sx(ctx))
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
oobs = "(<> " + root_hdr + " " + settings_oob + ")"
return oob_page_sx(oobs=oobs, content=content, menu=menu)
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
# ---- Cache ----
@@ -1435,7 +1462,7 @@ async def render_cache_page(ctx: dict) -> str:
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + cache_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
content = _cache_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1464,7 +1491,7 @@ async def render_snippets_page(ctx: dict) -> str:
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + snippets_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
content = _snippets_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1493,7 +1520,7 @@ async def render_menu_items_page(ctx: dict) -> str:
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + mi_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
content = _menu_items_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1522,7 +1549,7 @@ async def render_tag_groups_page(ctx: dict) -> str:
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + tg_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1552,7 +1579,7 @@ async def render_tag_group_edit_page(ctx: dict) -> str:
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + tg_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_edit_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1670,7 +1697,8 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
img_sx = sx_call("blog-nav-item-image", src=fi, label=label)
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",
@@ -1685,12 +1713,13 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("blog-nav-wrapper",
arrow_cls=arrow_cls, container_id=container_id,
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,
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
@@ -1720,15 +1749,12 @@ def render_features_panel(features: dict, post: dict,
sumup_sx = ""
if features.get("calendar") or features.get("market"):
placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..."
connected = sx_call("blog-sumup-connected") if sumup_configured else ""
key_hint = sx_call("blog-sumup-key-hint") if sumup_configured else ""
sumup_sx = sx_call("blog-sumup-form",
sumup_url=sumup_url, merchant_code=sumup_merchant_code,
placeholder=placeholder,
key_hint=SxExpr(key_hint) if key_hint else None,
sumup_configured=sumup_configured,
checkout_prefix=sumup_checkout_prefix,
connected=SxExpr(connected) if connected else None,
)
return sx_call("blog-features-panel",
@@ -1889,8 +1915,8 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
href = events_url_fn(entry_path) if events_url_fn else entry_path
item_parts.append(sx_call("blog-nav-entry-item",
href=href, nav_cls=nav_cls, name=e_name, date_str=date_str,
item_parts.append(sx_call("calendar-entry-nav",
href=href, nav_class=nav_cls, name=e_name, date_str=date_str,
))
# Calendar links
@@ -1906,6 +1932,11 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("blog-nav-entries-wrapper",
scroll_hs=scroll_hs, items=SxExpr(items_sx) if items_sx else None,
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,
)

9
build-tw.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Build Tailwind CSS from source templates.
# Requires: tailwindcss CLI standalone binary on PATH.
# curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
# -o /usr/local/bin/tailwindcss && chmod +x /usr/local/bin/tailwindcss
set -euo pipefail
cd "$(dirname "$0")"
tailwindcss -i shared/static/styles/tailwind.css -o shared/static/styles/tw.css --minify
echo "Built shared/static/styles/tw.css ($(wc -c < shared/static/styles/tw.css) bytes)"

View File

@@ -1,20 +0,0 @@
;; Cart checkout error components
(defcomp ~cart-checkout-error-filter ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600"
"We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~cart-checkout-error-order-id (&key order-id)
(p :class "text-xs text-rose-800/80"
"Order ID: " (span :class "font-mono" order-id)))
(defcomp ~cart-checkout-error-content (&key error-msg order back-url)
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")
(p error-msg)
order)
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))

View File

@@ -7,38 +7,4 @@
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
(defcomp ~cart-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-header-child-nested (&key outer inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
outer
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
inner)))
(defcomp ~cart-header-child-oob (&key inner)
(div :id "cart-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-auth-header-child (&key auth orders)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
orders)))
(defcomp ~cart-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-order-header-child (&key auth orders order)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
orders
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
order))))
(defcomp ~cart-orders-header-child-oob (&key inner)
(div :id "orders-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))

View File

@@ -3,10 +3,6 @@
(defcomp ~cart-item-img (&key src alt)
(img :src src :alt alt :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy"))
(defcomp ~cart-item-no-img ()
(div :class "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
"No image"))
(defcomp ~cart-item-price (&key text)
(p :class "text-sm sm:text-base font-semibold text-stone-900" text))
@@ -51,14 +47,6 @@
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
(div :class "flex items-center justify-between sm:justify-end gap-3" (when line-total line-total))))))
(defcomp ~cart-page-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))))
(defcomp ~cart-page-panel (&key items cal tickets summary)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"

View File

@@ -1,53 +0,0 @@
;; Cart single order detail components
(defcomp ~cart-order-item-img (&key src alt)
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~cart-order-item-no-img ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~cart-order-item (&key prod-url img title product-id qty price)
(li (a :class "w-full py-2 flex gap-3" :href prod-url
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
(div :class "flex-1 flex justify-between gap-3"
(div (p :class "font-medium" title)
(p :class "text-[11px] text-stone-500" product-id))
(div :class "text-right whitespace-nowrap"
(p qty) (p price))))))
(defcomp ~cart-order-items-panel (&key items)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
(defcomp ~cart-order-cal-entry (&key name pill status date-str cost)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div :class "font-medium flex items-center gap-2"
name (span :class pill status))
(div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" cost)))
(defcomp ~cart-order-cal-section (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~cart-order-main (&key summary items cal)
(div :class "max-w-full px-3 py-3 space-y-4" summary items cal))
(defcomp ~cart-order-pay-btn (&key url)
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" info))
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
(form :method "post" :action recheck-url :class "inline"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
pay)))

View File

@@ -1,51 +0,0 @@
;; Cart orders list components
(defcomp ~cart-order-row-desktop (&key order-id created desc total pill status detail-url)
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" order-id))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" created)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" total)
(td :class "px-3 py-2 align-top" (span :class pill status))
(td :class "px-3 py-0.5 align-top text-right"
(a :href detail-url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
(defcomp ~cart-order-row-mobile (&key order-id pill status created total detail-url)
(tr :class "sm:hidden border-t border-stone-100"
(td :colspan "5" :class "px-3 py-3"
(div :class "flex flex-col gap-2 text-xs"
(div :class "flex items-center justify-between gap-2"
(span :class "font-mono text-[11px] text-stone-700" order-id)
(span :class pill status))
(div :class "text-[11px] text-stone-500 break-words" created)
(div :class "flex items-center justify-between gap-2"
(div :class "font-medium text-stone-800" total)
(a :href detail-url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
(defcomp ~cart-orders-end ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results")))
(defcomp ~cart-orders-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~cart-orders-table (&key rows)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm"
(thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"
(tr
(th :class "px-3 py-2 text-left font-medium" "Order")
(th :class "px-3 py-2 text-left font-medium" "Created")
(th :class "px-3 py-2 text-left font-medium" "Description")
(th :class "px-3 py-2 text-left font-medium" "Total")
(th :class "px-3 py-2 text-left font-medium" "Status")
(th :class "px-3 py-2 text-left font-medium")))
(tbody rows)))))
(defcomp ~cart-orders-filter (&key search-mobile)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
(div :class "md:hidden" search-mobile)))

View File

@@ -11,10 +11,6 @@
(defcomp ~cart-group-card-img (&key src alt)
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
(defcomp ~cart-group-card-placeholder ()
(div :class "h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"
(i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true")))
(defcomp ~cart-mp-subtitle (&key title)
(p :class "text-xs text-stone-500 truncate" title))
@@ -40,13 +36,6 @@
(div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" total)))))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))
(defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards)))

View File

@@ -2,20 +2,6 @@
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto"
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put update-url :sx-target "#payments-panel" :sx-swap "outerHTML" :sx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected"))))))
(~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")))

View File

@@ -166,7 +166,9 @@ def _page_group_card_sx(grp: Any, ctx: dict) -> str:
if feature_image:
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = sx_call("cart-group-card-placeholder")
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:
@@ -194,7 +196,13 @@ def _page_group_card_sx(grp: Any, ctx: dict) -> str:
def _empty_cart_sx() -> str:
"""Empty cart state."""
return sx_call("cart-empty")
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:
@@ -232,7 +240,9 @@ def _cart_item_sx(item: Any, ctx: dict) -> str:
if p.image:
img = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = sx_call("cart-item-no-img")
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:
@@ -381,7 +391,14 @@ def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
return sx_call("cart-page-empty")
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 '""'
@@ -416,16 +433,16 @@ def _order_row_sx(order: Any, detail_url: str) -> str:
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sx_call(
"cart-order-row-desktop",
order_id=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, detail_url=detail_url,
"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(
"cart-order-row-mobile",
order_id=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, detail_url=detail_url,
"order-row-mobile",
oid=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, url=detail_url,
)
return "(<> " + desktop + " " + mobile + ")"
@@ -450,7 +467,7 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
id_prefix="orders", colspan=5,
))
else:
parts.append(sx_call("cart-orders-end"))
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
@@ -458,13 +475,13 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return sx_call("cart-orders-empty")
return sx_call("cart-orders-table", rows=SxExpr(rows_sx))
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("cart-orders-filter", search_mobile=SxExpr(search_mobile_sx(ctx)))
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
# ---------------------------------------------------------------------------
@@ -480,21 +497,21 @@ def _order_items_sx(order: Any) -> str:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"cart-order-item-img",
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("cart-order-item-no-img")
img = sx_call("order-item-no-image")
parts.append(sx_call(
"cart-order-item",
prod_url=prod_url, img=SxExpr(img),
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
product_id=f"Product ID: {item.product_id}",
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("cart-order-items-panel", items=SxExpr(items_sx))
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _order_summary_sx(order: Any) -> str:
@@ -526,12 +543,12 @@ def _order_calendar_items_sx(calendar_entries: list | None) -> str:
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
parts.append(sx_call(
"cart-order-cal-entry",
"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("cart-order-cal-section", items=SxExpr(items_sx))
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
@@ -540,10 +557,10 @@ def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"cart-order-main",
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
cal=SxExpr(cal) if cal else None,
calendar=SxExpr(cal) if cal else None,
)
@@ -555,10 +572,10 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
pay_sx = None
if status != "paid":
pay_sx = sx_call("cart-order-pay-btn", url=pay_url)
pay_sx = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"cart-order-filter",
"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,
@@ -598,8 +615,8 @@ async def render_page_cart_page(ctx: dict, page_post: Any,
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
"cart-header-child-nested",
outer=SxExpr(child), inner=SxExpr(page_hdr),
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
)
header_rows = "(<> " + hdr + " " + nested + ")"
return full_page_sx(ctx, header_rows=header_rows, content=main)
@@ -612,8 +629,9 @@ async def render_page_cart_oob(ctx: dict, page_post: Any,
"""OOB response for page cart."""
main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn)
child_oob = sx_call("cart-header-child-oob",
inner=SxExpr(_page_cart_header_sx(ctx, 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)
oobs = "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
@@ -642,8 +660,8 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"cart-auth-header-child",
auth=SxExpr(auth), orders=SxExpr(orders_hdr),
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
@@ -676,8 +694,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"cart-auth-header-child-oob",
inner=SxExpr(_orders_header_sx(ctx, list_url)),
"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 + ")"
@@ -715,10 +734,10 @@ async def render_order_page(ctx: dict, order: Any,
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
"cart-order-header-child",
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
order=SxExpr(order_row),
"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 + ")"
@@ -747,8 +766,9 @@ async def render_order_oob(ctx: dict, order: Any,
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("cart-orders-header-child-oob",
inner=SxExpr(order_row_oob))
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 + ")"
@@ -760,18 +780,18 @@ async def render_order_oob(ctx: dict, order: Any,
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("cart-checkout-error-filter")
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("cart-checkout-error-order-id", order_id=f"#{order.id}")
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
"cart-checkout-error-content",
error_msg=err_msg,
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
)

113
docs/cssx.md Normal file
View File

@@ -0,0 +1,113 @@
# On-Demand CSS System — Replace Tailwind with Server-Driven Style Delivery
## Context
The app recently moved from Tailwind CDN to a pre-built tw.css (92KB, Tailwind v4), but v4 broke styles since the classes target v3. Currently reverted to CDN for dev. Rather than fixing the v3/v4 mismatch, we replace Tailwind entirely with an on-demand CSS system: the server knows exactly which classes each response uses (because it renders sx→HTML), so it sends only the CSS rules needed — zero unused CSS, zero build step, zero Tailwind dependency.
This mirrors the existing `SX-Components` dedup protocol: client tells server what it has, server sends only what's new.
## Phase 1: Minimal Viable System
### Step 1: CSS Registry — `shared/sx/css_registry.py` (new file)
Parse `tw.css` at startup into a dict mapping HTML class names → CSS rule text.
- **`load_css_registry(path)`** — parse tw.css once at startup, populate module-level `_REGISTRY: dict[str, str]` and `_PREAMBLE: str` (the `@property` / `@layer` declarations that define `--tw-*` vars)
- **`_css_selector_to_class(selector)`** — unescape CSS selectors (`.sm\:hidden``sm:hidden`, `.h-\[60vh\]``h-[60vh]`)
- **`lookup_rules(classes: set[str]) -> str`** — return concatenated CSS for a set of class names, preserving source order from tw.css
- **`get_preamble() -> str`** — return the preamble (sent once per page load)
The parser uses brace-depth tracking to split minified CSS into individual rules, extracts selectors, unescapes to get HTML class names. Rules inside `@media` blocks are stored with their wrapping `@media`.
### Step 2: Class Collection During Render — `shared/sx/html.py`
Add a `contextvars.ContextVar[set[str] | None]` for collecting classes. In `_render_element()` (line ~460-469), when processing a `class` attribute, split the value and add to the collector if active.
```python
# ~3 lines added in _render_element after the attr loop
if attr_name == "class" and attr_val:
collector = _css_class_collector.get(None)
if collector is not None:
collector.update(str(attr_val).split())
```
### Step 3: Sx Source Scanner — `shared/sx/css_registry.py`
For sx pages where the body is rendered *client-side* (sx source sent as text, not pre-rendered HTML), we can't use the render-time collector. Instead, scan sx source text for `:class "..."` patterns:
```python
def scan_classes_from_sx(source: str) -> set[str]:
"""Extract class names from :class "..." in sx source text."""
```
This runs on both component definitions and page sx source at response time.
### Step 4: Wire Into Responses — `shared/sx/helpers.py`
**`sx_page()` (full page loads, line 416):**
1. Scan `component_defs` + `page_sx` for classes via `scan_classes_from_sx()`
2. Look up rules + preamble from registry
3. Replace `<script src="https://cdn.tailwindcss.com...">` in `_SX_PAGE_TEMPLATE` with `<style id="sx-css">{preamble}\n{rules}</style>`
4. Add `<meta name="sx-css-classes" content="{comma-separated classes}">` so client knows what it has
**`sx_response()` (fragment swaps, line 325):**
1. Read `SX-Css` header from request (client's known classes)
2. Scan the sx source for classes
3. Compute new classes = found - known
4. If new rules exist, prepend `<style data-sx-css>{rules}</style>` to response body
5. Set `SX-Css-Add` response header with the new class names (so client can track without parsing CSS)
### Step 5: Client-Side Tracking — `shared/static/scripts/sx.js`
**Boot (page load):**
- Read `<meta name="sx-css-classes">` → populate `_sxCssKnown` dict
**Request header (in `_doFetch`, line ~1516):**
- After sending `SX-Components`, also send `SX-Css: flex,p-2,sm:hidden,...`
**Response handling (line ~1641 for text/sx, ~1698 for HTML):**
- Strip `<style data-sx-css>` blocks from response, inject into `<style id="sx-css">` in `<head>`
- Read `SX-Css-Add` response header, merge class names into `_sxCssKnown`
### Step 6: Jinja Path Fallback — `shared/browser/templates/_types/root/_head.html`
For non-sx pages still using Jinja templates, serve all CSS rules (full registry dump) in `<style id="sx-css">`. Add a Jinja global `sx_css_all()` in `shared/sx/jinja_bridge.py` that returns preamble + all rules. This is the same 92KB but self-hosted with no Tailwind dependency. These pages can be optimized later.
### Step 7: Startup — `shared/sx/jinja_bridge.py`
Call `load_css_registry()` in `setup_sx_bridge()` after loading components.
## Files to Modify
| File | Change |
|------|--------|
| `shared/sx/css_registry.py` | **NEW** — registry, parser, scanner, lookup |
| `shared/sx/html.py` | Add contextvar + 3 lines in `_render_element` |
| `shared/sx/helpers.py` | Modify `_SX_PAGE_TEMPLATE`, `sx_page()`, `sx_response()` |
| `shared/sx/jinja_bridge.py` | Call `load_css_registry()` at startup, add `sx_css_all()` Jinja global |
| `shared/static/scripts/sx.js` | `_sxCssKnown` tracking, `SX-Css` header, response CSS injection |
| `shared/browser/templates/_types/root/_head.html` | Replace Tailwind CDN with `{{ sx_css_all() }}` |
## Key Design Decisions
- **Source of truth:** tw.css (already compiled, known to be correct for current classes)
- **Dedup model:** Mirrors `SX-Components` — client declares what it has, server sends the diff
- **Header size:** ~599 class names × ~10 chars ≈ 6KB header — within limits, can optimize later with hashing
- **CSS ordering:** Registry preserves tw.css source order (later rules win for equal specificity)
- **Preamble:** `--tw-*` custom property defaults (~2KB) always included on first page load
## Verification
1. Start blog service: `./dev.sh blog`
2. Load `https://blog.rose-ash.com/index/` — verify styles match current CDN appearance
3. View page source — should have `<style id="sx-css">` instead of Tailwind CDN script
4. Navigate via sx swap (click a post) — check DevTools Network tab for `SX-Css` request header and `SX-Css-Add` response header
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)
- **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

View File

@@ -0,0 +1,366 @@
# Remove Ghost CMS — Native Content Pipeline
## Context
Ghost CMS is the write-primary for all blog content. The blog service mirrors everything to its own DB, proxies editor uploads through Ghost, and relies on Ghost for newsletter dispatch. This creates a hard dependency on an external process that duplicates what we already have locally.
We execute phases in order: implement → commit → push → test in dev → next phase. Ghost can be shut down after Phase 4.
---
## Phase 0 — Initial Sync & Cutover Preparation
One-time migration script to ensure `db_blog` is a complete, verified copy of production Ghost before we cut writes over.
### 0.1 Final sync script — `blog/scripts/final_ghost_sync.py`
Standalone CLI script (not a route) that:
1. Calls Ghost Admin API for ALL posts, pages, authors, tags with `?limit=all&formats=html,plaintext,mobiledoc,lexical&include=authors,tags`
2. Upserts everything into `db_blog` (reuses existing `ghost_sync.py` logic)
3. **Re-renders HTML** from `lexical` via `lexical_renderer.py` for every post — ensures our renderer matches Ghost's output
4. Compares our rendered HTML vs Ghost's HTML, logs diffs (catch rendering gaps before cutover)
5. Prints summary: X posts, Y pages, Z authors, W tags synced
### 0.2 Verification queries
After running the sync script:
- `SELECT count(*) FROM posts WHERE deleted_at IS NULL` matches Ghost post count
- `SELECT count(*) FROM posts WHERE html IS NULL AND status='published'` = 0
- `SELECT count(*) FROM posts WHERE lexical IS NULL AND status='published'` = 0
- Spot-check 5 posts: compare rendered HTML vs Ghost HTML
### 0.3 Cutover sequence (deploy day)
1. Run `final_ghost_sync.py` against production
2. Deploy Phase 1 code
3. Verify post create/edit works without Ghost
4. Disable Ghost webhooks (Phase 1.5 neuters them in code)
5. Ghost continues running for image serving only
### Commit: `Prepare Ghost cutover: final sync script with HTML verification`
---
## Phase 1 — Native Post Writes
Blog service creates/updates posts directly in `db_blog`. Ghost is no longer called for any content write.
### 1.1 DB migration (`blog/alembic/`)
- Make `ghost_id` nullable on `posts`, `authors`, `tags` in `shared/models/ghost_content.py`
- Add `server_default=gen_random_uuid()` on `Post.uuid`
- Add `server_default=func.now()` on `Post.updated_at`
### 1.2 New `PostWriter` — `blog/services/post_writer.py`
Replaces `ghost_posts.py`. Direct DB writes:
```
create_post(sess, title, lexical_json, status, feature_image, ..., user_id) -> Post
create_page(sess, title, lexical_json, ..., user_id) -> Post
update_post(sess, post_id, lexical_json, title, expected_updated_at, ...) -> Post
update_post_settings(sess, post_id, expected_updated_at, **kwargs) -> Post
delete_post(sess, post_id) -> None (soft delete via deleted_at)
```
Key logic:
- Render `html` + `plaintext` from `lexical_json` using existing `lexical_renderer.py`
- Calculate `reading_time` (word count in plaintext / 265)
- Generate slug from title (reuse existing slugify pattern in admin routes)
- Upsert tags by name with generated slugs (`ghost_id=None` for new tags)
- Optimistic lock: compare submitted `updated_at` vs DB value, 409 on mismatch
- Fire AP federation publish on status transitions (extract from `ghost_sync.py:_build_ap_post_data`)
- Invalidate Redis cache after writes
### 1.3 Rewrite post save routes
**`blog/bp/blog/admin/routes.py`** — `new_post_save`, `new_page_save`:
- Replace `ghost_posts.create_post()` + `sync_single_post()``PostWriter.create_post()`
**`blog/bp/post/admin/routes.py`** — `edit_save`, `settings_save`:
- Replace `ghost_posts.update_post()` + `sync_single_post()``PostWriter.update_post()`
- Replace `ghost_posts.update_post_settings()` + `sync_single_post()``PostWriter.update_post_settings()`
### 1.4 Rewrite post edit GET routes
**`blog/bp/post/admin/routes.py`** — `edit`, `settings`:
- Currently call `get_post_for_edit(ghost_id)` which fetches from Ghost Admin API
- Change to read `Post` row from local DB via `DBClient`
- Build a `post_data` dict from the ORM object matching the shape the templates expect
### 1.5 Neuter Ghost webhooks
**`blog/bp/blog/web_hooks/routes.py`**:
- Post/page/author/tag handlers → return 204 (no-op)
- Keep member webhook active (membership sync handled in Phase 3)
### 1.6 Newsletter sending — temporary disable
The editor's "publish + email" flow currently triggers Ghost's email dispatch. For Phase 1, **disable email sending in the UI** (hide publish-mode dropdown). Phase 3 restores it natively.
### Critical files
| File | Action |
|------|--------|
| `shared/models/ghost_content.py` | Make `ghost_id` nullable, add defaults |
| `blog/services/post_writer.py` | **New** — replaces `ghost_posts.py` |
| `blog/bp/blog/ghost/ghost_sync.py` | Extract AP publish logic, rest unused |
| `blog/bp/blog/ghost/ghost_db.py` | Keep — local DB reads, no Ghost coupling |
| `blog/bp/blog/ghost/lexical_renderer.py` | Keep — renders HTML from Lexical JSON |
| `blog/bp/blog/ghost/lexical_validator.py` | Keep — validates Lexical docs |
| `blog/bp/blog/admin/routes.py` | Rewrite create handlers |
| `blog/bp/post/admin/routes.py` | Rewrite edit/settings handlers |
| `blog/bp/blog/web_hooks/routes.py` | Neuter post/page/author/tag handlers |
### Verification
- Create a new post → check it lands in `db_blog` with rendered HTML
- Edit a post → verify `updated_at` optimistic lock works (edit in two tabs, second save gets 409)
- Edit post settings (slug, tags, visibility) → check DB updates
- Publish a post → verify AP federation fires
- Verify Ghost is never called (grep active code paths for `GHOST_ADMIN_API_URL`)
### Commit: `Phase 1: native post writes — blog service owns CRUD, Ghost no longer write-primary`
---
## Phase 2 — Local Image Storage
Replace Ghost upload proxy with local filesystem. Existing Ghost-hosted image URLs continue working.
### 2.1 Docker volume for media
Add `blog_media` volume in `docker-compose.yml` / `docker-compose.dev.yml`, mounted at `/app/media`.
### 2.2 Rewrite upload handlers — `blog/bp/blog/ghost/editor_api.py`
Replace Ghost-proxying handlers with local filesystem writes:
- Validate type + size (existing logic stays)
- Save to `MEDIA_ROOT/YYYY/MM/filename.ext` (content-hash prefix to avoid collisions)
- Return same JSON shape: `{"images": [{"url": "/media/YYYY/MM/filename.ext"}]}`
- `useFileUpload.js` needs zero changes (reads `data[responseKey][0].url`)
### 2.3 Static file route
Add `GET /media/<path:filename>``send_from_directory(MEDIA_ROOT, filename)`
### 2.4 OEmbed replacement
Replace Ghost oembed proxy with direct httpx call to `https://oembed.com/providers.json` endpoint registry + provider-specific URLs.
### 2.5 Legacy Ghost image URLs
Add catch-all `GET /content/images/<path:rest>` that reverse-proxies to Ghost. Keeps all existing post images working. Removed when Phase 2b migration runs.
### 2.6 (later) Bulk image migration script — `blog/scripts/migrate_ghost_images.py`
- Scan all posts for `/content/images/` URLs in `html`, `feature_image`, `lexical`, `og_image`, `twitter_image`
- Download each from Ghost
- Save to `MEDIA_ROOT` with same path structure
- Rewrite URLs in DB rows
- After running: remove the `/content/images/` proxy route
### Verification
- Upload an image in the editor → appears at `/media/...` URL
- Upload media and file → same
- OEmbed: paste a YouTube link in editor → embed renders
- Existing posts with Ghost image URLs still display correctly
- New posts only reference `/media/...` URLs
### Commit: `Phase 2: local image storage — uploads bypass Ghost, legacy URLs proxied`
---
## Phase 3 — Native Newsletter Sending
Account service sends newsletter emails directly via SMTP. Re-enable the publish+email UI.
### 3.1 New model — `NewsletterSend`
In `shared/models/` (lives in `db_account`):
```python
class NewsletterSend(Base):
__tablename__ = "newsletter_sends"
id, post_id (int), newsletter_id (FK), status (str),
recipient_count (int), sent_at (datetime), error_message (text nullable)
```
### 3.2 Newsletter email template
Create `account/templates/_email/newsletter_post.html` and `.txt`. Nick structure from Ghost's email templates (MIT-licensed). Template vars:
- `post_title`, `post_html`, `post_url`, `post_excerpt`, `feature_image`
- `newsletter_name`, `site_name`, `unsubscribe_url`
### 3.3 Send function — `account/services/newsletter_sender.py`
```python
async def send_newsletter_for_post(sess, post_id, newsletter_id, post_data) -> int:
```
- Query `UserNewsletter` where `subscribed=True` for this newsletter, join `User` for emails
- Render template per recipient (unique unsubscribe URL each)
- Send via existing `aiosmtplib` SMTP infrastructure (same config as magic links)
- Insert `NewsletterSend` record
- Return recipient count
### 3.4 Internal action — `account/bp/actions/routes.py`
Add `send-newsletter` action handler. Blog calls `call_action("account", "send-newsletter", payload={...})` after publish.
### 3.5 Unsubscribe route — `account/bp/auth/routes.py`
`GET /unsubscribe/<token>/` — HMAC-signed token of `(user_id, newsletter_id)`. Sets `UserNewsletter.subscribed = False`. Shows confirmation page.
### 3.6 Re-enable publish+email UI in blog editor
Restore the publish-mode dropdown in `blog/bp/post/admin/routes.py` `edit_save`:
- If `publish_mode in ("email", "both")` and `newsletter_id` set:
- Render post HTML from lexical
- `call_action("account", "send-newsletter", {...})`
- Check `NewsletterSend` record to show "already emailed" badge (replaces Ghost's `post.email.status`)
### Verification
- Publish a post with "Web + Email" → newsletter lands in test inbox
- Check `newsletter_sends` table has correct record
- Click unsubscribe link → subscription toggled off
- Re-publishing same post → "already emailed" badge shown, email controls disabled
- Toggle newsletter subscription in account dashboard → still works
### Commit: `Phase 3: native newsletter sending — SMTP via account service, Ghost email fully replaced`
---
## Phase 4 — Decouple Blog Models from Shared & Rename
Blog models currently live in `shared/models/ghost_content.py` — this couples every service to blog's schema. The `SqlBlogService` in `shared/services/blog_impl.py` gives shared code direct DB access to blog tables, bypassing the HTTP boundary that all other cross-domain reads use.
### 4.1 Move blog models out of shared
- **Move** `shared/models/ghost_content.py``blog/models/content.py`
- Models: `Post`, `Author`, `Tag`, `PostTag`, `PostAuthor`, `PostUser`
- **Delete** `blog/models/ghost_content.py` (the re-export shim) — imports now go to `blog/models/content.py`
- **Remove** `ghost_content` line from `shared/models/__init__.py`
- Update all blog-internal imports
### 4.2 Remove `SqlBlogService` from shared
- **Delete** `shared/services/blog_impl.py`
- **Remove** `BlogService` protocol from `shared/contracts/protocols.py`
- **Remove** `services.blog` slot from `shared/services/registry.py`
- Blog's own `blog/services/__init__.py` keeps its local service — not shared
### 4.3 Fix `entry_associations.py` to use HTTP
`shared/services/entry_associations.py` calls `services.blog.get_post_by_id(session)` — direct DB access despite its docstring claiming otherwise. Replace with:
```python
post = await fetch_data("blog", "post-by-id", params={"id": post_id})
```
This aligns with the architecture: cross-domain reads go via HTTP.
### 4.4 Rename ghost directories and files
- `blog/bp/blog/ghost/``blog/bp/blog/legacy/` or inline into `blog/bp/blog/`
- `ghost_sync.py``sync.py` (if still needed)
- `ghost_db.py``queries.py` or merge into services
- `editor_api.py` — stays, not Ghost-specific
- `lexical_renderer.py`, `lexical_validator.py` — stay
### 4.5 Delete Ghost integration code
- `blog/bp/blog/ghost/ghost_sync.py` — delete (AP logic already extracted in Phase 1)
- `blog/bp/blog/ghost/ghost_posts.py` — delete (replaced by PostWriter)
- `blog/bp/blog/ghost/ghost_admin_token.py` — delete
- `shared/infrastructure/ghost_admin_token.py` — delete
- `blog/bp/blog/web_hooks/routes.py` — delete entire blueprint (or gut to empty)
- `account/services/ghost_membership.py` — delete (sync functions, not needed)
### 4.6 Rename membership models
- `shared/models/ghost_membership_entities.py``shared/models/membership.py`
### 4.7 Rename models (with DB migrations)
| Old | New | Table rename |
|-----|-----|-------------|
| `GhostLabel` | `Label` | `ghost_labels``labels` |
| `GhostNewsletter` | `Newsletter` | `ghost_newsletters``newsletters` |
Drop entirely: `GhostTier` (`ghost_tiers`), `GhostSubscription` (`ghost_subscriptions`)
### 4.4 Rename User columns (migration)
- `ghost_status``member_status`
- `ghost_subscribed``email_subscribed`
- `ghost_note``member_note`
- Drop: `ghost_raw` (JSONB blob of Ghost member data)
- Keep `ghost_id` for now (dropped in Phase 5 with all other `ghost_id` columns)
### 4.5 Remove Ghost env vars
From `docker-compose.yml`, `docker-compose.dev.yml`, `.env`:
- `GHOST_API_URL`, `GHOST_ADMIN_API_URL`, `GHOST_PUBLIC_URL`
- `GHOST_CONTENT_API_KEY`, `GHOST_WEBHOOK_SECRET`, `GHOST_ADMIN_API_KEY`
From `shared/infrastructure/factory.py`: remove Ghost config lines (~97-99)
### Verification
- `grep -r "ghost" --include="*.py" . | grep -v alembic | grep -v __pycache__ | grep -v artdag` — should be minimal (only migration files, model `ghost_id` column refs)
- All services start cleanly without Ghost env vars
- Blog CRUD still works end-to-end
- Newsletter sending still works
### Commit: `Phase 4: remove Ghost code — rename models, drop Ghost env vars, clean codebase`
---
## Phase 5 — Author/Tag/Label/Newsletter Admin
Native admin UI for entities that were previously managed in Ghost Admin.
### 5.1 Author admin in blog
- Routes at `/settings/authors/` (follows existing `/settings/tag-groups/` pattern)
- List, create, edit, soft-delete
- Link author to User via email match
### 5.2 Tag admin enhancement
- Add create/edit/delete to existing tag admin
- Tags fully managed in `db_blog`
### 5.3 Newsletter admin in account
- Routes at `/settings/newsletters/`
- List, create, edit, soft-delete newsletters
### 5.4 Label admin in account
- Routes at `/settings/labels/`
- CRUD + assign/unassign labels to users
### 5.5 Drop all `ghost_id` columns
Final migration: drop `ghost_id` from `posts`, `authors`, `tags`, `users`, `labels`, `newsletters`.
Drop `Post.mobiledoc` (Ghost legacy format, always null for new posts).
### Verification
- Create/edit/delete authors, tags, newsletters, labels through admin UI
- Assigned labels show on user's account dashboard
- All CRUD operations persist correctly
### Commit: `Phase 5: native admin for authors, tags, labels, newsletters — drop ghost_id columns`
---
## Phase 6 (future) — SX-Native Posts
- New `body_sx` column on Post
- Block-based SX editor (Notion-style: sequence of typed blocks, not WYSIWYG)
- Each block = one SX form (paragraph, heading, image, quote, code)
- Posts render through SX pipeline instead of Lexical renderer
- Lexical → SX migration script for existing content

View File

@@ -235,7 +235,7 @@ def register():
)
).scalars().all()
associated_entries = await get_associated_entries(g.s, post_id)
associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob

View File

@@ -135,7 +135,7 @@ def register():
for post in entry_posts:
# Get associated entries for this post
from shared.services.entry_associations import get_associated_entries
associated_entries = await get_associated_entries(g.s, post.id)
associated_entries = await get_associated_entries(post.id)
# Load calendars for this post
from models.calendars import Calendar

View File

@@ -84,7 +84,7 @@ def register():
)
).scalars().all()
associated_entries = await get_associated_entries(g.s, post_id)
associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob

View File

@@ -50,9 +50,11 @@ def register():
page = int(request.args.get("page", 1))
exclude = request.args.get("exclude", "")
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
current_calendar = request.args.get("current_calendar", "")
styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button_less_pad", "")
nav_class = styles.get("nav_button", "")
select_colours = current_app.jinja_env.globals.get("select_colours", "")
parts = []
# Calendar entries nav
@@ -87,8 +89,10 @@ def register():
)
for cal in calendars:
href = events_url(f"/{post_slug}/{cal.slug}/")
is_selected = (cal.slug == current_calendar) if current_calendar else False
parts.append(sx_call("calendar-link-nav",
href=href, name=cal.name, nav_class=nav_class))
href=href, name=cal.name, nav_class=nav_class,
is_selected=is_selected, select_colours=select_colours))
if not parts:
return ""

View File

@@ -36,45 +36,6 @@
(div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" weekdays)
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" cells))))
(defcomp ~events-calendars-create-form (&key create-url csrf)
(<>
(div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
:sx-target "#calendars-list" :sx-select "#calendars-list" :sx-swap "outerHTML"
:sx-on:beforeRequest "document.querySelector('#cal-create-errors').textContent='';"
:sx-on:responseError "document.querySelector('#cal-create-errors').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Events, Gigs, Meetings"))
(button :type "submit" :class "border rounded px-3 py-2" "Add calendar"))))
(defcomp ~events-calendars-panel (&key form list)
(section :class "p-4"
form
(div :id "calendars-list" :class "mt-6" list)))
(defcomp ~events-calendars-empty ()
(p :class "text-gray-500 mt-4" "No calendars yet. Create one above."))
(defcomp ~events-calendars-item (&key href cal-name cal-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
(h3 :class "font-semibold" cal-name)
(h4 :class "text-gray-500" (str "/" cal-slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete calendar?"
:data-confirm-text "Entries will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-delete del-url :sx-trigger "confirmed"
:sx-target "#calendars-list" :sx-select "#calendars-list" :sx-swap "outerHTML"
:sx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))
(defcomp ~events-calendar-description-display (&key description edit-url)
(div :id "calendar-description"
(if description

View File

@@ -1,8 +1,5 @@
;; Events entry card components (all events / page summary)
(defcomp ~events-state-badge (&key cls label)
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " cls) label))
(defcomp ~events-entry-title-linked (&key href name)
(a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name)))
@@ -61,43 +58,8 @@
(div :class "pt-2 pb-1"
(h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" date-str)))
(defcomp ~events-sentinel (&key page next-url)
(div :id (str "sentinel-" page) :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"
:role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))
(defcomp ~events-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"
:viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~events-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"
:viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~events-view-toggle (&key list-href tile-href hx-select list-active tile-active list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
:class (str "p-1.5 rounded " list-active) :title "List view"
:_ "on click js localStorage.removeItem('events_view') end"
list-svg)
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
:class (str "p-1.5 rounded " tile-active) :title "Tile view"
:_ "on click js localStorage.setItem('events_view','tile') end"
tile-svg)))
(defcomp ~events-grid (&key grid-cls cards)
(div :class grid-cls cards))
(defcomp ~events-empty ()
(div :class "px-3 py-12 text-center text-stone-400"
(i :class "fa fa-calendar-xmark text-4xl mb-3" :aria-hidden "true")
(p :class "text-lg" "No upcoming events")))
(defcomp ~events-main-panel-body (&key toggle body)
(<> toggle body (div :class "pb-8")))

View File

@@ -494,8 +494,4 @@
(defcomp ~events-admin-placeholder-nav ()
(div :class "relative nav-group"
(span :class "block px-3 py-2 text-stone-400 text-sm italic" "Admin options")))
;; Entry admin main panel — ticket_types link
(defcomp ~events-entry-admin-main-panel (&key link)
link)
(span :class "block px-3 py-2 text-stone-400 text-sm italic" "Admin options")))

View File

@@ -23,15 +23,6 @@
;; Account page tickets (fragments/account_page_tickets.html)
;; ---------------------------------------------------------------------------
(defcomp ~events-frag-ticket-badge (&key state)
(cond
((= state "checked_in")
(span :class "inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700" "checked in"))
((= state "confirmed")
(span :class "inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700" "confirmed"))
(true
(span :class "inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700" state))))
(defcomp ~events-frag-ticket-item (&key href entry-name date-str calendar-name type-name badge)
(div :class "py-4 first:pt-0 last:pb-0"
(div :class "flex items-start justify-between gap-4"
@@ -50,9 +41,6 @@
(h1 :class "text-xl font-semibold tracking-tight" "Tickets")
items)))
(defcomp ~events-frag-tickets-empty ()
(p :class "text-sm text-stone-500" "No tickets yet."))
(defcomp ~events-frag-tickets-list (&key items)
(div :class "divide-y divide-stone-100" items))
@@ -61,15 +49,6 @@
;; Account page bookings (fragments/account_page_bookings.html)
;; ---------------------------------------------------------------------------
(defcomp ~events-frag-booking-badge (&key state)
(cond
((= state "confirmed")
(span :class "inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700" "confirmed"))
((= state "provisional")
(span :class "inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700" "provisional"))
(true
(span :class "inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600" state))))
(defcomp ~events-frag-booking-item (&key name date-str calendar-name cost-str badge)
(div :class "py-4 first:pt-0 last:pb-0"
(div :class "flex items-start justify-between gap-4"
@@ -87,8 +66,5 @@
(h1 :class "text-xl font-semibold tracking-tight" "Bookings")
items)))
(defcomp ~events-frag-bookings-empty ()
(p :class "text-sm text-stone-500" "No bookings yet."))
(defcomp ~events-frag-bookings-list (&key items)
(div :class "divide-y divide-stone-100" items))

View File

@@ -1,39 +0,0 @@
;; Events markets components
(defcomp ~events-markets-create-form (&key create-url csrf)
(<>
(div :id "market-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
:sx-target "#markets-list" :sx-select "#markets-list" :sx-swap "outerHTML"
:sx-on:beforeRequest "document.querySelector('#market-create-errors').textContent='';"
:sx-on:responseError "document.querySelector('#market-create-errors').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Farm Shop, Bakery"))
(button :type "submit" :class "border rounded px-3 py-2" "Add market"))))
(defcomp ~events-markets-panel (&key form list)
(section :class "p-4"
form
(div :id "markets-list" :class "mt-6" list)))
(defcomp ~events-markets-empty ()
(p :class "text-gray-500 mt-4" "No markets yet. Create one above."))
(defcomp ~events-markets-item (&key href market-name market-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
(h3 :class "font-semibold" market-name)
(h4 :class "text-gray-500" (str "/" market-slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete market?"
:data-confirm-text "Products will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-delete del-url :sx-trigger "confirmed"
:sx-target "#markets-list" :sx-select "#markets-list" :sx-swap "outerHTML"
:sx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))

View File

@@ -2,23 +2,9 @@
(defcomp ~events-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto"
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put update-url :sx-target "#payments-panel" :sx-swap "outerHTML" :sx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected"))))))
(~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")))
(defcomp ~events-markets-create-form (&key create-url csrf)
(<>

View File

@@ -37,7 +37,7 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
def _clear_oob(*ids: str) -> str:
"""Generate OOB swaps to remove orphaned header rows/children."""
return "".join(f'<div id="{i}" hx-swap-oob="outerHTML"></div>' for i in ids)
return "".join(f'(div :id "{i}" :hx-swap-oob "outerHTML")' for i in ids)
# All possible header row/child IDs at each depth (deepest first)
@@ -68,11 +68,14 @@ async def _ensure_container_nav(ctx: dict) -> dict:
slug = post.get("slug", "")
if not post_id:
return ctx
from quart import g
from shared.infrastructure.fragments import fetch_fragments
current_cal = getattr(g, "calendar_slug", "") or ""
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
"current_calendar": current_cal,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
@@ -361,12 +364,15 @@ def _calendars_main_panel_sx(ctx: dict) -> str:
form_html = ""
if can_create:
create_url = url_for("calendars.create_calendar")
form_html = sx_call("events-calendars-create-form",
create_url=create_url, csrf=csrf)
form_html = sx_call("crud-create-form",
create_url=create_url, csrf=csrf,
errors_id="cal-create-errors", list_id="calendars-list",
placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar")
list_html = _calendars_list_sx(ctx, calendars)
return sx_call("events-calendars-panel",
form=SxExpr(form_html), list=SxExpr(list_html))
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="calendars-list")
def _calendars_list_sx(ctx: dict, calendars: list) -> str:
@@ -378,7 +384,8 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
prefix = route_prefix()
if not calendars:
return sx_call("events-calendars-empty")
return sx_call("empty-state", message="No calendars yet. Create one above.",
cls="text-gray-500 mt-4")
parts = []
for cal in calendars:
@@ -387,9 +394,12 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sx_call("events-calendars-item",
href=href, cal_name=cal_name, cal_slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr))
parts.append(sx_call("crud-item",
href=href, name=cal_name, slug=cal_slug,
del_url=del_url, csrf_hdr=csrf_hdr,
list_id="calendars-list",
confirm_title="Delete calendar?",
confirm_text="Entries will be hidden (soft delete)"))
return "".join(parts)
@@ -504,13 +514,15 @@ def _calendar_main_panel_html(ctx: dict) -> str:
bg_cls=bg_cls, name=e.name,
state_label=state_label))
badges_html = "".join(entry_badges)
badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else ""
cells.append(sx_call("events-calendar-cell",
cell_cls=cell_cls, day_short=SxExpr(day_short_html),
day_num=SxExpr(day_num_html), badges=SxExpr(badges_html)))
day_num=SxExpr(day_num_html),
badges=SxExpr(badges_html) if badges_html else None))
cells_html = "".join(cells)
arrows_html = "".join(nav_arrows)
cells_html = "(<> " + "".join(cells) + ")"
arrows_html = "(<> " + "".join(nav_arrows) + ")"
wd_html = "(<> " + wd_html + ")"
return sx_call("events-calendar-grid",
arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html),
cells=SxExpr(cells_html))
@@ -632,7 +644,7 @@ def _entry_state_badge_html(state: str) -> str:
}
cls = state_classes.get(state, "bg-stone-100 text-stone-700")
label = state.replace("_", " ").capitalize()
return sx_call("events-state-badge", cls=cls, label=label)
return sx_call("badge", cls=cls, label=label)
# ---------------------------------------------------------------------------
@@ -693,12 +705,15 @@ def _markets_main_panel_html(ctx: dict) -> str:
form_html = ""
if can_create:
create_url = url_for("markets.create_market")
form_html = sx_call("events-markets-create-form",
create_url=create_url, csrf=csrf)
form_html = sx_call("crud-create-form",
create_url=create_url, csrf=csrf,
errors_id="market-create-errors", list_id="markets-list",
placeholder="e.g. Farm Shop, Bakery", btn_label="Add market")
list_html = _markets_list_html(ctx, markets)
return sx_call("events-markets-panel",
form=SxExpr(form_html), list=SxExpr(list_html))
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="markets-list")
def _markets_list_html(ctx: dict, markets: list) -> str:
@@ -710,7 +725,8 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
slug = post.get("slug", "")
if not markets:
return sx_call("events-markets-empty")
return sx_call("empty-state", message="No markets yet. Create one above.",
cls="text-gray-500 mt-4")
parts = []
for m in markets:
@@ -719,10 +735,13 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
del_url = url_for("markets.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sx_call("events-markets-item",
href=market_href, market_name=m_name,
market_slug=m_slug, del_url=del_url,
csrf_hdr=csrf_hdr))
parts.append(sx_call("crud-item",
href=market_href, name=m_name,
slug=m_slug, del_url=del_url,
csrf_hdr=csrf_hdr,
list_id="markets-list",
confirm_title="Delete market?",
confirm_text="Products will be hidden (soft delete)"))
return "".join(parts)
@@ -740,7 +759,7 @@ def _ticket_state_badge_html(state: str) -> str:
}
cls = cls_map.get(state, "bg-stone-100 text-stone-700")
label = (state or "").replace("_", " ").capitalize()
return sx_call("events-state-badge", cls=cls, label=label)
return sx_call("badge", cls=cls, label=label)
# ---------------------------------------------------------------------------
@@ -1085,8 +1104,8 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
))
if has_more:
parts.append(sx_call("events-sentinel",
page=str(page), next_url=next_url))
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page}", next_url=next_url))
return "".join(parts)
@@ -1101,14 +1120,14 @@ _TILE_SVG = None
def _get_list_svg():
global _LIST_SVG
if _LIST_SVG is None:
_LIST_SVG = sx_call("events-list-svg")
_LIST_SVG = sx_call("list-svg")
return _LIST_SVG
def _get_tile_svg():
global _TILE_SVG
if _TILE_SVG is None:
_TILE_SVG = sx_call("events-tile-svg")
_TILE_SVG = sx_call("tile-svg")
return _TILE_SVG
@@ -1131,11 +1150,11 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600'
tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600'
return sx_call("events-view-toggle",
return sx_call("view-toggle",
list_href=list_href, tile_href=tile_href,
hx_select=hx_select, list_active=list_active,
tile_active=tile_active, list_svg=_get_list_svg(),
tile_svg=_get_tile_svg())
hx_select=hx_select, list_cls=list_active,
tile_cls=tile_active, storage_key="events_view",
list_svg=_get_list_svg(), tile_svg=_get_tile_svg())
def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info,
@@ -1154,7 +1173,9 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
if view == "tile" else "max-w-full px-3 py-3 space-y-3")
body = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards))
else:
body = sx_call("events-empty")
body = sx_call("empty-state", icon="fa fa-calendar-xmark",
message="No upcoming events",
cls="px-3 py-12 text-center text-stone-400")
return sx_call("events-main-panel-body",
toggle=SxExpr(toggle), body=SxExpr(body))
@@ -1462,7 +1483,7 @@ async def render_slots_page(ctx: dict) -> str:
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav(ctx)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
@@ -1477,7 +1498,7 @@ async def render_slots_oob(ctx: dict) -> str:
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav(ctx)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
@@ -3012,7 +3033,7 @@ async def render_slot_page(ctx: dict) -> str:
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav(ctx)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
@@ -3030,7 +3051,7 @@ async def render_slot_oob(ctx: dict) -> str:
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav(ctx)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
@@ -3465,15 +3486,16 @@ def render_fragment_account_tickets(tickets) -> str:
type_name = ""
if getattr(ticket, "ticket_type_name", None):
type_name = f'<span>&middot; {escape(ticket.ticket_type_name)}</span>'
badge_html = sx_call("events-frag-ticket-badge",
state=getattr(ticket, "state", ""))
badge_html = sx_call("status-pill",
status=getattr(ticket, "state", ""))
items_html += sx_call("events-frag-ticket-item",
href=href, entry_name=ticket.entry_name,
date_str=date_str, calendar_name=cal_name,
type_name=type_name, badge=badge_html)
body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
else:
body = sx_call("events-frag-tickets-empty")
body = sx_call("empty-state", message="No tickets yet.",
cls="text-sm text-stone-500")
return sx_call("events-frag-tickets-panel", items=SxExpr(body))
@@ -3498,8 +3520,8 @@ def render_fragment_account_bookings(bookings) -> str:
cost_str = ""
if getattr(booking, "cost", None):
cost_str = f'<span>&middot; &pound;{escape(str(booking.cost))}</span>'
badge_html = sx_call("events-frag-booking-badge",
state=getattr(booking, "state", ""))
badge_html = sx_call("status-pill",
status=getattr(booking, "state", ""))
items_html += sx_call("events-frag-booking-item",
name=booking.name,
date_str=date_str + date_str_extra,
@@ -3507,6 +3529,7 @@ def render_fragment_account_bookings(bookings) -> str:
badge=badge_html)
body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
else:
body = sx_call("events-frag-bookings-empty")
body = sx_call("empty-state", message="No bookings yet.",
cls="text-sm text-stone-500")
return sx_call("events-frag-bookings-panel", items=SxExpr(body))

View File

@@ -1,31 +1,5 @@
;; Auth components (login, check email, choose username)
(defcomp ~federation-error-banner (&key error)
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" error))
(defcomp ~federation-login-form (&key error action csrf email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
(defcomp ~federation-check-email-error (&key error)
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" error))
(defcomp ~federation-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
error))
;; Auth components (choose username — federation-specific)
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
(defcomp ~federation-choose-username (&key domain error csrf username check-url)
(div :class "py-8 max-w-md mx-auto"

View File

@@ -15,9 +15,6 @@
preview
(div :class "text-xs text-stone-400 mt-1" time)))))
(defcomp ~federation-notifications-empty ()
(p :class "text-stone-500" "No notifications yet."))
(defcomp ~federation-notifications-list (&key items)
(div :class "space-y-2" items))

View File

@@ -1,10 +1,11 @@
;; Search and actor card components
;; Aliases — delegate to shared ~avatar
(defcomp ~federation-actor-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(~avatar :src src :cls cls))
(defcomp ~federation-actor-avatar-placeholder (&key cls initial)
(div :class cls initial))
(~avatar :cls cls :initial initial))
(defcomp ~federation-actor-name-link (&key href name)
(a :href href :class "font-semibold text-stone-900 hover:underline" name))

View File

@@ -6,9 +6,6 @@
(nav :class "flex gap-3 text-sm items-center"
(a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username")))
(defcomp ~federation-nav-link (&key href cls label)
(a :href href :class cls label))
(defcomp ~federation-nav-notification-link (&key href cls count-url)
(a :href href :class cls "Notifications"
(span :sx-get count-url :sx-trigger "load, every 30s" :sx-swap "innerHTML"
@@ -21,35 +18,29 @@
(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"
(div :class "w-full flex flex-row items-center gap-2 flex-wrap" nav)))
(defcomp ~federation-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center" inner))
;; --- Post card ---
(defcomp ~federation-boost-label (&key name)
(div :class "text-sm text-stone-500 mb-2" "Boosted by " name))
;; Aliases — delegate to shared ~avatar
(defcomp ~federation-avatar-img (&key src cls)
(img :src src :alt "" :class cls))
(~avatar :src src :cls cls))
(defcomp ~federation-avatar-placeholder (&key cls initial)
(div :class cls initial))
(~avatar :cls cls :initial initial))
(defcomp ~federation-content-cw (&key summary content)
(details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary))
(defcomp ~federation-content (&key content summary)
(if summary
(details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content))))
(defcomp ~federation-content-plain (&key content)
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))
(defcomp ~federation-original-link (&key url)
(a :href url :target "_blank" :rel "noopener"
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
(defcomp ~federation-interactions-wrap (&key id buttons)
(div :id id buttons))
(defcomp ~federation-post-card (&key boost avatar actor-name actor-username domain time content original interactions)
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
boost

View File

@@ -11,6 +11,7 @@ from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize
from shared.sx.helpers import (
sx_call, SxExpr,
root_header_sx, full_page_sx, header_child_sx,
@@ -45,12 +46,8 @@ def _social_nav_sx(actor: Any) -> str:
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(sx_call(
"federation-nav-link",
href=href,
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
label=label,
))
cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}"
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
# Notifications with live badge
notif_url = url_for("social.notifications")
@@ -65,12 +62,7 @@ def _social_nav_sx(actor: Any) -> str:
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(sx_call(
"federation-nav-link",
href=profile_url,
cls="px-2 py-1 rounded hover:bg-stone-200",
label=f"@{actor.preferred_username}",
))
parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})')
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("federation-nav-bar", items=SxExpr(items_sx))
@@ -171,26 +163,23 @@ def _post_card_sx(item: Any, actor: Any) -> str:
"federation-boost-label", name=str(escape(boosted_by)),
) if boosted_by else ""
if actor_icon:
avatar = sx_call("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = sx_call(
"federation-avatar-placeholder",
cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=initial,
)
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
avatar = sx_call(
"avatar", src=actor_icon or None,
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=None if actor_icon else initial,
)
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
time_str = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_sx = sx_call(
"federation-content-cw",
summary=str(escape(summary)), content=content,
"federation-content",
content=content, summary=str(escape(summary)),
)
else:
content_sx = sx_call("federation-content-plain", content=content)
content_sx = sx_call("federation-content", content=content)
original_sx = ""
if url and post_type == "remote":
@@ -200,11 +189,7 @@ def _post_card_sx(item: Any, actor: Any) -> str:
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_sx = sx_call(
"federation-interactions-wrap",
id=f"interactions-{safe_id}",
buttons=SxExpr(_interaction_buttons_sx(item, actor)),
)
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
return sx_call(
"federation-post-card",
@@ -263,15 +248,12 @@ def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url:
avatar = sx_call("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = sx_call(
"federation-actor-avatar-placeholder",
cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=initial,
)
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=None if icon_url else initial,
)
# Name link
if (list_type in ("following", "search")) and aid:
@@ -359,15 +341,12 @@ def _notification_sx(notif: Any) -> str:
border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon:
avatar = sx_call("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
else:
initial = from_name[0].upper() if from_name else "?"
avatar = sx_call(
"federation-avatar-placeholder",
cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=initial,
)
initial = from_name[0].upper() if (not from_icon and from_name) else "?"
avatar = sx_call(
"avatar", src=from_icon or None,
cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=None if from_icon else initial,
)
domain_str = f"@{escape(from_domain)}" if from_domain else ""
@@ -423,12 +402,12 @@ async def render_login_page(ctx: dict) -> str:
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_sx = sx_call("federation-error-banner", error=error) if error else ""
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
"federation-login-form",
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action, csrf=csrf,
action=action, csrf_token=csrf,
email=str(escape(email)),
)
@@ -442,11 +421,11 @@ async def render_check_email_page(ctx: dict) -> str:
email_error = ctx.get("email_error")
error_sx = sx_call(
"federation-check-email-error", error=str(escape(email_error)),
"auth-check-email-error", error=str(escape(email_error)),
) if email_error else ""
content = sx_call(
"federation-check-email",
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
@@ -622,15 +601,12 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
summary = getattr(remote_actor, "summary", None)
actor_url = getattr(remote_actor, "actor_url", "")
if icon_url:
avatar = sx_call("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
else:
initial = display_name[0].upper() if display_name else "?"
avatar = sx_call(
"federation-avatar-placeholder",
cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=initial,
)
initial = display_name[0].upper() if (not icon_url and display_name) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=None if icon_url else initial,
)
summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else ""
@@ -687,7 +663,8 @@ async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications:
notif_sx = sx_call("federation-notifications-empty")
notif_sx = sx_call("empty-state", message="No notifications yet.",
cls="text-stone-500")
else:
items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")"
notif_sx = sx_call(
@@ -717,7 +694,7 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_sx = sx_call("federation-error-banner", error=error) if error else ""
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
"federation-choose-username",

View File

@@ -47,12 +47,14 @@ def register():
if not markets:
return ""
styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button_less_pad", "")
nav_class = styles.get("nav_button", "")
select_colours = current_app.jinja_env.globals.get("select_colours", "")
parts = []
for m in markets:
href = market_url(f"/{post_slug}/{m.slug}/")
parts.append(sx_call("market-link-nav",
href=href, name=m.name, nav_class=nav_class))
href=href, name=m.name, nav_class=nav_class,
select_colours=select_colours))
return "(<> " + " ".join(parts) + ")"
_handlers["container-nav"] = _container_nav_handler

View File

@@ -1,9 +1,27 @@
from __future__ import annotations
from quart import make_response, Blueprint
import re
import unicodedata
from quart import make_response, request, g, Blueprint
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.services.registry import services
from shared.sx.helpers import sx_response
def _slugify(value: str, max_len: int = 255) -> str:
if value is None:
value = ""
value = unicodedata.normalize("NFKD", value)
value = value.encode("ascii", "ignore").decode("ascii")
value = value.lower()
value = value.replace("/", "-")
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
value = value.strip("-")[:max_len].strip("-")
return value or "market"
def register():
@@ -20,8 +38,51 @@ def register():
html = await render_page_admin_page(tctx)
return await make_response(html)
else:
from shared.sx.helpers import sx_response
sx_src = await render_page_admin_oob(tctx)
return sx_response(sx_src)
@bp.post("/new/")
@require_admin
async def create_market(**kwargs):
form = await request.form
name = (form.get("name") or "").strip()
post_data = getattr(g, "post_data", None)
post_id = (post_data.get("post") or {}).get("id") if post_data else None
if not post_id:
return await make_response("No page context", 400)
slug = _slugify(name)
try:
await services.market.create_marketplace(g.s, "page", post_id, name, slug)
except Exception as e:
from shared.sx.jinja_bridge import render as render_comp
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_admin_list_panel
ctx = await get_template_context()
html = await render_markets_admin_list_panel(ctx)
return sx_response(html)
@bp.delete("/<market_slug>/")
@require_admin
async def delete_market(**kwargs):
market_slug = g.get("market_slug", "")
post_data = getattr(g, "post_data", None)
post_id = (post_data.get("post") or {}).get("id") if post_data else None
if not post_id:
return await make_response("No page context", 400)
await services.market.soft_delete_marketplace(g.s, "page", post_id, market_slug)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_admin_list_panel
ctx = await get_template_context()
html = await render_markets_admin_list_panel(ctx)
return sx_response(html)
return bp

View File

@@ -19,9 +19,6 @@
(when labels (ul :class "flex flex-row gap-1" (map (lambda (l) (li l)) labels)))
(div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" brand))))
(defcomp ~market-card-label-item (&key label)
(li label))
(defcomp ~market-card-sticker (&key src name ring-cls)
(img :src src :alt name :class (str "w-6 h-6" ring-cls)))
@@ -32,15 +29,9 @@
(defcomp ~market-card-highlight (&key pre mid post)
(<> pre (mark mid) post))
(defcomp ~market-card-text (&key text)
(<> text))
;; Price — single component accepts both prices, renders correctly
;; Price — delegates to shared ~price
(defcomp ~market-card-price (&key special-price regular-price)
(div :class "mt-1 flex items-baseline gap-2 justify-center"
(when special-price (div :class "text-lg font-semibold text-emerald-700" special-price))
(when (and special-price regular-price) (div :class "text-sm line-through text-stone-500" regular-price))
(when (and (not special-price) regular-price) (div :class "mt-1 text-lg font-semibold" regular-price))))
(~price :special-price special-price :regular-price regular-price))
;; Main product card — accepts pure data, composes sub-components
(defcomp ~market-product-card (&key href hx-select
@@ -104,31 +95,3 @@
(if desc-content desc-content (when desc desc)))
(if badge-content badge-content (when badge badge))))
(defcomp ~market-sentinel-mobile (&key id next-url hyperscript)
(div :id id
:class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML"
:_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading text-center text-xs text-stone-400" "loading...")
(div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying...")))
(defcomp ~market-sentinel-desktop (&key id next-url hyperscript)
(div :id id
:class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML"
:_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading text-center text-xs text-stone-400" "loading...")
(div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying...")))
(defcomp ~market-sentinel-end ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~market-market-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms"
:sx-swap "outerHTML" :role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))

View File

@@ -3,17 +3,9 @@
(defcomp ~market-markets-grid (&key cards)
(div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" cards))
(defcomp ~market-no-markets (&key message)
(div :class "px-3 py-12 text-center text-stone-400"
(i :class "fa fa-store text-4xl mb-3" :aria-hidden "true")
(p :class "text-lg" message)))
(defcomp ~market-product-grid (&key cards)
(<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" cards) (div :class "pb-8")))
(defcomp ~market-bottom-spacer ()
(div :class "pb-8"))
(defcomp ~market-like-toggle-button (&key colour action hx-headers label icon-cls)
(button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]")
:sx-post action :sx-target "this" :sx-swap "outerHTML" :sx-push-url "false"

View File

@@ -6,9 +6,6 @@
(div :class "flex flex-col md:flex-row md:gap-2 text-xs"
(div top-slug) sub-div)))
(defcomp ~market-sub-slug (&key sub)
(div sub))
(defcomp ~market-product-label (&key title)
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title)))

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize
from shared.sx.helpers import (
call_url, get_asset_url, sx_call, SxExpr,
root_header_sx,
@@ -102,7 +103,7 @@ def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
sub_slug = ctx.get("sub_slug", "")
hx_select_search = ctx.get("hx_select_search", "#main-panel")
sub_div = sx_call("market-sub-slug", sub=sub_slug) if sub_slug else ""
sub_div = f'(div {serialize(sub_slug)})' if sub_slug else ""
label_sx = sx_call(
"market-shop-label",
title=market_title, top_slug=top_slug or "",
@@ -439,14 +440,14 @@ def _product_cards_sx(ctx: dict) -> str:
else:
next_qs = f"?page={page + 1}"
next_url = prefix + current_local_href + next_qs
parts.append(sx_call("market-sentinel-mobile",
parts.append(sx_call("sentinel-mobile",
id=f"sentinel-{page}-m", next_url=next_url,
hyperscript=_MOBILE_SENTINEL_HS))
parts.append(sx_call("market-sentinel-desktop",
parts.append(sx_call("sentinel-desktop",
id=f"sentinel-{page}-d", next_url=next_url,
hyperscript=_DESKTOP_SENTINEL_HS))
else:
parts.append(sx_call("market-sentinel-end"))
parts.append(sx_call("end-of-results"))
return "(<> " + " ".join(parts) + ")"
@@ -1170,7 +1171,7 @@ def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
post_slug=post_slug) for m in markets]
if has_more:
parts.append(sx_call(
"market-market-sentinel",
"sentinel-simple",
id=f"sentinel-{page}", next_url=next_url,
))
return "(<> " + " ".join(parts) + ")"
@@ -1197,7 +1198,8 @@ def _markets_grid(cards_sx: str) -> str:
def _no_markets_sx(message: str = "No markets available") -> str:
"""Empty state for markets as sx."""
return sx_call("market-no-markets", message=message)
return sx_call("empty-state", icon="fa fa-store", message=message,
cls="px-3 py-12 text-center text-stone-400")
async def render_all_markets_page(ctx: dict, markets: list, has_more: bool,
@@ -1214,7 +1216,7 @@ async def render_all_markets_page(ctx: dict, markets: list, has_more: bool,
content = _markets_grid(cards)
else:
content = _no_markets_sx()
content = "(<> " + content + " " + sx_call("market-bottom-spacer") + ")"
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content)
@@ -1234,7 +1236,7 @@ async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool,
content = _markets_grid(cards)
else:
content = _no_markets_sx()
content = "(<> " + content + " " + sx_call("market-bottom-spacer") + ")"
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
oobs = root_header_sx(ctx, oob=True)
return oob_page_sx(oobs=oobs, content=content)
@@ -1272,7 +1274,7 @@ async def render_page_markets_page(ctx: dict, markets: list, has_more: bool,
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
content = "(<> " + content + " " + sx_call("market-bottom-spacer") + ")"
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
hdr = root_header_sx(ctx)
hdr = "(<> " + hdr + " " + header_child_sx(_post_header_sx(ctx)) + ")"
@@ -1296,7 +1298,7 @@ async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool,
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
content = "(<> " + content + " " + sx_call("market-bottom-spacer") + ")"
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
oobs = _oob_header_sx("post-header-child", "market-header-child", "")
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + ")"
@@ -1516,6 +1518,73 @@ def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "")
# Page admin (/<slug>/admin/) — post-level admin for markets
# ---------------------------------------------------------------------------
async def _markets_admin_panel_sx(ctx: dict) -> str:
"""Render the markets list + create form panel."""
from quart import g, url_for
from shared.services.registry import services
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
post_id = post.get("id")
markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
form_html = ""
if can_create:
create_url = url_for("page_admin.create_market")
form_html = sx_call("crud-create-form",
create_url=create_url, csrf=csrf,
errors_id="market-create-errors",
list_id="markets-list",
placeholder="e.g. Suma, Craft Fair",
btn_label="Add market")
list_html = _markets_admin_list_sx(ctx, markets)
return sx_call("crud-panel",
form=SxExpr(form_html), list=SxExpr(list_html),
list_id="markets-list")
def _markets_admin_list_sx(ctx: dict, markets: list) -> str:
"""Render the markets list items."""
from quart import url_for
from shared.utils import route_prefix
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not markets:
return sx_call("empty-state",
message="No markets yet. Create one above.",
cls="text-gray-500 mt-4")
parts = []
for m in markets:
m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
post_slug = (ctx.get("post") or {}).get("slug", "")
href = prefix + f"/{post_slug}/{m_slug}/"
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sx_call("crud-item",
href=href, name=m_name, slug=m_slug,
del_url=del_url, csrf_hdr=csrf_hdr,
list_id="markets-list",
confirm_title="Delete market?",
confirm_text="Products will be hidden (soft delete)"))
return "".join(parts)
async def render_markets_admin_list_panel(ctx: dict) -> str:
"""Render the markets admin panel HTML for POST/DELETE response."""
return await _markets_admin_panel_sx(ctx)
async def render_page_admin_page(ctx: dict) -> str:
"""Full page: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
@@ -1523,7 +1592,8 @@ async def render_page_admin_page(ctx: dict) -> str:
hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + admin_hdr + ")"
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))'
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return full_page_sx(ctx, header_rows=hdr, content=content)
@@ -1533,7 +1603,8 @@ async def render_page_admin_oob(ctx: dict) -> str:
oobs = "(<> " + post_admin_header_sx(ctx, slug, oob=True, selected="markets") + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child") + ")"
content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))'
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return oob_page_sx(oobs=oobs, content=content)

View File

@@ -1,38 +0,0 @@
;; Checkout error components
(defcomp ~orders-checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~orders-checkout-error-order-id (&key oid)
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" oid)))
(defcomp ~orders-checkout-error-pay-btn (&key url)
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
(defcomp ~orders-checkout-error-content (&key msg order back-url)
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")
(p msg)
order)
(div
(a :href back-url
:class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))
(defcomp ~orders-detail-filter (&key created status list-url recheck-url csrf pay)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Placed " created " \u00b7 Status: " status))
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
(form :method "post" :action recheck-url :class "inline"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit"
:class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
pay)))

View File

@@ -1,54 +0,0 @@
;; Order detail components
(defcomp ~orders-item-image (&key src alt)
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~orders-item-no-image ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~orders-item-row (&key href img title pid qty price)
(li (a :class "w-full py-2 flex gap-3" :href href
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
(div :class "flex-1 flex justify-between gap-3"
(div
(p :class "font-medium" title)
(p :class "text-[11px] text-stone-500" "Product ID: " pid))
(div :class "text-right whitespace-nowrap"
(p "Qty: " qty)
(p price))))))
(defcomp ~orders-items-section (&key items)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
(defcomp ~orders-calendar-item (&key name pill state ds cost)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div
(div :class "font-medium flex items-center gap-2"
name
(span :class pill state))
(div :class "text-xs text-stone-500" ds))
(div :class "ml-4 font-medium" cost)))
(defcomp ~orders-calendar-section (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~orders-detail-panel (&key summary items calendar)
(div :class "max-w-full px-3 py-3 space-y-4"
summary items calendar))
(defcomp ~orders-detail-header-stack (&key auth orders order)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
orders
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
order))))
(defcomp ~orders-header-child-oob (&key inner)
(div :id "orders-header-child" :sx-swap-oob "outerHTML"
:class "flex flex-col w-full items-center"
inner))

View File

@@ -1,60 +0,0 @@
;; Orders list components
(defcomp ~orders-row-desktop (&key oid created desc total pill status url)
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" created)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" total)
(td :class "px-3 py-2 align-top" (span :class pill status))
(td :class "px-3 py-0.5 align-top text-right"
(a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
(defcomp ~orders-row-mobile (&key oid created total pill status url)
(tr :class "sm:hidden border-t border-stone-100"
(td :colspan "5" :class "px-3 py-3"
(div :class "flex flex-col gap-2 text-xs"
(div :class "flex items-center justify-between gap-2"
(span :class "font-mono text-[11px] text-stone-700" oid)
(span :class pill status))
(div :class "text-[11px] text-stone-500 break-words" created)
(div :class "flex items-center justify-between gap-2"
(div :class "font-medium text-stone-800" total)
(a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
(defcomp ~orders-end-row ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results")))
(defcomp ~orders-empty-state ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~orders-table (&key rows)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm"
(thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"
(tr
(th :class "px-3 py-2 text-left font-medium" "Order")
(th :class "px-3 py-2 text-left font-medium" "Created")
(th :class "px-3 py-2 text-left font-medium" "Description")
(th :class "px-3 py-2 text-left font-medium" "Total")
(th :class "px-3 py-2 text-left font-medium" "Status")
(th :class "px-3 py-2 text-left font-medium" "")))
(tbody rows)))))
(defcomp ~orders-summary (&key search-mobile)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1" (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
(div :class "md:hidden" search-mobile)))
;; Header child wrapper
(defcomp ~orders-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
inner))
(defcomp ~orders-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :sx-swap-oob "outerHTML"
:class "flex flex-col w-full items-center"
inner))

View File

@@ -103,12 +103,12 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
parts = []
for o in orders:
d = _order_row_data(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
parts.append(sx_call("orders-row-desktop",
parts.append(sx_call("order-row-desktop",
oid=d["oid"], created=d["created"],
desc=d["desc"], total=d["total"],
pill=d["pill_desktop"], status=d["status"],
url=d["url"]))
parts.append(sx_call("orders-row-mobile",
parts.append(sx_call("order-row-mobile",
oid=d["oid"], created=d["created"],
total=d["total"], pill=d["pill_mobile"],
status=d["status"], url=d["url"]))
@@ -120,7 +120,7 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
total_pages=total_pages,
id_prefix="orders", colspan=5))
else:
parts.append(sx_call("orders-end-row"))
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
@@ -128,13 +128,13 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel with table or empty state (sx)."""
if not orders:
return sx_call("orders-empty-state")
return sx_call("orders-table", rows=SxExpr(rows_sx))
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 (sx)."""
return sx_call("orders-summary", search_mobile=SxExpr(search_mobile_sx(ctx)))
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
@@ -189,8 +189,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
main = _orders_main_panel_sx(orders, rows)
auth_hdr = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call("orders-auth-header-child-oob",
inner=SxExpr(_orders_header_sx(ctx, list_url)))
auth_child_oob = sx_call("oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)))
root_hdr = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")"
@@ -213,23 +214,23 @@ def _order_items_sx(order: Any) -> str:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"orders-item-image",
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("orders-item-no-image")
img = sx_call("order-item-no-image")
items.append(sx_call(
"orders-item-row",
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=str(item.product_id),
qty=str(item.quantity),
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(items) + ")"
return sx_call("orders-items-section", items=SxExpr(items_sx))
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _calendar_items_sx(calendar_entries: list | None) -> str:
@@ -249,15 +250,15 @@ def _calendar_items_sx(calendar_entries: list | None) -> str:
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items.append(sx_call(
"orders-calendar-item",
"order-calendar-entry",
name=e.name,
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
state=st.capitalize(), ds=ds,
status=st.capitalize(), date_str=ds,
cost=f"\u00a3{e.cost or 0:.2f}",
))
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("orders-calendar-section", items=SxExpr(items_sx))
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
@@ -272,7 +273,7 @@ def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
items = _order_items_sx(order)
calendar = _calendar_items_sx(calendar_entries)
return sx_call(
"orders-detail-panel",
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(calendar) if calendar else None,
@@ -287,11 +288,11 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
pay = ""
if status != "paid":
pay = sx_call("orders-checkout-error-pay-btn", url=pay_url)
pay = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"orders-detail-filter",
created=created, status=status,
"order-detail-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url,
csrf=csrf_token,
pay=SxExpr(pay) if pay else None,
@@ -322,7 +323,7 @@ async def render_order_page(ctx: dict, order: Any,
link_label="Order", icon="fa fa-gbp",
)
detail_header = sx_call(
"orders-detail-header-stack",
"order-detail-header-stack",
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
order=SxExpr(order_row),
@@ -353,8 +354,9 @@ async def render_order_oob(ctx: dict, order: Any,
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp", oob=True,
)
header_child_oob = sx_call("orders-header-child-oob",
inner=SxExpr(order_row_oob))
header_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_hdr = root_header_sx(ctx, oob=True)
oobs = "(<> " + header_child_oob + " " + root_hdr + ")"
@@ -366,17 +368,17 @@ async def render_order_oob(ctx: dict, order: Any,
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("orders-checkout-error-header")
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 = ""
if order:
order_sx = sx_call("orders-checkout-error-order-id", oid=f"#{order.id}")
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
"orders-checkout-error-content",
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,

View File

@@ -223,16 +223,21 @@ def errors(app):
errnum='403'
)
else:
try:
html = _sx_error_page(
"403", "FORBIDDEN",
image="/static/errors/403.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='403',
)
html = await _rich_error_page(
"403", "FORBIDDEN",
image="/static/errors/403.gif",
)
if html is None:
try:
html = _sx_error_page(
"403", "FORBIDDEN",
image="/static/errors/403.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='403',
)
return await make_response(html, 403)
@@ -291,7 +296,11 @@ def errors(app):
status,
)
# Raw HTML — avoids context processor / fragment loop on errors.
return await make_response(_error_page(
"WELL THIS IS EMBARRASSING&hellip;"
), status)
errnum = str(status)
html = await _rich_error_page(
errnum, "WELL THIS IS EMBARRASSING\u2026",
image="/static/errors/error.gif",
)
if html is None:
html = _error_page("WELL THIS IS EMBARRASSING&hellip;")
return await make_response(html, status)

View File

@@ -1,15 +1,8 @@
<style>
@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }
</style>
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/basics.css')}}">
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/cards.css')}}">
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
<meta name="csrf-token" content="{{ csrf_token() }}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet" />
<style id="sx-css">{{ sx_css_all() }}</style>
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>

View File

@@ -10,7 +10,6 @@ from .dtos import (
CartSummaryDTO,
)
from .protocols import (
BlogService,
CalendarService,
MarketService,
CartService,
@@ -24,7 +23,6 @@ __all__ = [
"ProductDTO",
"CartItemDTO",
"CartSummaryDTO",
"BlogService",
"CalendarService",
"MarketService",
"CartService",

View File

@@ -11,7 +11,6 @@ from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
from .dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
TicketDTO,
@@ -29,17 +28,6 @@ from .dtos import (
)
@runtime_checkable
class BlogService(Protocol):
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: ...
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: ...
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: ...
async def search_posts(
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
) -> tuple[list[PostDTO], int]: ...
@runtime_checkable
class CalendarService(Protocol):
async def calendars_for_container(

View File

@@ -77,6 +77,8 @@ def create_base_app(
template_folder=TEMPLATE_DIR,
root_path=str(BASE_DIR),
)
# Disable aggressive browser caching of static files in dev
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
configure_logging(name)
@@ -115,6 +117,24 @@ def create_base_app(
load_shared_components()
load_relation_registry()
# Load CSS registry (tw.css → class-to-rule lookup for on-demand CSS)
from shared.sx.css_registry import load_css_registry, registry_loaded
_styles = BASE_DIR / "static" / "styles"
_fa_css = BASE_DIR / "static" / "fontawesome" / "css"
if (_styles / "tw.css").exists() and not registry_loaded():
load_css_registry(
_styles / "tw.css",
extra_css=[
_styles / "basics.css",
_styles / "cards.css",
_styles / "blog-content.css",
_styles / "prism.css",
_fa_css / "all.min.css",
_fa_css / "v4-shims.min.css",
],
url_rewrites={"../webfonts/": "/static/fontawesome/webfonts/"},
)
# Dev-mode: auto-reload sx templates when files change on disk
if os.getenv("RELOAD") == "true":
from shared.sx.jinja_bridge import reload_if_changed
@@ -296,7 +316,7 @@ def create_base_app(
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = (
"SX-Request, SX-Target, SX-Current-URL, SX-Components, "
"SX-Request, SX-Target, SX-Current-URL, SX-Components, SX-Css, "
"HX-Request, HX-Target, HX-Current-URL, HX-Trigger, "
"Content-Type, X-CSRFToken"
)

View File

@@ -10,7 +10,6 @@ from .ghost_membership_entities import (
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag
from .page_config import PageConfig
from .order import Order, OrderItem
from .market import (

View File

@@ -23,6 +23,17 @@ class User(Base):
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
ghost_raw: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Author profile fields (merged from Ghost Author)
slug: Mapped[str | None] = mapped_column(String(191), unique=True, index=True, nullable=True)
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
profile_image: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_image: Mapped[str | None] = mapped_column(Text, nullable=True)
website: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(Text, nullable=True)
facebook: Mapped[str | None] = mapped_column(Text, nullable=True)
twitter: Mapped[str | None] = mapped_column(Text, nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=func.false())
# Relationships to Ghost-related entities
user_newsletters = relationship("UserNewsletter", back_populates="user", cascade="all, delete-orphan", lazy="selectin")

View File

@@ -1,65 +0,0 @@
"""SQL-backed BlogService implementation.
Queries ``shared.models.ghost_content.Post`` — only this module may read
blog-domain tables on behalf of other domains.
"""
from __future__ import annotations
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.ghost_content import Post
from shared.contracts.dtos import PostDTO
def _post_to_dto(post: Post) -> PostDTO:
return PostDTO(
id=post.id,
slug=post.slug,
title=post.title,
status=post.status,
visibility=post.visibility,
is_page=post.is_page,
feature_image=post.feature_image,
html=post.html,
excerpt=post.excerpt,
custom_excerpt=post.custom_excerpt,
published_at=post.published_at,
)
class SqlBlogService:
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.slug == slug))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.id == id))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
if not ids:
return []
result = await session.execute(select(Post).where(Post.id.in_(ids)))
return [_post_to_dto(p) for p in result.scalars().all()]
async def search_posts(
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
) -> tuple[list[PostDTO], int]:
"""Search posts by title with pagination. Not part of the Protocol
(admin-only use in events), but provided for convenience."""
if query:
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
total = (await session.execute(count_stmt)).scalar() or 0
offset = (page - 1) * per_page
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
return [_post_to_dto(p) for p in result.scalars().all()], total

View File

@@ -4,16 +4,12 @@ Only uses HTTP-based fetch_data/call_action, no direct DB access.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
from shared.services.registry import services
async def toggle_entry_association(
session: AsyncSession,
post_id: int,
entry_id: int
) -> tuple[bool, str | None]:
@@ -21,7 +17,7 @@ async def toggle_entry_association(
Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message).
"""
post = await services.blog.get_post_by_id(session, post_id)
post = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False)
if not post:
return False, "Post not found"
@@ -35,7 +31,6 @@ async def toggle_entry_association(
async def get_post_entry_ids(
session: AsyncSession,
post_id: int
) -> set[int]:
"""
@@ -49,7 +44,6 @@ async def get_post_entry_ids(
async def get_associated_entries(
session: AsyncSession,
post_id: int,
page: int = 1,
per_page: int = 10

View File

@@ -8,15 +8,14 @@ Usage::
from shared.services.registry import services
# Register at app startup (own domain only)
services.blog = SqlBlogService()
services.calendar = SqlCalendarService()
# Use locally within the owning app
post = await services.blog.get_post_by_slug(session, slug)
cals = await services.calendar.calendars_for_container(session, "page", page_id)
"""
from __future__ import annotations
from shared.contracts.protocols import (
BlogService,
CalendarService,
MarketService,
CartService,
@@ -33,23 +32,11 @@ class _ServiceRegistry:
"""
def __init__(self) -> None:
self._blog: BlogService | None = None
self._calendar: CalendarService | None = None
self._market: MarketService | None = None
self._cart: CartService | None = None
self._federation: FederationService | None = None
# -- blog -----------------------------------------------------------------
@property
def blog(self) -> BlogService:
if self._blog is None:
raise RuntimeError("BlogService not registered")
return self._blog
@blog.setter
def blog(self, impl: BlogService) -> None:
self._blog = impl
# -- calendar -------------------------------------------------------------
@property
def calendar(self) -> CalendarService:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
/**
* sx.js — S-expression parser, evaluator, and DOM renderer.
* sx.js — S-expression parser, evaluator, and DOM renderer. [v2-debug]
*
* Client-side counterpart to shared/sx/ Python modules.
* Parses s-expression text, evaluates it, and renders to DOM nodes.
@@ -122,7 +122,7 @@
this._advance(m[0].length);
var raw = m[0].slice(1, -1);
return raw.replace(/\\n/g, "\n").replace(/\\t/g, "\t")
.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
.replace(/\\"/g, '"').replace(/\\[/]/g, "/").replace(/\\\\/g, "\\");
}
// Keyword
@@ -691,6 +691,9 @@
// Keyword → text
if (isKw(expr)) return document.createTextNode(expr.name);
// Pre-rendered DOM node → return as-is
if (expr && expr.nodeType) return expr;
// Dict → empty
if (expr && typeof expr === "object" && !Array.isArray(expr)) return document.createDocumentFragment();
@@ -808,18 +811,40 @@
return renderDOM(fn.body, local);
}
/** True when the array expr is a render-only form (HTML tag, <>, raw!, ~comp). */
function _isRenderExpr(v) {
if (!Array.isArray(v) || !v.length) return false;
var h = v[0];
if (!isSym(h)) return false;
var n = h.name;
return !!(HTML_TAGS[n] || SVG_TAGS[n] || n === "<>" || n === "raw!" || n.charAt(0) === "~");
}
function renderComponentDOM(comp, args, env) {
var kwargs = {}, children = [];
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
// Keep kwarg values as AST — renderDOM will handle them when the
// component body references the param symbol. Simple literals are
// eval'd so strings/numbers resolve immediately.
// Evaluate kwarg values eagerly in the caller's env so expressions
// like (get t "src") resolve while lambda params are still bound.
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v))
? v : (isSym(v) ? sxEval(v, env) : v);
if (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v)) {
kwargs[args[i].name] = v;
} else if (isSym(v)) {
kwargs[args[i].name] = sxEval(v, env);
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
// Expression with Symbol head — evaluate in caller's env.
// Render-only forms go through renderDOM; data exprs through sxEval.
if (_isRenderExpr(v)) {
kwargs[args[i].name] = renderDOM(v, env);
} else {
kwargs[args[i].name] = sxEval(v, env);
}
} else {
// Data arrays, dicts, etc — pass through as-is
kwargs[args[i].name] = v;
}
i += 2;
} else {
children.push(args[i]);
@@ -854,6 +879,16 @@
if (typeof val === "string") {
var tpl = document.createElement("template");
tpl.innerHTML = val;
// Scripts in innerHTML don't execute — recreate them as live elements
var deadScripts = tpl.content.querySelectorAll("script");
for (var si = 0; si < deadScripts.length; si++) {
var dead = deadScripts[si];
var live = document.createElement("script");
for (var ai = 0; ai < dead.attributes.length; ai++)
live.setAttribute(dead.attributes[ai].name, dead.attributes[ai].value);
live.textContent = dead.textContent;
dead.parentNode.replaceChild(live, dead);
}
frag.appendChild(tpl.content);
} else if (val && val.nodeType) {
// Already a DOM node (e.g. from children fragment)
@@ -1080,8 +1115,14 @@
}
var open = "<" + tag + attrs.join("") + ">";
if (VOID_ELEMENTS[tag]) return open;
var isRawText = (tag === "script" || tag === "style");
var inner = [];
for (var ci = 0; ci < children.length; ci++) inner.push(renderStr(children[ci], env));
for (var ci = 0; ci < children.length; ci++) {
var child = children[ci];
if (isRawText && typeof child === "string") inner.push(child);
else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env)));
else inner.push(renderStr(child, env));
}
return open + inner.join("") + "</" + tag + ">";
}
@@ -1096,10 +1137,26 @@
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
// Evaluate kwarg values eagerly in the caller's env so expressions
// like (get t "src") resolve while lambda params are still bound.
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v))
? v : (isSym(v) ? sxEval(v, env) : v);
if (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v)) {
kwargs[args[i].name] = v;
} else if (isSym(v)) {
kwargs[args[i].name] = sxEval(v, env);
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
// Expression with Symbol head — evaluate in caller's env.
// Render-only forms go through renderStr; data exprs through sxEval.
if (_isRenderExpr(v)) {
kwargs[args[i].name] = new RawHTML(renderStr(v, env));
} else {
kwargs[args[i].name] = sxEval(v, env);
}
} else {
// Data arrays, dicts, etc — pass through as-is
kwargs[args[i].name] = v;
}
i += 2;
} else { children.push(args[i]); i++; }
}
@@ -1236,8 +1293,13 @@
// Component management
loadComponents: function (text) {
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
try {
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
} catch (err) {
console.error("sx.js loadComponents error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
throw err;
}
},
getEnv: function () { return _componentEnv; },
@@ -1362,7 +1424,7 @@
var PROCESSED = "_sxBound";
var VERBS = ["get", "post", "put", "delete", "patch"];
var DEFAULT_SWAP = "outerHTML";
var HISTORY_MAX = 20;
function dispatch(el, name, detail) {
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
@@ -1458,6 +1520,10 @@
});
if (loadedNames.length) headers["SX-Components"] = loadedNames.join(",");
// Send known CSS classes so server only sends new rules
var cssHeader = _getSxCssHeader();
if (cssHeader) headers["SX-Css"] = cssHeader;
// Extra headers from sx-headers
var extraH = el.getAttribute("sx-headers");
if (extraH) {
@@ -1573,82 +1639,139 @@
return resp.text().then(function (text) {
dispatch(el, "sx:afterRequest", { response: resp });
// Check for text/sx content type
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sx") >= 0) {
try { text = Sx.renderToString(text); }
catch (err) {
console.error("sx.js render error:", err);
return;
}
}
// Process the response
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
// sx-select: extract subset from response
var selectSel = el.getAttribute("sx-select");
// Parse response into DOM for OOB + select processing
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Check for text/sx content type — use direct DOM rendering path
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sx") >= 0) {
try {
// Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS: extract <style data-sx-css> and inject into head
text = _processCssResponse(text, resp);
var sxSource = text.trim();
// Process any sx script blocks in the response (e.g. cross-domain component defs)
Sx.processScripts(doc);
// Parse and render to live DOM nodes (skip renderToString + DOMParser)
if (sxSource && sxSource.charAt(0) !== "(") {
console.error("sx.js: sxSource does not start with '(' — first 200 chars:", sxSource.substring(0, 200));
}
var sxDom = Sx.render(sxSource);
// OOB processing: extract elements with sx-swap-oob
var oobs = doc.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
// Wrap in container for querySelectorAll (DocumentFragment doesn't support it)
var container = document.createElement("div");
container.appendChild(sxDom);
// OOB processing on live DOM nodes
var oobs = container.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// hx-swap-oob compat
var hxOobs = container.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// sx-select filtering
var selectedDOM;
if (selectSel) {
selectedDOM = document.createDocumentFragment();
selectSel.split(",").forEach(function (sel) {
container.querySelectorAll(sel.trim()).forEach(function (m) {
selectedDOM.appendChild(m);
});
});
} else {
// Use all remaining children
selectedDOM = document.createDocumentFragment();
while (container.firstChild) selectedDOM.appendChild(container.firstChild);
}
// Main swap using DOM morph
if (swapStyle !== "none" && target) {
_swapDOM(target, selectedDOM, swapStyle);
_hoistHeadElements(target);
}
} catch (err) {
console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)");
return;
}
oob.parentNode.removeChild(oob);
});
// Also support hx-swap-oob during migration
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Build final content
var content;
if (selectSel) {
// sx-select may be comma-separated
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
var frags = [];
parts.forEach(function (sel) {
var matches = doc.querySelectorAll(sel);
matches.forEach(function (m) { frags.push(m.outerHTML); });
});
content = frags.join("");
} else {
content = doc.body ? doc.body.innerHTML : text;
}
// HTML string path — existing DOMParser pipeline
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Main swap
if (swapStyle !== "none" && target) {
_swapContent(target, content, swapStyle);
// Auto-hoist any head elements that ended up in body
_hoistHeadElements(target);
// Process any sx script blocks in the response
Sx.processScripts(doc);
// OOB processing
var oobs = doc.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Build final content
var content;
if (selectSel) {
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
var frags = [];
parts.forEach(function (sel) {
var matches = doc.querySelectorAll(sel);
matches.forEach(function (m) { frags.push(m.outerHTML); });
});
content = frags.join("");
} else {
content = doc.body ? doc.body.innerHTML : text;
}
// Main swap
if (swapStyle !== "none" && target) {
_swapContent(target, content, swapStyle);
_hoistHeadElements(target);
}
}
// History
var pushUrl = el.getAttribute("sx-push-url");
if (pushUrl === "true") {
history.pushState({ sxUrl: url }, "", url);
} else if (pushUrl && pushUrl !== "false") {
history.pushState({ sxUrl: pushUrl }, "", pushUrl);
if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
var pushTarget = pushUrl === "true" ? url : pushUrl;
try {
history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget);
} catch (e) {
// Cross-origin pushState not allowed — full navigation
location.assign(pushTarget);
return;
}
}
dispatch(el, "sx:afterSwap", { target: target });
@@ -1666,7 +1789,194 @@
});
}
// ---- Swap engine ------------------------------------------------------
// ---- DOM morphing ------------------------------------------------------
/**
* Lightweight DOM reconciler — patches oldNode to match newNode in-place,
* preserving event listeners, focus, scroll position, and form state on
* keyed (id) elements.
*/
function _morphDOM(oldNode, newNode) {
// Different node types or tag names → replace wholesale
if (oldNode.nodeType !== newNode.nodeType ||
oldNode.nodeName !== newNode.nodeName) {
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
return;
}
// Text/comment nodes → update content
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
if (oldNode.nodeValue !== newNode.nodeValue)
oldNode.nodeValue = newNode.nodeValue;
return;
}
// Element nodes → sync attributes, then recurse children
if (oldNode.nodeType === 1) {
// Skip morphing focused input to preserve user's in-progress edits
if (oldNode === document.activeElement &&
(oldNode.tagName === "INPUT" || oldNode.tagName === "TEXTAREA" || oldNode.tagName === "SELECT")) {
_syncAttrs(oldNode, newNode); // sync non-value attrs (class, style, etc.)
return; // don't touch value or children
}
_syncAttrs(oldNode, newNode);
_morphChildren(oldNode, newNode);
}
}
function _syncAttrs(old, neu) {
// Add/update attributes from new
var newAttrs = neu.attributes;
for (var i = 0; i < newAttrs.length; i++) {
var a = newAttrs[i];
if (old.getAttribute(a.name) !== a.value)
old.setAttribute(a.name, a.value);
}
// Remove attributes not in new
var oldAttrs = old.attributes;
for (var j = oldAttrs.length - 1; j >= 0; j--) {
if (!neu.hasAttribute(oldAttrs[j].name))
old.removeAttribute(oldAttrs[j].name);
}
}
function _morphChildren(oldParent, newParent) {
var oldChildren = Array.prototype.slice.call(oldParent.childNodes);
var newChildren = Array.prototype.slice.call(newParent.childNodes);
// Build ID map of old children for keyed matching
var oldById = {};
for (var k = 0; k < oldChildren.length; k++) {
var kid = oldChildren[k];
if (kid.id) oldById[kid.id] = kid;
}
var oi = 0;
for (var ni = 0; ni < newChildren.length; ni++) {
var newChild = newChildren[ni];
var matchById = newChild.id ? oldById[newChild.id] : null;
if (matchById) {
// Keyed match — move into position if needed, then morph
if (matchById !== oldChildren[oi]) {
oldParent.insertBefore(matchById, oldChildren[oi] || null);
}
_morphDOM(matchById, newChild);
oi++;
} else if (oi < oldChildren.length) {
// Positional match — morph in place
var oldChild = oldChildren[oi];
if (oldChild.id && !newChild.id) {
// Old has ID, new doesn't — insert new before old (don't clobber keyed)
oldParent.insertBefore(newChild.cloneNode(true), oldChild);
} else {
_morphDOM(oldChild, newChild);
oi++;
}
} else {
// Extra new children — append
oldParent.appendChild(newChild.cloneNode(true));
}
}
// Remove leftover old children
while (oi < oldChildren.length) {
var leftover = oldChildren[oi];
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
oi++;
}
}
// ---- DOM-native swap engine --------------------------------------------
/**
* Swap using live DOM nodes (from Sx.render) instead of HTML strings.
* Uses _morphDOM for innerHTML/outerHTML to preserve state.
*/
function _swapDOM(target, newNodes, strategy) {
// newNodes is a DocumentFragment, Element, or Text node
var wrapper;
switch (strategy) {
case "innerHTML":
// Morph children of target to match newNodes
if (newNodes.nodeType === 11) {
// DocumentFragment — morph its children into target
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
break;
case "outerHTML":
var parent = target.parentNode;
if (newNodes.nodeType === 11) {
// Fragment — morph first child, insert rest
var first = newNodes.firstChild;
if (first) {
_morphDOM(target, first);
var sib = first.nextSibling; // skip first (used as morph template, not consumed)
while (sib) {
var next = sib.nextSibling;
parent.insertBefore(sib, target.nextSibling);
sib = next;
}
} else {
parent.removeChild(target);
}
} else {
_morphDOM(target, newNodes);
}
_activateScripts(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return like existing outerHTML
case "afterend":
target.parentNode.insertBefore(newNodes, target.nextSibling);
break;
case "beforeend":
target.appendChild(newNodes);
break;
case "afterbegin":
target.insertBefore(newNodes, target.firstChild);
break;
case "beforebegin":
target.parentNode.insertBefore(newNodes, target);
break;
case "delete":
target.parentNode.removeChild(target);
return;
default: // fallback = innerHTML
if (newNodes.nodeType === 11) {
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
}
_activateScripts(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
}
// ---- Swap engine (string-based, kept as fallback) ----------------------
/** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
* Recreate them as live elements so the browser fetches & runs them. */
function _activateScripts(root) {
var dead = root.querySelectorAll("script:not([type]), script[type='text/javascript']");
for (var i = 0; i < dead.length; i++) {
var d = dead[i];
var live = document.createElement("script");
for (var a = 0; a < d.attributes.length; a++)
live.setAttribute(d.attributes[a].name, d.attributes[a].value);
live.textContent = d.textContent;
d.parentNode.replaceChild(live, d);
}
}
function _swapContent(target, html, strategy) {
switch (strategy) {
@@ -1679,6 +1989,7 @@
tgt.insertAdjacentHTML("afterend", html);
parent.removeChild(tgt);
// Process parent to catch all newly inserted siblings
_activateScripts(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
@@ -1701,6 +2012,7 @@
default:
target.innerHTML = html;
}
_activateScripts(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
@@ -1838,42 +2150,18 @@
// ---- History manager --------------------------------------------------
var _historyCache = {};
var _historyCacheKeys = [];
function _cacheCurrentPage() {
var key = location.href;
var main = document.getElementById("main-panel");
if (!main) return;
_historyCache[key] = main.innerHTML;
// LRU eviction
var idx = _historyCacheKeys.indexOf(key);
if (idx >= 0) _historyCacheKeys.splice(idx, 1);
_historyCacheKeys.push(key);
while (_historyCacheKeys.length > HISTORY_MAX) {
delete _historyCache[_historyCacheKeys.shift()];
}
}
if (typeof window !== "undefined") {
window.addEventListener("popstate", function (e) {
var url = location.href;
// Try cache first
if (_historyCache[url]) {
var main = document.getElementById("main-panel");
if (main) {
main.innerHTML = _historyCache[url];
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
return;
}
}
// Fetch fresh
var histOpts = {
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
};
var main = document.getElementById("main-panel");
if (!main) { location.reload(); return; }
var histHeaders = { "SX-Request": "true", "SX-History-Restore": "true" };
var cssH = _getSxCssHeader();
if (cssH) histHeaders["SX-Css"] = cssH;
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
if (loadedN.length) histHeaders["SX-Components"] = loadedN.join(",");
var histOpts = { headers: histHeaders };
try {
var hHost = new URL(url, location.href).hostname;
if (hHost !== location.hostname &&
@@ -1881,24 +2169,103 @@
histOpts.credentials = "include";
}
} catch (e) {}
fetch(url, histOpts).then(function (resp) {
return resp.text();
}).then(function (text) {
var ct = "";
// Response content-type is lost here, check for sx
return resp.text().then(function (t) { return { text: t, resp: resp }; });
}).then(function (r) {
var text = r.text;
var resp = r.resp;
// Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS
text = _processCssResponse(text, resp);
text = text.trim();
if (text.charAt(0) === "(") {
try { text = Sx.renderToString(text); } catch (e) { /* not sx */ }
}
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
var main = document.getElementById("main-panel");
if (main && newMain) {
main.innerHTML = newMain.innerHTML;
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
// sx response — render to live DOM, morph into main
try {
var popDom = Sx.render(text);
var popContainer = document.createElement("div");
popContainer.appendChild(popDom);
// Process OOB swaps (sidebar, filter, menu, headers)
var oobs = popContainer.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
oob.parentNode.removeChild(oob);
if (oobTarget) {
_swapDOM(oobTarget, oob, oobSwap);
Sx.hydrate(oobTarget);
SxEngine.process(oobTarget);
}
});
var hxOobs = popContainer.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
oob.parentNode.removeChild(oob);
if (oobTarget) {
_swapDOM(oobTarget, oob, oobSwap);
Sx.hydrate(oobTarget);
SxEngine.process(oobTarget);
}
});
var newMain = popContainer.querySelector("#main-panel");
_morphChildren(main, newMain || popContainer);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} catch (err) {
console.error("sx.js popstate render error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
location.reload();
}
} else {
// HTML response — parse and morph
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Process OOB swaps from HTML response
var hOobs = doc.querySelectorAll("[sx-swap-oob]");
hOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
var hhOobs = doc.querySelectorAll("[hx-swap-oob]");
hhOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
var newMain = doc.getElementById("main-panel");
if (newMain) {
_morphChildren(main, newMain);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} else {
location.reload();
}
}
}).catch(function () {
location.reload();
@@ -1975,11 +2342,64 @@
// Auto-init in browser
// =========================================================================
Sx.VERSION = "2026-03-01a";
Sx.VERSION = "2026-03-01c-cssx";
// CSS class tracking for on-demand CSS delivery
var _sxCssKnown = {};
var _sxCssHash = ""; // 8-char hex hash from server
function _initCssTracking() {
var meta = document.querySelector('meta[name="sx-css-classes"]');
if (meta) {
var content = meta.getAttribute("content");
if (content) {
// If content is short (≤16 chars), it's a hash from the server
if (content.length <= 16) {
_sxCssHash = content;
} else {
content.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
}
}
}
function _getSxCssHeader() {
// Prefer sending the hash (compact) over the full class list
if (_sxCssHash) return _sxCssHash;
var names = Object.keys(_sxCssKnown);
return names.length ? names.join(",") : "";
}
function _processCssResponse(text, resp) {
// Read SX-Css-Hash response header — replaces local hash
var hashHeader = resp.headers.get("SX-Css-Hash");
if (hashHeader) _sxCssHash = hashHeader;
// Merge SX-Css-Add header into known set (kept for debugging/fallback)
var addHeader = resp.headers.get("SX-Css-Add");
if (addHeader) {
addHeader.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
// Extract <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css");
if (cssTarget) {
text = text.replace(/<style[^>]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi,
function (_, css) {
cssTarget.textContent += css;
return "";
});
}
return text;
}
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");
_initCssTracking();
Sx.processScripts();
Sx.hydrate();
SxEngine.process();
@@ -1990,10 +2410,7 @@
init();
}
// Cache current page before navigation
document.addEventListener("sx:beforeRequest", function () {
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
});
}

View File

@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
safelist: [
// ~menu-row-sx builds bg-{colour}-{shade} dynamically via (str ...)
// Levels 14 produce shades 400100 (level 5+ yields 0 or negative = no match)
{ pattern: /^bg-sky-(100|200|300|400)$/ },
],
content: [
'/root/rose-ash/shared/sx/templates/**/*.sx',
'/root/rose-ash/shared/browser/templates/**/*.html',
'/root/rose-ash/blog/sx/**/*.sx',
'/root/rose-ash/blog/browser/templates/**/*.html',
'/root/rose-ash/market/sx/**/*.sx',
'/root/rose-ash/market/browser/templates/**/*.html',
'/root/rose-ash/cart/sx/**/*.sx',
'/root/rose-ash/cart/browser/templates/**/*.html',
'/root/rose-ash/events/sx/**/*.sx',
'/root/rose-ash/events/browser/templates/**/*.html',
'/root/rose-ash/federation/sx/**/*.sx',
'/root/rose-ash/federation/browser/templates/**/*.html',
'/root/rose-ash/account/sx/**/*.sx',
'/root/rose-ash/account/browser/templates/**/*.html',
'/root/rose-ash/orders/sx/**/*.sx',
'/root/rose-ash/orders/browser/templates/**/*.html',
'/root/rose-ash/shared/sx/helpers.py',
'/root/rose-ash/blog/sx/sx_components.py',
'/root/rose-ash/market/sx/sx_components.py',
'/root/rose-ash/cart/sx/sx_components.py',
'/root/rose-ash/events/sx/sx_components.py',
'/root/rose-ash/federation/sx/sx_components.py',
'/root/rose-ash/account/sx/sx_components.py',
'/root/rose-ash/orders/sx/sx_components.py',
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

View File

@@ -0,0 +1,30 @@
@import "tailwindcss" source(none);
/* Scan all template and component files */
@source "../../../shared/sx/templates";
@source "../../../shared/browser/templates";
@source "../../../shared/static/scripts";
@source "../../../blog/sx";
@source "../../../blog/templates";
@source "../../../market/sx";
@source "../../../market/templates";
@source "../../../cart/sx";
@source "../../../cart/templates";
@source "../../../events/sx";
@source "../../../events/templates";
@source "../../../federation/sx";
@source "../../../federation/templates";
@source "../../../account/sx";
@source "../../../account/templates";
@source "../../../orders/sx";
@source "../../../orders/templates";
/* Dynamic classes from ~menu-row-sx: bg-{colour}-{shade}
colour defaults to "sky", shade = 500 - (level * 100) for levels 0-4 */
@source inline("bg-sky-{100,200,300,400,500}");
@source inline("bg-stone-{100,200,300,400,500}");
@source inline("bg-emerald-{100,200,300,400,500}");
@source inline("bg-amber-{100,200,300,400,500}");
/* Dynamic text size in ~status-pill: text-{sz} */
@source inline("text-{xs,sm,base,lg}");

File diff suppressed because one or more lines are too long

327
shared/sx/css_registry.py Normal file
View File

@@ -0,0 +1,327 @@
"""
On-demand CSS registry — parses tw.css at startup into a lookup table.
Maps HTML class names (e.g. "flex", "sm:hidden", "h-[60vh]") to their
CSS rule text. The server uses this to send only the CSS rules needed
for each response, instead of the full Tailwind bundle.
Usage::
load_css_registry("/path/to/tw.css")
rules = lookup_rules({"flex", "p-2", "sm:hidden"})
preamble = get_preamble()
"""
from __future__ import annotations
import hashlib
import re
from collections import OrderedDict
from pathlib import Path
from typing import Sequence
# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, str] = {} # class name → CSS rule text
_RULE_ORDER: dict[str, int] = {} # class name → source order index
_PREAMBLE: str = "" # base/reset CSS (sent once per page)
_ALL_RULES: str = "" # full concatenated rules (for Jinja fallback)
# Hash cache: maps 8-char hex hash → frozenset of class names
_CSS_HASH_CACHE: OrderedDict[str, frozenset[str]] = OrderedDict()
_CSS_HASH_CACHE_MAX = 1000
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_css_registry(
path: str | Path,
*,
extra_css: Sequence[str | Path] = (),
url_rewrites: dict[str, str] | None = None,
) -> None:
"""Parse a Tailwind v3 CSS file and populate the registry.
Parameters
----------
path:
Path to the main Tailwind CSS file (tw.css).
extra_css:
Additional CSS files to include in the preamble (inlined verbatim).
These are loaded in order and prepended before the Tailwind rules.
url_rewrites:
Dict of ``{old_prefix: new_prefix}`` applied to all extra CSS files.
e.g. ``{"../webfonts/": "/static/fontawesome/webfonts/"}``
"""
global _PREAMBLE, _ALL_RULES
_REGISTRY.clear()
_RULE_ORDER.clear()
css_path = Path(path)
css = css_path.read_text(encoding="utf-8")
rewrites = url_rewrites or {}
# Load extra CSS files into a combined prefix
sibling_css = ""
for extra in extra_css:
p = Path(extra)
if p.exists():
content = p.read_text(encoding="utf-8")
for old, new in rewrites.items():
content = content.replace(old, new)
sibling_css += content
# Split into preamble (resets, vars) and utility rules.
# Tailwind v3 minified structure:
# - Custom property defaults (*,:after,:before{--tw-...})
# - Base resets (html, body, etc.)
# - Utility classes (.flex{...}, .hidden{...})
# - Responsive variants (@media ...{.sm\:...{...}})
#
# We treat everything before the first utility class selector as preamble.
# A utility class selector starts with "." followed by a word char or backslash.
preamble_parts: list[str] = []
utility_css = css
# Find first bare utility class (not inside a reset block)
# Heuristic: the preamble ends at the first rule whose selector is ONLY
# a class (starts with . and no *, :, html, body, etc.)
#
# More robust: walk rules and detect when selectors become single-class.
rules = _split_rules(css)
preamble_end = 0
for i, rule in enumerate(rules):
sel = _extract_selector(rule)
if sel and _is_utility_selector(sel):
preamble_end = i
break
_PREAMBLE = sibling_css + "".join(rules[:preamble_end])
_index_rules(rules[preamble_end:])
_ALL_RULES = _PREAMBLE + "".join(
_REGISTRY[k] for k in sorted(_REGISTRY, key=lambda k: _RULE_ORDER.get(k, 0))
)
def get_preamble() -> str:
"""Return the preamble CSS (resets + custom property defaults)."""
return _PREAMBLE
def get_all_css() -> str:
"""Return preamble + all utility rules (for Jinja fallback pages)."""
return _ALL_RULES
def lookup_rules(classes: set[str]) -> str:
"""Return concatenated CSS for a set of class names, preserving source order."""
found = [(name, _RULE_ORDER.get(name, 0)) for name in classes if name in _REGISTRY]
found.sort(key=lambda t: t[1])
return "".join(_REGISTRY[name] for name, _ in found)
def scan_classes_from_sx(source: str) -> set[str]:
"""Extract class names from :class "..." patterns in sx source text.
Works on both component definitions and page sx source.
Also picks up classes from :class (str ...) concatenation patterns
and ``;; @css class1 class2 ...`` comment annotations for dynamically
constructed class names that the regex can't infer.
"""
classes: set[str] = set()
# Match :class "value" — the common case
for m in re.finditer(r':class\s+"([^"]*)"', source):
classes.update(m.group(1).split())
# Match :class (str "a" " b" ...) — string concatenation
for m in re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
for s in re.findall(r'"([^"]*)"', m.group(1)):
classes.update(s.split())
# Match ;; @css class1 class2 ... — explicit hints for dynamic classes
for m in re.finditer(r';\s*@css\s+(.+)', source):
classes.update(m.group(1).split())
return classes
def registry_loaded() -> bool:
"""True if the registry has been populated."""
return bool(_REGISTRY)
def store_css_hash(classes: set[str] | frozenset[str]) -> str:
"""Compute an 8-char hex hash of the class set, store in cache, return it."""
fs = frozenset(classes)
key = hashlib.sha256(",".join(sorted(fs)).encode()).hexdigest()[:8]
# Move to end (LRU) or insert
_CSS_HASH_CACHE[key] = fs
_CSS_HASH_CACHE.move_to_end(key)
# Evict oldest if over limit
while len(_CSS_HASH_CACHE) > _CSS_HASH_CACHE_MAX:
_CSS_HASH_CACHE.popitem(last=False)
return key
def lookup_css_hash(h: str) -> set[str] | None:
"""Look up a class set by its hash. Returns None on cache miss."""
fs = _CSS_HASH_CACHE.get(h)
if fs is not None:
_CSS_HASH_CACHE.move_to_end(h)
return set(fs)
return None
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _split_rules(css: str) -> list[str]:
"""Split minified CSS into individual top-level rules using brace tracking.
Each returned string is a complete rule including its braces, e.g.:
".flex{display:flex}"
"@media (min-width:640px){.sm\\:hidden{display:none}}"
"""
rules: list[str] = []
depth = 0
start = 0
i = 0
while i < len(css):
ch = css[i]
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
rules.append(css[start:i + 1])
start = i + 1
i += 1
# Trailing content (unlikely in valid CSS)
if start < len(css):
tail = css[start:].strip()
if tail:
rules.append(tail)
return rules
def _extract_selector(rule: str) -> str:
"""Extract the selector portion before the first '{' in a rule."""
brace = rule.find('{')
return rule[:brace].strip() if brace >= 0 else ""
def _is_utility_selector(sel: str) -> bool:
"""Check if a selector looks like a single utility class (.flex, .\\!p-2, etc).
Returns False for resets (*,:before,:after{...}), element selectors (html,body),
and @media / @keyframes wrappers (handled separately).
"""
if sel.startswith('@'):
return False
# Must start with a dot and be a single class
if not sel.startswith('.'):
return False
# Exclude selectors with spaces (descendant combinator, .prose :where(...))
if ' ' in sel:
return False
return True
def _css_selector_to_class(selector: str) -> str:
"""Convert a CSS selector to an HTML class name by unescaping.
.sm\\:hidden → sm:hidden
.h-\\[60vh\\] → h-[60vh]
.\\!p-2 → !p-2
.hover\\:text-stone-700:hover → hover:text-stone-700
"""
# Strip leading dot
name = selector.lstrip('.')
# Strip trailing pseudo-class/element (:hover, :focus, ::placeholder, etc.)
# But don't strip escaped colons (\:) — those are part of the class name.
# Unescaped colon = pseudo-class boundary.
# Find the first unescaped colon (not preceded by backslash)
result = []
i = 0
while i < len(name):
if name[i] == '\\' and i + 1 < len(name):
result.append(name[i + 1])
i += 2
elif name[i] == ':':
break # pseudo-class — stop here
else:
result.append(name[i])
i += 1
return "".join(result)
def _index_rules(rules: list[str]) -> None:
"""Index utility rules into _REGISTRY and _RULE_ORDER."""
order = len(_RULE_ORDER)
for rule in rules:
sel = _extract_selector(rule)
if sel.startswith('@media'):
# Responsive/container wrapper — extract inner rules
_index_media_block(rule, order)
order += 1
continue
if sel.startswith('@'):
# @keyframes, @supports, etc — skip
continue
if not sel.startswith('.'):
continue
# Compound selectors like ".prose :where(p)..." → key on root class "prose"
if ' ' in sel:
root_sel = sel.split(' ', 1)[0]
class_name = _css_selector_to_class(root_sel)
if class_name:
# Append to existing entry
_REGISTRY[class_name] = _REGISTRY.get(class_name, "") + rule
if class_name not in _RULE_ORDER:
_RULE_ORDER[class_name] = order
order += 1
continue
class_name = _css_selector_to_class(sel)
if class_name:
_REGISTRY[class_name] = _REGISTRY.get(class_name, "") + rule
if class_name not in _RULE_ORDER:
_RULE_ORDER[class_name] = order
order += 1
def _index_media_block(rule: str, base_order: int) -> None:
"""Index individual class rules inside an @media block.
For example:
@media (min-width:640px){.sm\\:hidden{display:none}.sm\\:flex{display:flex}}
Each inner rule gets stored wrapped in the @media query.
"""
# Extract the @media wrapper and inner content
first_brace = rule.find('{')
if first_brace < 0:
return
media_prefix = rule[:first_brace + 1] # "@media (min-width:640px){"
# Inner content is between first { and last }
inner = rule[first_brace + 1:]
if inner.endswith('}'):
inner = inner[:-1] # strip the closing } of @media
# Split inner content into individual rules
inner_rules = _split_rules(inner)
for i, inner_rule in enumerate(inner_rules):
sel = _extract_selector(inner_rule)
class_name = _css_selector_to_class(sel)
if class_name:
# Wrap in the @media block
_REGISTRY[class_name] = media_prefix + inner_rule + "}"
_RULE_ORDER[class_name] = base_order + i

View File

@@ -14,6 +14,34 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
# ---------------------------------------------------------------------------
# Pre-computed CSS classes for inline sx built by Python helpers
# ---------------------------------------------------------------------------
# These :class strings appear in post_header_sx / post_admin_header_sx etc.
# They're static — scan once at import time so they aren't re-scanned per request.
_HELPER_CLASS_SOURCES = [
':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"',
':class "relative nav-group"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"',
':class "!bg-stone-500 !text-white"',
':class "fa fa-cog"',
':class "fa fa-shield-halved"',
':class "text-white"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"',
]
def _scan_helper_classes() -> frozenset[str]:
"""Scan the static class strings from helper functions once."""
from .css_registry import scan_classes_from_sx
combined = " ".join(_HELPER_CLASS_SOURCES)
return frozenset(scan_classes_from_sx(combined))
HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes()
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
@@ -127,7 +155,7 @@ def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = "/admin" in request.path
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
@@ -209,9 +237,13 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap."""
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return sx_call("oob-header-sx",
parent_id=parent_id, child_id=child_id,
parent_id=parent_id,
row=SxExpr(row_sx),
)
@@ -219,7 +251,7 @@ def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
id=id, inner=SxExpr(inner_sx),
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
@@ -227,7 +259,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
oobs=SxExpr(oobs) if oobs else None,
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -245,7 +277,7 @@ def full_page_sx(ctx: dict, *, header_rows: str,
meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
"""
body_sx = sx_call("app-body",
header_rows=SxExpr(header_rows) if header_rows else None,
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -341,14 +373,66 @@ def sx_response(source_or_component: str, status: int = 200,
source = source_or_component
body = source
# Validate the sx source parses as a single expression
try:
from .parser import parse as _parse_check
_parse_check(source)
except Exception as _e:
import logging
logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500])
# For SX requests, prepend missing component definitions
comp_defs = ""
if request.headers.get("SX-Request"):
comp_defs = components_for_request()
if comp_defs:
body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}')
# On-demand CSS: scan source for classes, send only new rules
from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded, lookup_css_hash, store_css_hash
from .jinja_bridge import _COMPONENT_ENV
from .types import Component as _Component
new_classes: set[str] = set()
cumulative_classes: set[str] = set()
if registry_loaded():
new_classes = scan_classes_from_sx(source)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
new_classes.update(HELPER_CSS_CLASSES)
if comp_defs:
# Use pre-computed classes for components being sent
for key, val in _COMPONENT_ENV.items():
if isinstance(val, _Component) and val.css_classes:
new_classes.update(val.css_classes)
# Resolve known classes from SX-Css header (hash or full list)
known_classes: set[str] = set()
known_raw = request.headers.get("SX-Css", "")
if known_raw:
if len(known_raw) <= 16:
# Treat as hash
looked_up = lookup_css_hash(known_raw)
if looked_up is not None:
known_classes = looked_up
else:
# Cache miss — send all classes (safe fallback)
known_classes = set()
else:
known_classes = set(known_raw.split(","))
cumulative_classes = known_classes | new_classes
new_classes -= known_classes
if new_classes:
new_rules = lookup_rules(new_classes)
if new_rules:
body = f'<style data-sx-css>{new_rules}</style>\n{body}'
resp = Response(body, status=status, content_type="text/sx")
if new_classes:
resp.headers["SX-Css-Add"] = ",".join(sorted(new_classes))
if cumulative_classes:
resp.headers["SX-Css-Hash"] = store_css_hash(cumulative_classes)
if headers:
for k, v in headers.items():
resp.headers[k] = v
@@ -371,14 +455,9 @@ _SX_PAGE_TEMPLATE = """\
<title>{title}</title>
{meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/basics.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/cards.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/blog-content.css">
<meta name="csrf-token" content="{csrf}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{asset_url}/fontawesome/css/all.min.css">
<link rel="stylesheet" href="{asset_url}/fontawesome/css/v4-shims.min.css">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
<style id="sx-css">{sx_css}</style>
<meta name="sx-css-classes" content="{sx_css_classes}">
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
@@ -403,7 +482,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js"></script>
<script src="{asset_url}/scripts/sx.js?v=20260301d"></script>
<script src="{asset_url}/scripts/body.js"></script>
</body>
</html>"""
@@ -414,9 +493,13 @@ def sx_page(ctx: dict, page_sx: str, *,
"""Return a minimal HTML shell that boots the page from sx source.
The browser loads component definitions and page sx, then sx.js
renders everything client-side.
renders everything client-side. CSS rules are scanned from the sx
source and component defs, then injected as a <style> block.
"""
from .jinja_bridge import client_components_tag
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .css_registry import scan_classes_from_sx, lookup_rules, get_preamble, registry_loaded, store_css_hash
from .types import Component
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
@@ -427,6 +510,27 @@ def sx_page(ctx: dict, page_sx: str, *,
if start > 0 and end > start:
component_defs = components_tag[start:end]
# Scan for CSS classes — use pre-computed sets for components, scan page sx at request time
sx_css = ""
sx_css_classes = ""
sx_css_hash = ""
if registry_loaded():
# Union pre-computed component classes instead of re-scanning source
classes: set[str] = set()
for val in _COMPONENT_ENV.values():
if isinstance(val, Component) and val.css_classes:
classes.update(val.css_classes)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
classes.update(HELPER_CSS_CLASSES)
# Page sx is unique per request — scan it
classes.update(scan_classes_from_sx(page_sx))
# Always include body classes
classes.update(["bg-stone-50", "text-stone-900"])
rules = lookup_rules(classes)
sx_css = get_preamble() + rules
sx_css_hash = store_css_hash(classes)
sx_css_classes = sx_css_hash
asset_url = get_asset_url(ctx)
title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token()
@@ -438,6 +542,8 @@ def sx_page(ctx: dict, page_sx: str, *,
csrf=_html_escape(csrf),
component_defs=component_defs,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
)

View File

@@ -24,11 +24,18 @@ rendered as HTML::
from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval, _call_component
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.ContextVar(
"css_class_collector", default=None
)
class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped."""
@@ -455,6 +462,13 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
children.append(arg)
i += 1
# Collect CSS classes if collector is active
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
if collector is not None:
collector.update(str(class_val).split())
# Build opening tag
parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items():

View File

@@ -113,11 +113,25 @@ def register_components(sx_source: str) -> None:
"""
from .evaluator import _eval
from .parser import parse_all
from .css_registry import scan_classes_from_sx
# Snapshot existing component names before eval
existing = set(_COMPONENT_ENV.keys())
exprs = parse_all(sx_source)
for expr in exprs:
_eval(expr, _COMPONENT_ENV)
# Pre-scan CSS classes for newly registered components.
# Scan the full source once — components from the same file share the set.
# Slightly over-counts per component but safe and avoids re-scanning at request time.
all_classes: set[str] | None = None
for key, val in _COMPONENT_ENV.items():
if key not in existing and isinstance(val, Component):
if all_classes is None:
all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes)
# ---------------------------------------------------------------------------
# sx() — render s-expression from Jinja template
@@ -232,6 +246,12 @@ def client_components_tag(*names: str) -> str:
return f'<script type="text/sx" data-components>{source}</script>'
def sx_css_all() -> str:
"""Return all CSS rules (preamble + utilities) for Jinja fallback pages."""
from .css_registry import get_all_css
return get_all_css()
def setup_sx_bridge(app: Any) -> None:
"""Register s-expression helpers with a Quart app's Jinja environment.
@@ -243,7 +263,9 @@ def setup_sx_bridge(app: Any) -> None:
This registers:
- ``sx(source, **kwargs)`` — sync render (components, pure HTML)
- ``sx_async(source, **kwargs)`` — async render (with I/O resolution)
- ``sx_css_all()`` — full CSS dump for non-sx pages
"""
app.jinja_env.globals["sx"] = sx
app.jinja_env.globals["render"] = render
app.jinja_env.globals["sx_async"] = sx_async
app.jinja_env.globals["sx_css_all"] = sx_css_all

View File

@@ -138,6 +138,7 @@ class Tokenizer:
content = content.replace("\\n", "\n")
content = content.replace("\\t", "\t")
content = content.replace('\\"', '"')
content = content.replace("\\/", "/")
content = content.replace("\\\\", "\\")
return content
@@ -284,6 +285,7 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
.replace("</script", "<\\/script")
)
return f'"{escaped}"'

View File

@@ -0,0 +1,33 @@
;; Shared auth components — login flow, check email
;; Used by account and federation services.
(defcomp ~auth-error-banner (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error)))
(defcomp ~auth-login-form (&key error action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
(defcomp ~auth-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
error)))
(defcomp ~auth-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
error))

View File

@@ -6,7 +6,7 @@
(header :class "z-50"
(div :id "root-header-summary"
:class "flex items-start gap-2 p-1 bg-sky-500"
(div :class "flex flex-col w-full items-center"
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(when header-rows header-rows)))))
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu menu))))
@@ -68,7 +68,9 @@
(when cart-mini cart-mini)
(div :class "font-bold text-5xl flex-1"
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
(h1 (or site-title ""))))
(h1 (or site-title ""))
(when app-label
(span :class "text-lg text-white/80 font-normal" app-label))))
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(when nav-tree nav-tree)
(when auth-menu auth-menu)
@@ -80,6 +82,7 @@
(div :class "block md:hidden text-md font-bold"
(when auth-menu auth-menu))))
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
hx-select nav child-id child oob external)
(let* ((c (or colour "sky"))
@@ -107,13 +110,12 @@
(div :id child-id :class "flex flex-col w-full items-center"
(when child child))))))
(defcomp ~oob-header-sx (&key parent-id child-id row)
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" row
(div :id child-id))))
(defcomp ~oob-header-sx (&key parent-id row)
(div :id parent-id :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
row))
(defcomp ~header-child-sx (&key id inner)
(div :id (or id "root-header-child") :class "w-full" inner))
(div :id (or id "root-header-child") :class "flex flex-col w-full items-center" inner))
(defcomp ~error-content (&key errnum message image)
(div :class "text-center p-8 max-w-lg mx-auto"

View File

@@ -1,4 +1,4 @@
;; Miscellaneous shared components for Phase 3 conversion
;; Miscellaneous shared components
;; The single place where raw! lives — for CMS content (Ghost post body,
;; product descriptions, etc.) that arrives as pre-rendered HTML.
@@ -33,3 +33,226 @@
:sx-select hx-select :sx-swap "outerHTML"
:sx-push-url "true" :class nav-class
label)))
;; ---------------------------------------------------------------------------
;; Shared sentinel components — infinite scroll triggers
;; ---------------------------------------------------------------------------
(defcomp ~sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-8 text-stone-400"
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~sentinel-simple (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"
:role "status" :aria-hidden "true"
(div :class "text-center text-xs text-stone-400" "loading...")))
(defcomp ~end-of-results (&key cls)
(div :class (or cls "col-span-full mt-4 text-center text-xs text-stone-400") "End of results"))
;; ---------------------------------------------------------------------------
;; Shared empty state — icon + message + optional action
;; ---------------------------------------------------------------------------
(defcomp ~empty-state (&key icon message cls &rest children)
(div :class (or cls "p-8 text-center text-stone-400")
(when icon (div (i :class (str icon " text-4xl mb-2") :aria-hidden "true")))
(p message)
children))
;; ---------------------------------------------------------------------------
;; Shared badge — inline pill with configurable colours
;; ---------------------------------------------------------------------------
(defcomp ~badge (&key label cls)
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " (or cls "bg-stone-100 text-stone-700"))
label))
;; ---------------------------------------------------------------------------
;; Shared delete button with confirm dialog
;; ---------------------------------------------------------------------------
(defcomp ~delete-btn (&key url trigger-target title text confirm-text cancel-text
sx-headers cls)
(button :type "button"
:data-confirm "" :data-confirm-title (or title "Delete?")
:data-confirm-text (or text "Are you sure?")
:data-confirm-icon "warning"
:data-confirm-confirm-text (or confirm-text "Yes, delete")
:data-confirm-cancel-text (or cancel-text "Cancel")
:data-confirm-event "confirmed"
:sx-delete url :sx-trigger "confirmed"
:sx-target trigger-target :sx-swap "outerHTML"
:sx-headers sx-headers
:class (or cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800")
(i :class "fa fa-trash") " Delete"))
;; ---------------------------------------------------------------------------
;; Shared price display — special + regular with strikethrough
;; ---------------------------------------------------------------------------
(defcomp ~price (&key special-price regular-price)
(div :class "mt-1 flex items-baseline gap-2 justify-center"
(when special-price (div :class "text-lg font-semibold text-emerald-700" special-price))
(when (and special-price regular-price) (div :class "text-sm line-through text-stone-500" regular-price))
(when (and (not special-price) regular-price) (div :class "mt-1 text-lg font-semibold" regular-price))))
;; ---------------------------------------------------------------------------
;; Shared image-or-placeholder
;; ---------------------------------------------------------------------------
(defcomp ~img-or-placeholder (&key src alt size-cls placeholder-icon placeholder-text)
(if src
(img :src src :alt (or alt "") :class (or size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0"))
(div :class (str (or size-cls "w-12 h-12 rounded-full") " bg-stone-200 flex items-center justify-center flex-shrink-0")
(if placeholder-icon
(i :class (str placeholder-icon " text-stone-400") :aria-hidden "true")
(when placeholder-text placeholder-text)))))
;; ---------------------------------------------------------------------------
;; Shared view toggle — list/tile view switcher
;; ---------------------------------------------------------------------------
(defcomp ~list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~view-toggle (&key list-href tile-href hx-select list-cls tile-cls
storage-key list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ (str "on click js localStorage.removeItem('" (or storage-key "view") "') end")
(or list-svg (~list-svg)))
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ (str "on click js localStorage.setItem('" (or storage-key "view") "','tile') end")
(or tile-svg (~tile-svg)))))
;; ---------------------------------------------------------------------------
;; Shared CRUD admin panel — for calendars, markets, etc.
;; ---------------------------------------------------------------------------
(defcomp ~crud-create-form (&key create-url csrf errors-id list-id placeholder label btn-label)
(<>
(div :id (or errors-id "crud-create-errors") :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
:sx-on:beforeRequest (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='';")
:sx-on:responseError (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}")
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" (or label "Name"))
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder (or placeholder "Name")))
(button :type "submit" :class "border rounded px-3 py-2" (or btn-label "Add")))))
(defcomp ~crud-panel (&key form list list-id)
(section :class "p-4"
form
(div :id (or list-id "crud-list") :class "mt-6" list)))
(defcomp ~crud-item (&key href name slug del-url csrf-hdr list-id
confirm-title confirm-text)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
(h3 :class "font-semibold" name)
(h4 :class "text-gray-500" (str "/" slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title (or confirm-title "Delete?")
:data-confirm-text (or confirm-text "This will be soft deleted")
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-delete del-url :sx-trigger "confirmed"
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
:sx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))
;; ---------------------------------------------------------------------------
;; Shared SumUp settings form — payment credentials (merchant code, API key,
;; checkout prefix) used by blog, events, and cart admin panels.
;; ---------------------------------------------------------------------------
(defcomp ~sumup-settings-form (&key update-url csrf merchant-code placeholder
input-cls sumup-configured checkout-prefix
panel-id sx-select)
(div :id (or panel-id "payments-panel") :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put update-url
:sx-target (str "#" (or panel-id "payments-panel"))
:sx-swap "outerHTML"
:sx-select sx-select
:class "space-y-3"
(when csrf (input :type "hidden" :name "csrf_token" :value csrf))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
(button :type "submit"
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected")))))
;; ---------------------------------------------------------------------------
;; Shared avatar — image or initial-letter placeholder
;; ---------------------------------------------------------------------------
(defcomp ~avatar (&key src cls initial)
(if src
(img :src src :alt "" :class cls)
(div :class cls initial)))
;; ---------------------------------------------------------------------------
;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows
;; ---------------------------------------------------------------------------
(defcomp ~scroll-nav-wrapper (&key wrapper-id container-id arrow-cls left-hs scroll-hs right-hs items oob)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id wrapper-id :sx-swap-oob (if oob "outerHTML" nil)
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll left"
:_ left-hs (i :class "fa fa-chevron-left"))
(div :id container-id
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" items))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll right"
:_ right-hs (i :class "fa fa-chevron-right"))))

View File

@@ -0,0 +1,25 @@
;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id)
(div :id wrapper-id :sx-swap-oob "outerHTML"))
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:aria-selected selected :class nav-cls
img (span label))))
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
(div (a :href href :aria-selected selected :class nav-cls
img (span label))))
;; Nav entries
(defcomp ~blog-nav-entries-empty ()
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
(a :href href :class nav-cls
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))

View File

@@ -5,15 +5,22 @@
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" date-str))))
(defcomp ~calendar-link-nav (&key href name nav-class)
(a :href href :class nav-class
(defcomp ~calendar-link-nav (&key href name nav-class is-selected select-colours)
(a :href href
:sx-get href
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:aria-selected (when is-selected "true")
:class (str (or nav-class "") " " (or select-colours ""))
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(span name)))
(defcomp ~market-link-nav (&key href name nav-class)
(a :href href :class nav-class
(defcomp ~market-link-nav (&key href name nav-class select-colours)
(a :href href :class (str (or nav-class "") " " (or select-colours ""))
(i :class "fa fa-shopping-bag" :aria-hidden "true")
(div name)))
(span name)))
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")

View File

@@ -0,0 +1,142 @@
;; Shared order components — used by both cart and orders services
;;
;; Order table (list view), order detail panels, and checkout error screens.
;; ---------------------------------------------------------------------------
;; Order table rows
;; ---------------------------------------------------------------------------
(defcomp ~order-row-desktop (&key oid created desc total pill status url)
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" created)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" total)
(td :class "px-3 py-2 align-top" (span :class pill status))
(td :class "px-3 py-0.5 align-top text-right"
(a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
(defcomp ~order-row-mobile (&key oid created total pill status url)
(tr :class "sm:hidden border-t border-stone-100"
(td :colspan "5" :class "px-3 py-3"
(div :class "flex flex-col gap-2 text-xs"
(div :class "flex items-center justify-between gap-2"
(span :class "font-mono text-[11px] text-stone-700" oid)
(span :class pill status))
(div :class "text-[11px] text-stone-500 break-words" created)
(div :class "flex items-center justify-between gap-2"
(div :class "font-medium text-stone-800" total)
(a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
(defcomp ~order-end-row ()
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400"
(~end-of-results :cls "text-center text-xs text-stone-400"))))
(defcomp ~order-empty-state ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~order-table (&key rows)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm"
(thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"
(tr
(th :class "px-3 py-2 text-left font-medium" "Order")
(th :class "px-3 py-2 text-left font-medium" "Created")
(th :class "px-3 py-2 text-left font-medium" "Description")
(th :class "px-3 py-2 text-left font-medium" "Total")
(th :class "px-3 py-2 text-left font-medium" "Status")
(th :class "px-3 py-2 text-left font-medium" "")))
(tbody rows)))))
(defcomp ~order-list-header (&key search-mobile)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
(div :class "md:hidden" search-mobile)))
;; ---------------------------------------------------------------------------
;; Order detail panels
;; ---------------------------------------------------------------------------
(defcomp ~order-item-image (&key src alt)
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
(defcomp ~order-item-no-image ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~order-item-row (&key href img title pid qty price)
(li (a :class "w-full py-2 flex gap-3" :href href
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
(div :class "flex-1 flex justify-between gap-3"
(div
(p :class "font-medium" title)
(p :class "text-[11px] text-stone-500" pid))
(div :class "text-right whitespace-nowrap"
(p qty)
(p price))))))
(defcomp ~order-items-panel (&key items)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
(defcomp ~order-calendar-entry (&key name pill status date-str cost)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div :class "font-medium flex items-center gap-2"
name (span :class pill status))
(div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" cost)))
(defcomp ~order-calendar-section (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~order-detail-panel (&key summary items calendar)
(div :class "max-w-full px-3 py-3 space-y-4" summary items calendar))
(defcomp ~order-pay-btn (&key url)
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
(defcomp ~order-detail-filter (&key info list-url recheck-url csrf pay)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" info))
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
(form :method "post" :action recheck-url :class "inline"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
pay)))
(defcomp ~order-detail-header-stack (&key auth orders order)
(~header-child-sx :inner
(<> auth (~header-child-sx :id "auth-header-child" :inner
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
;; ---------------------------------------------------------------------------
;; Checkout error screens
;; ---------------------------------------------------------------------------
(defcomp ~checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~checkout-error-order-id (&key oid)
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" oid)))
(defcomp ~checkout-error-content (&key msg order back-url)
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")
(p msg)
order)
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))

View File

@@ -10,7 +10,7 @@
"body{margin:0;min-height:100vh;display:flex;align-items:center;"
"justify-content:center;font-family:system-ui,sans-serif;"
"background:#fafaf9;color:#1c1917}")
(script :src "https://cdn.tailwindcss.com")
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/tw.css"))
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")))
(body :class "bg-stone-50 text-stone-900"
children))))

View File

@@ -237,7 +237,7 @@ class TestBaseShell:
assert "<html" in html
assert "<title>Test</title>" in html
assert "<p>Hello</p>" in html
assert "tailwindcss" in html
assert "tw.css" in html
class TestErrorPage:

View File

@@ -143,6 +143,7 @@ class Component:
has_children: bool # True if &rest children declared
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
css_classes: set[str] = field(default_factory=set) # pre-scanned :class values
def __repr__(self):
return f"<Component ~{self.name}({', '.join(self.params)})>"