Compare commits
3 Commits
6f1d5bac3c
...
53c4a0a1e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c4a0a1e0 | |||
| 9c6170ed31 | |||
| a0a0f5ebc2 |
@@ -1,36 +0,0 @@
|
||||
{# Desktop auth menu #}
|
||||
<span id="auth-menu-desktop" class="hidden md:inline-flex">
|
||||
{% if user_email %}
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{ user_email }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{# Mobile auth menu #}
|
||||
<span id="auth-menu-mobile" class="block md:hidden text-md font-bold">
|
||||
{% if user_email %}
|
||||
<a href="{{ account_url('/') }}" data-close-details>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{ user_email }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ account_url('/') }}">
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -1,20 +0,0 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="blog" data-hx-disable>
|
||||
<div class="flex flex-row items-start gap-3 p-3">
|
||||
{% if feature_image %}
|
||||
<img src="{{ feature_image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||
{% else %}
|
||||
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||
<i class="fas fa-file-alt text-lg"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||
{% if excerpt %}
|
||||
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ excerpt }}</div>
|
||||
{% endif %}
|
||||
{% if published_at %}
|
||||
<div class="text-xs text-stone-400 mt-1">{{ published_at.strftime('%d %b %Y') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,27 +0,0 @@
|
||||
<div id="cart-mini" {% if oob %}hx-swap-oob="true"{% endif %}>
|
||||
{% if cart_count == 0 %}
|
||||
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||
<a
|
||||
href="{{ blog_url('/') }}"
|
||||
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||
>
|
||||
<img
|
||||
src="{{ blog_url('/static/img/logo.jpg') }}"
|
||||
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a
|
||||
href="{{ cart_url('/') }}"
|
||||
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||
>
|
||||
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
|
||||
<span
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
>
|
||||
{{ cart_count }}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -176,6 +176,7 @@ def register():
|
||||
|
||||
async def _link_card_handler():
|
||||
from shared.infrastructure.urls import events_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
slug = request.args.get("slug", "")
|
||||
keys_raw = request.args.get("keys", "")
|
||||
@@ -193,12 +194,10 @@ def register():
|
||||
g.s, "page", post.id,
|
||||
)
|
||||
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
|
||||
parts.append(await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=post.title,
|
||||
feature_image=post.feature_image,
|
||||
calendar_names=cal_names,
|
||||
link=events_url(f"/{post.slug}"),
|
||||
parts.append(render_sexp(
|
||||
'(~link-card :title title :image image :subtitle subtitle :link link)',
|
||||
title=post.title, image=post.feature_image,
|
||||
subtitle=cal_names, link=events_url(f"/{post.slug}"),
|
||||
))
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -213,12 +212,10 @@ def register():
|
||||
g.s, "page", post.id,
|
||||
)
|
||||
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
|
||||
return await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=post.title,
|
||||
feature_image=post.feature_image,
|
||||
calendar_names=cal_names,
|
||||
link=events_url(f"/{post.slug}"),
|
||||
return render_sexp(
|
||||
'(~link-card :title title :image image :subtitle subtitle :link link)',
|
||||
title=post.title, image=post.feature_image,
|
||||
subtitle=cal_names, link=events_url(f"/{post.slug}"),
|
||||
)
|
||||
|
||||
_handlers["link-card"] = _link_card_handler
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{# Calendar links nav — served as fragment from events app #}
|
||||
{% for calendar in calendars %}
|
||||
{% set local_href=events_url('/' + post_slug + '/calendars/' + calendar.slug + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -1,28 +0,0 @@
|
||||
{# Calendar entries nav — served as fragment from events app #}
|
||||
{% for entry in entries %}
|
||||
{% set _entry_path = '/' + post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
>
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Infinite scroll sentinel — URL points back to the consumer app #}
|
||||
{% if has_more and paginate_url_base %}
|
||||
<div id="entries-load-sentinel-{{ page }}"
|
||||
hx-get="{{ paginate_url_base }}?page={{ page + 1 }}"
|
||||
hx-trigger="intersect once"
|
||||
hx-swap="beforebegin"
|
||||
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
|
||||
class="flex-shrink-0 w-1">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="events" data-hx-disable>
|
||||
<div class="flex flex-row items-start gap-3 p-3">
|
||||
{% if feature_image %}
|
||||
<img src="{{ feature_image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||
{% else %}
|
||||
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||
<i class="fas fa-calendar text-lg"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||
{% if calendar_names %}
|
||||
<div class="text-xs text-stone-500 mt-0.5">{{ calendar_names }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,18 +0,0 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="federation" data-hx-disable>
|
||||
<div class="flex flex-row items-center gap-3 p-3">
|
||||
{% if avatar_url %}
|
||||
<img src="{{ avatar_url }}" alt="" class="flex-shrink-0 w-12 h-12 rounded-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center text-stone-400">
|
||||
<i class="fas fa-user text-lg"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm">{{ display_name or username }}</div>
|
||||
<div class="text-xs text-stone-500">@{{ username }}</div>
|
||||
{% if summary %}
|
||||
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -6,7 +6,7 @@ by other coop apps via the fragment client.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, render_template, request
|
||||
from quart import Blueprint, Response, g, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.registry import services
|
||||
@@ -65,6 +65,7 @@ def register():
|
||||
from sqlalchemy import select
|
||||
from shared.models.market import Product
|
||||
from shared.infrastructure.urls import market_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
slug = request.args.get("slug", "")
|
||||
keys_raw = request.args.get("keys", "")
|
||||
@@ -79,14 +80,16 @@ def register():
|
||||
await g.s.execute(select(Product).where(Product.slug == s))
|
||||
).scalar_one_or_none()
|
||||
if product:
|
||||
parts.append(await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=product.title,
|
||||
image=product.image,
|
||||
description_short=product.description_short,
|
||||
brand=product.brand,
|
||||
regular_price=product.regular_price,
|
||||
special_price=product.special_price,
|
||||
subtitle = product.brand or ""
|
||||
detail = ""
|
||||
if product.special_price:
|
||||
detail = f"<s>{product.regular_price}</s> {product.special_price}"
|
||||
elif product.regular_price:
|
||||
detail = str(product.regular_price)
|
||||
parts.append(render_sexp(
|
||||
'(~link-card :title title :image image :subtitle subtitle :detail detail :link link)',
|
||||
title=product.title, image=product.image,
|
||||
subtitle=subtitle, detail=detail,
|
||||
link=market_url(f"/product/{product.slug}/"),
|
||||
))
|
||||
return "\n".join(parts)
|
||||
@@ -99,14 +102,16 @@ def register():
|
||||
).scalar_one_or_none()
|
||||
if not product:
|
||||
return ""
|
||||
return await render_template(
|
||||
"fragments/link_card.html",
|
||||
title=product.title,
|
||||
image=product.image,
|
||||
description_short=product.description_short,
|
||||
brand=product.brand,
|
||||
regular_price=product.regular_price,
|
||||
special_price=product.special_price,
|
||||
subtitle = product.brand or ""
|
||||
detail = ""
|
||||
if product.special_price:
|
||||
detail = f"<s>{product.regular_price}</s> {product.special_price}"
|
||||
elif product.regular_price:
|
||||
detail = str(product.regular_price)
|
||||
return render_sexp(
|
||||
'(~link-card :title title :image image :subtitle subtitle :detail detail :link link)',
|
||||
title=product.title, image=product.image,
|
||||
subtitle=subtitle, detail=detail,
|
||||
link=market_url(f"/product/{product.slug}/"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{# Market links nav — served as fragment from market app #}
|
||||
{% for m in markets %}
|
||||
<a
|
||||
href="{{ market_url('/' + post_slug + '/' + m.slug + '/') }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{m.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -1,28 +0,0 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="market" data-hx-disable>
|
||||
<div class="flex flex-row items-start gap-3 p-3">
|
||||
{% if image %}
|
||||
<img src="{{ image }}" alt="" class="flex-shrink-0 w-16 h-16 rounded object-cover">
|
||||
{% else %}
|
||||
<div class="flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400">
|
||||
<i class="fas fa-shopping-bag text-lg"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm clamp-2">{{ title }}</div>
|
||||
{% if brand %}
|
||||
<div class="text-xs text-stone-500 mt-0.5">{{ brand }}</div>
|
||||
{% endif %}
|
||||
{% if description_short %}
|
||||
<div class="text-xs text-stone-500 mt-1 clamp-2">{{ description_short }}</div>
|
||||
{% endif %}
|
||||
<div class="text-xs mt-1">
|
||||
{% if special_price %}
|
||||
<span class="text-red-600 font-medium">£{{ "%.2f"|format(special_price) }}</span>
|
||||
<span class="text-stone-400 line-through ml-1">£{{ "%.2f"|format(regular_price) }}</span>
|
||||
{% elif regular_price %}
|
||||
<span class="text-stone-700 font-medium">£{{ "%.2f"|format(regular_price) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Add relation_type and metadata columns to container_relations
|
||||
|
||||
Revision ID: relations_0002
|
||||
Revises: relations_0001
|
||||
Create Date: 2026-02-28
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "relations_0002"
|
||||
down_revision = "relations_0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"container_relations",
|
||||
sa.Column("relation_type", sa.String(64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"container_relations",
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_container_relations_relation_type",
|
||||
"container_relations",
|
||||
["relation_type", "parent_type", "parent_id"],
|
||||
)
|
||||
|
||||
# Backfill relation_type for existing rows based on parent/child type pairs
|
||||
op.execute("""
|
||||
UPDATE container_relations
|
||||
SET relation_type = CASE
|
||||
WHEN parent_type = 'page' AND child_type = 'market'
|
||||
THEN 'page->market'
|
||||
WHEN parent_type = 'page' AND child_type = 'calendar'
|
||||
THEN 'page->calendar'
|
||||
WHEN parent_type = 'page' AND child_type = 'menu_node'
|
||||
THEN 'page->menu_node'
|
||||
END
|
||||
WHERE relation_type IS NULL
|
||||
AND parent_type = 'page'
|
||||
AND child_type IN ('market', 'calendar', 'menu_node')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_container_relations_relation_type", "container_relations")
|
||||
op.drop_column("container_relations", "metadata")
|
||||
op.drop_column("container_relations", "relation_type")
|
||||
@@ -3,7 +3,7 @@ import path_setup # noqa: F401
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_actions, register_data
|
||||
from bp import register_actions, register_data, register_fragments
|
||||
from services import register_domain_services
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ def create_app() -> "Quart":
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
from .fragments.routes import register as register_fragments
|
||||
|
||||
@@ -46,6 +46,8 @@ def register() -> Blueprint:
|
||||
child_id=data["child_id"],
|
||||
label=data.get("label"),
|
||||
sort_order=data.get("sort_order"),
|
||||
relation_type=data.get("relation_type"),
|
||||
metadata=data.get("metadata"),
|
||||
)
|
||||
return {
|
||||
"id": rel.id,
|
||||
@@ -54,6 +56,7 @@ def register() -> Blueprint:
|
||||
"child_type": rel.child_type,
|
||||
"child_id": rel.child_id,
|
||||
"sort_order": rel.sort_order,
|
||||
"relation_type": rel.relation_type,
|
||||
}
|
||||
|
||||
_handlers["attach-child"] = _attach_child
|
||||
@@ -70,9 +73,122 @@ def register() -> Blueprint:
|
||||
parent_id=data["parent_id"],
|
||||
child_type=data["child_type"],
|
||||
child_id=data["child_id"],
|
||||
relation_type=data.get("relation_type"),
|
||||
)
|
||||
return {"deleted": deleted}
|
||||
|
||||
_handlers["detach-child"] = _detach_child
|
||||
|
||||
# --- relate (registry-aware) ---
|
||||
async def _relate():
|
||||
"""Create a typed relation with registry validation and cardinality enforcement."""
|
||||
from shared.services.relationships import attach_child, get_children
|
||||
from shared.sexp.relations import get_relation
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
rel_type = data.get("relation_type")
|
||||
if not rel_type:
|
||||
return {"error": "relation_type is required"}, 400
|
||||
|
||||
defn = get_relation(rel_type)
|
||||
if defn is None:
|
||||
return {"error": f"unknown relation_type: {rel_type}"}, 400
|
||||
|
||||
from_id = data["from_id"]
|
||||
to_id = data["to_id"]
|
||||
|
||||
# Cardinality enforcement
|
||||
if defn.cardinality == "one-to-one":
|
||||
existing = await get_children(
|
||||
g.s,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=from_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=rel_type,
|
||||
)
|
||||
if existing:
|
||||
return {"error": "one-to-one relation already exists", "existing_id": existing[0].child_id}, 409
|
||||
|
||||
rel = await attach_child(
|
||||
g.s,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=from_id,
|
||||
child_type=defn.to_type,
|
||||
child_id=to_id,
|
||||
label=data.get("label"),
|
||||
sort_order=data.get("sort_order"),
|
||||
relation_type=rel_type,
|
||||
metadata=data.get("metadata"),
|
||||
)
|
||||
return {
|
||||
"id": rel.id,
|
||||
"relation_type": rel.relation_type,
|
||||
"parent_type": rel.parent_type,
|
||||
"parent_id": rel.parent_id,
|
||||
"child_type": rel.child_type,
|
||||
"child_id": rel.child_id,
|
||||
"sort_order": rel.sort_order,
|
||||
}
|
||||
|
||||
_handlers["relate"] = _relate
|
||||
|
||||
# --- unrelate (registry-aware) ---
|
||||
async def _unrelate():
|
||||
"""Remove a typed relation with registry validation."""
|
||||
from shared.services.relationships import detach_child
|
||||
from shared.sexp.relations import get_relation
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
rel_type = data.get("relation_type")
|
||||
if not rel_type:
|
||||
return {"error": "relation_type is required"}, 400
|
||||
|
||||
defn = get_relation(rel_type)
|
||||
if defn is None:
|
||||
return {"error": f"unknown relation_type: {rel_type}"}, 400
|
||||
|
||||
deleted = await detach_child(
|
||||
g.s,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=data["from_id"],
|
||||
child_type=defn.to_type,
|
||||
child_id=data["to_id"],
|
||||
relation_type=rel_type,
|
||||
)
|
||||
return {"deleted": deleted}
|
||||
|
||||
_handlers["unrelate"] = _unrelate
|
||||
|
||||
# --- can-relate (pre-flight check) ---
|
||||
async def _can_relate():
|
||||
"""Check if a relation can be created (cardinality, registry validation)."""
|
||||
from shared.services.relationships import get_children
|
||||
from shared.sexp.relations import get_relation
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
rel_type = data.get("relation_type")
|
||||
if not rel_type:
|
||||
return {"error": "relation_type is required"}, 400
|
||||
|
||||
defn = get_relation(rel_type)
|
||||
if defn is None:
|
||||
return {"allowed": False, "reason": f"unknown relation_type: {rel_type}"}
|
||||
|
||||
from_id = data["from_id"]
|
||||
|
||||
if defn.cardinality == "one-to-one":
|
||||
existing = await get_children(
|
||||
g.s,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=from_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=rel_type,
|
||||
)
|
||||
if existing:
|
||||
return {"allowed": False, "reason": "one-to-one relation already exists"}
|
||||
|
||||
return {"allowed": True}
|
||||
|
||||
_handlers["can-relate"] = _can_relate
|
||||
|
||||
return bp
|
||||
|
||||
@@ -35,22 +35,43 @@ def register() -> Blueprint:
|
||||
parent_type = request.args.get("parent_type", "")
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
child_type = request.args.get("child_type")
|
||||
relation_type = request.args.get("relation_type")
|
||||
if not parent_type or parent_id is None:
|
||||
return []
|
||||
rels = await get_children(g.s, parent_type, parent_id, child_type)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"parent_type": r.parent_type,
|
||||
"parent_id": r.parent_id,
|
||||
"child_type": r.child_type,
|
||||
"child_id": r.child_id,
|
||||
"sort_order": r.sort_order,
|
||||
"label": r.label,
|
||||
}
|
||||
for r in rels
|
||||
]
|
||||
rels = await get_children(g.s, parent_type, parent_id, child_type, relation_type=relation_type)
|
||||
return [_serialize_rel(r) for r in rels]
|
||||
|
||||
_handlers["get-children"] = _get_children
|
||||
|
||||
# --- get-parents ---
|
||||
async def _get_parents():
|
||||
"""Return ContainerRelation parents for a child."""
|
||||
from shared.services.relationships import get_parents
|
||||
|
||||
child_type = request.args.get("child_type", "")
|
||||
child_id = request.args.get("child_id", type=int)
|
||||
parent_type = request.args.get("parent_type")
|
||||
relation_type = request.args.get("relation_type")
|
||||
if not child_type or child_id is None:
|
||||
return []
|
||||
rels = await get_parents(g.s, child_type, child_id, parent_type, relation_type=relation_type)
|
||||
return [_serialize_rel(r) for r in rels]
|
||||
|
||||
_handlers["get-parents"] = _get_parents
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def _serialize_rel(r):
|
||||
"""Serialize a ContainerRelation to a dict."""
|
||||
return {
|
||||
"id": r.id,
|
||||
"parent_type": r.parent_type,
|
||||
"parent_id": r.parent_id,
|
||||
"child_type": r.child_type,
|
||||
"child_id": r.child_id,
|
||||
"sort_order": r.sort_order,
|
||||
"label": r.label,
|
||||
"relation_type": r.relation_type,
|
||||
"metadata": r.metadata_,
|
||||
}
|
||||
|
||||
0
relations/bp/fragments/__init__.py
Normal file
0
relations/bp/fragments/__init__.py
Normal file
88
relations/bp/fragments/routes.py
Normal file
88
relations/bp/fragments/routes.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Relations app fragment endpoints.
|
||||
|
||||
Generic container-nav fragment that renders navigation items for all
|
||||
related entities, driven by the relation registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/html")
|
||||
html = await handler()
|
||||
return Response(html, status=200, content_type="text/html")
|
||||
|
||||
# --- generic container-nav fragment ----------------------------------------
|
||||
|
||||
async def _container_nav_handler():
|
||||
"""Render nav items for all visible relations of a container entity.
|
||||
|
||||
Query params:
|
||||
container_type: entity type (e.g. "page")
|
||||
container_id: entity id
|
||||
post_slug: used for URL construction
|
||||
exclude: comma-separated relation types to skip
|
||||
"""
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
from shared.sexp.relations import relations_from
|
||||
from shared.services.relationships import get_children
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = int(request.args.get("container_id", 0))
|
||||
post_slug = request.args.get("post_slug", "")
|
||||
exclude_raw = request.args.get("exclude", "")
|
||||
exclude = set(exclude_raw.split(",")) if exclude_raw else set()
|
||||
|
||||
nav_defs = [
|
||||
d for d in relations_from(container_type)
|
||||
if d.nav != "hidden" and d.name not in exclude
|
||||
]
|
||||
|
||||
if not nav_defs:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for defn in nav_defs:
|
||||
children = await get_children(
|
||||
g.s,
|
||||
parent_type=container_type,
|
||||
parent_id=container_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=defn.name,
|
||||
)
|
||||
for child in children:
|
||||
slug = (child.metadata_ or {}).get("slug", "")
|
||||
href = f"/{post_slug}/{slug}/" if post_slug else f"/{slug}/"
|
||||
parts.append(render_sexp(
|
||||
'(~relation-nav :href href :name name :icon icon :nav-class nav-class :relation-type relation-type)',
|
||||
href=href,
|
||||
name=child.label or "",
|
||||
icon=defn.nav_icon or "",
|
||||
**{
|
||||
"nav-class": "",
|
||||
"relation-type": defn.name,
|
||||
},
|
||||
))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
_handlers["container-nav"] = _container_nav_handler
|
||||
|
||||
return bp
|
||||
@@ -30,6 +30,7 @@ from .jinja_setup import setup_jinja
|
||||
from .user_loader import load_current_user
|
||||
from shared.sexp.jinja_bridge import setup_sexp_bridge
|
||||
from shared.sexp.components import load_shared_components
|
||||
from shared.sexp.relations import load_relation_registry
|
||||
|
||||
|
||||
# Async init of config (runs once at import)
|
||||
@@ -108,6 +109,7 @@ def create_base_app(
|
||||
setup_jinja(app)
|
||||
setup_sexp_bridge(app)
|
||||
load_shared_components()
|
||||
load_relation_registry()
|
||||
errors(app)
|
||||
|
||||
# Auto-register OAuth client blueprint for non-account apps
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func
|
||||
from sqlalchemy import Integer, String, DateTime, Index, JSON, UniqueConstraint, func
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ class ContainerRelation(Base):
|
||||
),
|
||||
Index("ix_container_relations_parent", "parent_type", "parent_id"),
|
||||
Index("ix_container_relations_child", "child_type", "child_id"),
|
||||
Index(
|
||||
"ix_container_relations_relation_type",
|
||||
"relation_type", "parent_type", "parent_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -24,6 +28,9 @@ class ContainerRelation(Base):
|
||||
child_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
relation_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=False)
|
||||
metadata_: Mapped[Optional[dict]] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ async def attach_child(
|
||||
child_id: int,
|
||||
label: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
relation_type: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> ContainerRelation:
|
||||
"""
|
||||
Create a ContainerRelation and emit container.child_attached event.
|
||||
@@ -39,6 +41,10 @@ async def attach_child(
|
||||
existing.sort_order = sort_order
|
||||
if label is not None:
|
||||
existing.label = label
|
||||
if relation_type is not None:
|
||||
existing.relation_type = relation_type
|
||||
if metadata is not None:
|
||||
existing.metadata_ = metadata
|
||||
await session.flush()
|
||||
await emit_activity(
|
||||
session,
|
||||
@@ -50,12 +56,25 @@ async def attach_child(
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
**({"relation_type": relation_type} if relation_type else {}),
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=existing.id,
|
||||
)
|
||||
return existing
|
||||
# Already attached and active — no-op
|
||||
# Already attached and active — update mutable fields if provided
|
||||
changed = False
|
||||
if relation_type is not None and existing.relation_type != relation_type:
|
||||
existing.relation_type = relation_type
|
||||
changed = True
|
||||
if metadata is not None and existing.metadata_ != metadata:
|
||||
existing.metadata_ = metadata
|
||||
changed = True
|
||||
if label is not None and existing.label != label:
|
||||
existing.label = label
|
||||
changed = True
|
||||
if changed:
|
||||
await session.flush()
|
||||
return existing
|
||||
|
||||
if sort_order is None:
|
||||
@@ -75,6 +94,8 @@ async def attach_child(
|
||||
child_id=child_id,
|
||||
label=label,
|
||||
sort_order=sort_order,
|
||||
relation_type=relation_type,
|
||||
metadata_=metadata,
|
||||
)
|
||||
session.add(rel)
|
||||
await session.flush()
|
||||
@@ -89,6 +110,7 @@ async def attach_child(
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
**({"relation_type": relation_type} if relation_type else {}),
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=rel.id,
|
||||
@@ -102,8 +124,9 @@ async def get_children(
|
||||
parent_type: str,
|
||||
parent_id: int,
|
||||
child_type: str | None = None,
|
||||
relation_type: str | None = None,
|
||||
) -> list[ContainerRelation]:
|
||||
"""Query children of a container, optionally filtered by child_type."""
|
||||
"""Query children of a container, optionally filtered by child_type or relation_type."""
|
||||
stmt = select(ContainerRelation).where(
|
||||
ContainerRelation.parent_type == parent_type,
|
||||
ContainerRelation.parent_id == parent_id,
|
||||
@@ -111,6 +134,8 @@ async def get_children(
|
||||
)
|
||||
if child_type is not None:
|
||||
stmt = stmt.where(ContainerRelation.child_type == child_type)
|
||||
if relation_type is not None:
|
||||
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
|
||||
|
||||
stmt = stmt.order_by(
|
||||
ContainerRelation.sort_order.asc(), ContainerRelation.id.asc()
|
||||
@@ -119,23 +144,49 @@ async def get_children(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_parents(
|
||||
session: AsyncSession,
|
||||
child_type: str,
|
||||
child_id: int,
|
||||
parent_type: str | None = None,
|
||||
relation_type: str | None = None,
|
||||
) -> list[ContainerRelation]:
|
||||
"""Query parents of an entity, optionally filtered by parent_type or relation_type."""
|
||||
stmt = select(ContainerRelation).where(
|
||||
ContainerRelation.child_type == child_type,
|
||||
ContainerRelation.child_id == child_id,
|
||||
ContainerRelation.deleted_at.is_(None),
|
||||
)
|
||||
if parent_type is not None:
|
||||
stmt = stmt.where(ContainerRelation.parent_type == parent_type)
|
||||
if relation_type is not None:
|
||||
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
|
||||
|
||||
stmt = stmt.order_by(ContainerRelation.id.asc())
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def detach_child(
|
||||
session: AsyncSession,
|
||||
parent_type: str,
|
||||
parent_id: int,
|
||||
child_type: str,
|
||||
child_id: int,
|
||||
relation_type: str | None = None,
|
||||
) -> bool:
|
||||
"""Soft-delete a ContainerRelation and emit container.child_detached event."""
|
||||
result = await session.execute(
|
||||
select(ContainerRelation).where(
|
||||
ContainerRelation.parent_type == parent_type,
|
||||
ContainerRelation.parent_id == parent_id,
|
||||
ContainerRelation.child_type == child_type,
|
||||
ContainerRelation.child_id == child_id,
|
||||
ContainerRelation.deleted_at.is_(None),
|
||||
)
|
||||
stmt = select(ContainerRelation).where(
|
||||
ContainerRelation.parent_type == parent_type,
|
||||
ContainerRelation.parent_id == parent_id,
|
||||
ContainerRelation.child_type == child_type,
|
||||
ContainerRelation.child_id == child_id,
|
||||
ContainerRelation.deleted_at.is_(None),
|
||||
)
|
||||
if relation_type is not None:
|
||||
stmt = stmt.where(ContainerRelation.relation_type == relation_type)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
rel = result.scalar_one_or_none()
|
||||
if not rel:
|
||||
return False
|
||||
@@ -153,6 +204,7 @@ async def detach_child(
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
**({"relation_type": rel.relation_type} if rel.relation_type else {}),
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=rel.id,
|
||||
|
||||
@@ -2,769 +2,17 @@
|
||||
Shared s-expression component definitions.
|
||||
|
||||
Loaded at app startup via ``load_shared_components()``. Each component
|
||||
replaces a per-service Jinja fragment template with a single reusable
|
||||
s-expression definition.
|
||||
is defined in an external ``.sexp`` file under ``templates/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .jinja_bridge import register_components
|
||||
import os
|
||||
|
||||
from .jinja_bridge import load_sexp_dir
|
||||
|
||||
|
||||
def load_shared_components() -> None:
|
||||
"""Register all shared s-expression components."""
|
||||
register_components(_LINK_CARD)
|
||||
register_components(_CART_MINI)
|
||||
register_components(_AUTH_MENU)
|
||||
register_components(_ACCOUNT_NAV_ITEM)
|
||||
register_components(_CALENDAR_ENTRY_NAV)
|
||||
register_components(_CALENDAR_LINK_NAV)
|
||||
register_components(_MARKET_LINK_NAV)
|
||||
register_components(_POST_CARD)
|
||||
register_components(_BASE_SHELL)
|
||||
register_components(_ERROR_PAGE)
|
||||
# Phase 6: layout infrastructure
|
||||
register_components(_APP_SHELL)
|
||||
register_components(_APP_LAYOUT)
|
||||
register_components(_OOB_RESPONSE)
|
||||
register_components(_HEADER_ROW)
|
||||
register_components(_MENU_ROW)
|
||||
register_components(_NAV_LINK)
|
||||
register_components(_INFINITE_SCROLL)
|
||||
register_components(_STATUS_PILL)
|
||||
register_components(_SEARCH_MOBILE)
|
||||
register_components(_SEARCH_DESKTOP)
|
||||
register_components(_MOBILE_FILTER)
|
||||
register_components(_ORDER_SUMMARY_CARD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~link-card
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: blog/templates/fragments/link_card.html
|
||||
# market/templates/fragments/link_card.html
|
||||
# events/templates/fragments/link_card.html
|
||||
# federation/templates/fragments/link_card.html
|
||||
# artdag/l1/app/templates/fragments/link_card.html
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")')
|
||||
# sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LINK_CARD = '''
|
||||
(defcomp ~link-card (&key link title image icon subtitle detail data-app)
|
||||
(a :href link
|
||||
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
|
||||
:data-fragment "link-card"
|
||||
:data-app data-app
|
||||
:data-hx-disable true
|
||||
(div :class "flex flex-row items-start gap-3 p-3"
|
||||
(if image
|
||||
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
|
||||
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
|
||||
(i :class icon)))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
|
||||
(when subtitle
|
||||
(div :class "text-xs text-stone-500 mt-0.5" subtitle))
|
||||
(when detail
|
||||
(div :class "text-xs text-stone-400 mt-1" detail))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~cart-mini
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: cart/templates/fragments/cart_mini.html
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~cart-mini :cart-count count :blog-url burl :cart-url curl)',
|
||||
# count=0, burl="https://blog.rose-ash.com", curl="https://cart.rose-ash.com")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CART_MINI = '''
|
||||
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
|
||||
(div :id "cart-mini"
|
||||
:hx-swap-oob oob
|
||||
(if (= cart-count 0)
|
||||
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
|
||||
(a :href blog-url
|
||||
:class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||
(img :src (str blog-url "static/img/logo.jpg")
|
||||
:class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
|
||||
(a :href cart-url
|
||||
:class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
|
||||
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
cart-count)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~auth-menu
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: account/templates/fragments/auth_menu.html
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~auth-menu :user-email email :account-url aurl)',
|
||||
# email="user@example.com", aurl="https://account.rose-ash.com")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUTH_MENU = '''
|
||||
(defcomp ~auth-menu (&key user-email account-url)
|
||||
(<>
|
||||
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
|
||||
(if user-email
|
||||
(a :href account-url
|
||||
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
:data-close-details true
|
||||
(i :class "fa-solid fa-user")
|
||||
(span user-email))
|
||||
(a :href account-url
|
||||
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
:data-close-details true
|
||||
(i :class "fa-solid fa-key")
|
||||
(span "sign in or register"))))
|
||||
(span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
|
||||
(if user-email
|
||||
(a :href account-url :data-close-details true
|
||||
(i :class "fa-solid fa-user")
|
||||
(span user-email))
|
||||
(a :href account-url
|
||||
(i :class "fa-solid fa-key")
|
||||
(span "sign in or register"))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~account-nav-item
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: hardcoded HTML in cart/bp/fragments/routes.py
|
||||
# and orders/bp/fragments/routes.py
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~account-nav-item :href url :label "orders")', url=cart_url("/orders/"))
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ACCOUNT_NAV_ITEM = '''
|
||||
(defcomp ~account-nav-item (&key href label)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
:data-hx-disable true
|
||||
label)))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~calendar-entry-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: events/templates/fragments/container_nav_entries.html (per-entry)
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~calendar-entry-nav :href url :name name :date-str "Jan 15, 2026 at 14:00")',
|
||||
# url="/events/...", name="Workshop")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CALENDAR_ENTRY_NAV = '''
|
||||
(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
|
||||
(a :href href :class nav-class
|
||||
(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))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~calendar-link-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: events/templates/fragments/container_nav_calendars.html (per-calendar)
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~calendar-link-nav :href url :name "My Calendar")', url="/events/...")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CALENDAR_LINK_NAV = '''
|
||||
(defcomp ~calendar-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~market-link-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: market/templates/fragments/container_nav_markets.html (per-market)
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~market-link-nav :href url :name "Farm Shop")', url="/market/...")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MARKET_LINK_NAV = '''
|
||||
(defcomp ~market-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(i :class "fa fa-shopping-bag" :aria-hidden "true")
|
||||
(div name)))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~post-card
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: blog/templates/_types/blog/_card.html
|
||||
#
|
||||
# A simplified s-expression version of the blog listing card.
|
||||
# The full card is complex (like buttons, card widgets, at_bar with tag/author
|
||||
# filtering). This component covers the core card structure; the at_bar and
|
||||
# card_widgets are passed as pre-rendered HTML via :at-bar-html and
|
||||
# :widgets-html kwargs for incremental migration.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~post-card :title t :slug s :href h ...)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POST_CARD = '''
|
||||
(defcomp ~post-card (&key title slug href feature-image excerpt
|
||||
status published-at updated-at publish-requested
|
||||
hx-select like-html widgets-html at-bar-html)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when like-html (raw! like-html))
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select hx-select
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(cond
|
||||
(= status "draft")
|
||||
(begin
|
||||
(div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||
(when updated-at
|
||||
(p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
|
||||
published-at
|
||||
(p :class "text-sm text-stone-500" (str "Published: " published-at))))
|
||||
(when feature-image
|
||||
(div :class "mb-4"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt
|
||||
(p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
(when widgets-html (raw! widgets-html))
|
||||
(when at-bar-html (raw! at-bar-html))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~base-shell — full HTML document wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: shared/browser/templates/_types/root/index.html (the <html> shell)
|
||||
#
|
||||
# Usage: For full-page s-expression rendering (Step 4 proof of concept)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BASE_SHELL = '''
|
||||
(defcomp ~base-shell (&key title asset-url &rest children)
|
||||
(<>
|
||||
(raw! "<!doctype html>")
|
||||
(html :lang "en"
|
||||
(head
|
||||
(meta :charset "utf-8")
|
||||
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||
(title title)
|
||||
(style
|
||||
"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" :href (str asset-url "/fontawesome/css/all.min.css")))
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
children))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~error-page — styled error page
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: shared/browser/templates/_types/root/exceptions/_.html
|
||||
# + base.html + 404/message.html + 404/img.html
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~error-page :title "Not Found" :message "NOT FOUND" :image img-url :asset-url aurl)',
|
||||
# img_url="/static/errors/404.gif", aurl="/static")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ERROR_PAGE = '''
|
||||
(defcomp ~error-page (&key title message image asset-url)
|
||||
(~base-shell :title title :asset-url asset-url
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
|
||||
(div message))
|
||||
(when image
|
||||
(div :class "flex justify-center"
|
||||
(img :src image :width "300" :height "300"))))))
|
||||
'''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Phase 6: Layout infrastructure components
|
||||
# ===================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~app-shell — full HTML document with all required CSS/JS assets
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/index.html <html><head>...<body> shell
|
||||
#
|
||||
# This includes htmx, hyperscript, tailwind, fontawesome, prism, and
|
||||
# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page
|
||||
# shell; ``~app-shell`` is for real app pages.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APP_SHELL = r'''
|
||||
(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
|
||||
(<>
|
||||
(raw! "<!doctype html>")
|
||||
(html :lang "en"
|
||||
(head
|
||||
(meta :charset "utf-8")
|
||||
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||
(meta :name "robots" :content "index,follow")
|
||||
(meta :name "theme-color" :content "#ffffff")
|
||||
(title title)
|
||||
(when meta-html (raw! meta-html))
|
||||
(style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
|
||||
(script :src "https://unpkg.com/htmx.org@2.0.8")
|
||||
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
|
||||
(script :src "https://cdn.tailwindcss.com")
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
|
||||
(link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
|
||||
(script :src "https://unpkg.com/prismjs/prism.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
||||
(script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
|
||||
(script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
|
||||
(style
|
||||
"details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
|
||||
"details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
|
||||
"@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}"
|
||||
"img{max-width:100%;height:auto}"
|
||||
".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}"
|
||||
".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
|
||||
".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
|
||||
"details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}"
|
||||
".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
|
||||
".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
(raw! body-html)
|
||||
(when body-end-html (raw! body-end-html))
|
||||
(script :src (str asset-url "/scripts/body.js"))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~app-layout — page body layout (header + filter + aside + main-panel)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/index.html body structure
|
||||
#
|
||||
# The header uses a <details>/<summary> pattern for mobile menu toggle.
|
||||
# All content sections are passed as pre-rendered HTML strings.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~app-layout :title t :asset-url a :header-rows-html h
|
||||
# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APP_LAYOUT = r'''
|
||||
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
|
||||
header-rows-html menu-html
|
||||
filter-html aside-html content-html
|
||||
body-end-html)
|
||||
(let* ((colour (or menu-colour "sky")))
|
||||
(~app-shell :title (or title "Rose Ash") :asset-url asset-url
|
||||
:meta-html meta-html :body-end-html body-end-html
|
||||
:body-html (str
|
||||
"<div class=\"max-w-screen-2xl mx-auto py-1 px-1\">"
|
||||
"<div class=\"w-full\">"
|
||||
"<details class=\"group/root p-2\" data-toggle-group=\"mobile-panels\">"
|
||||
"<summary>"
|
||||
"<header class=\"z-50\">"
|
||||
"<div id=\"root-header-summary\" class=\"flex items-start gap-2 p-1 bg-" colour "-500\">"
|
||||
"<div class=\"flex flex-col w-full items-center\">"
|
||||
header-rows-html
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</header>"
|
||||
"</summary>"
|
||||
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
|
||||
(or menu-html "")
|
||||
"</div>"
|
||||
"</details>"
|
||||
"</div>"
|
||||
"<div id=\"filter\">"
|
||||
(or filter-html "")
|
||||
"</div>"
|
||||
"<main id=\"root-panel\" class=\"max-w-full\">"
|
||||
"<div class=\"md:min-h-0\">"
|
||||
"<div class=\"flex flex-row md:h-full md:min-h-0\">"
|
||||
"<aside id=\"aside\" class=\"hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3\">"
|
||||
(or aside-html "")
|
||||
"</aside>"
|
||||
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
|
||||
(or content-html "")
|
||||
"<div class=\"pb-8\"></div>"
|
||||
"</section>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</main>"
|
||||
"</div>"))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~oob-response — HTMX OOB multi-target swap wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: oob_elements.html base template
|
||||
#
|
||||
# Each named region gets hx-swap-oob="outerHTML" on its wrapper div.
|
||||
# The oobs-html param contains any extra OOB elements (header row swaps).
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah
|
||||
# :menu-html mh :content-html ch)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OOB_RESPONSE = '''
|
||||
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
||||
(<>
|
||||
(when oobs-html (raw! oobs-html))
|
||||
(div :id "filter" :hx-swap-oob "outerHTML"
|
||||
(when filter-html (raw! filter-html)))
|
||||
(aside :id "aside" :hx-swap-oob "outerHTML"
|
||||
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
(when aside-html (raw! aside-html)))
|
||||
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu-html (raw! menu-html)))
|
||||
(section :id "main-panel"
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content-html (raw! content-html)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/header/_header.html header_row macro
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st
|
||||
# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np
|
||||
# :settings-url su :is-admin ia)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HEADER_ROW = '''
|
||||
(defcomp ~header-row (&key cart-mini-html blog-url site-title
|
||||
nav-tree-html auth-menu-html nav-panel-html
|
||||
settings-url is-admin oob hamburger-html)
|
||||
(<>
|
||||
(div :id "root-row"
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
|
||||
(div :class "w-full flex flex-row items-top"
|
||||
(when cart-mini-html (raw! cart-mini-html))
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start"
|
||||
(h1 (or site-title ""))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree-html (raw! nav-tree-html))
|
||||
(when auth-menu-html (raw! auth-menu-html))
|
||||
(when nav-panel-html (raw! nav-panel-html))
|
||||
(when (and is-admin settings-url)
|
||||
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
(when hamburger-html (raw! hamburger-html))))
|
||||
(div :class "block md:hidden text-md font-bold"
|
||||
(when auth-menu-html (raw! auth-menu-html)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~menu-row — section header row (wraps in colored bar)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: macros/links.html menu_row macro
|
||||
#
|
||||
# Each nested header row gets a progressively lighter background.
|
||||
# The route handler passes the level (0-based depth after root).
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky"
|
||||
# :link-href url :link-label "account" :icon "fa-solid fa-user"
|
||||
# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MENU_ROW = '''
|
||||
(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
|
||||
hx-select nav-html child-id child-html oob)
|
||||
(let* ((c (or colour "sky"))
|
||||
(lv (or level 1))
|
||||
(shade (str (- 500 (* lv 100)))))
|
||||
(<>
|
||||
(div :id id
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
|
||||
(div :class "relative nav-group"
|
||||
(a :href link-href
|
||||
:hx-get link-href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(if link-label-html (raw! link-label-html)
|
||||
(when link-label (div link-label)))))
|
||||
(when nav-html
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(raw! nav-html))))
|
||||
(when child-id
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child-html (raw! child-html)))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~nav-link — HTMX navigation link (replaces macros/links.html link macro)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NAV_LINK = '''
|
||||
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class (or aclass
|
||||
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
(or select-colours "")))
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(when label (span label)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~infinite-scroll — pagination sentinel for table-based lists
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: sentinel pattern in _rows.html templates
|
||||
#
|
||||
# For table rows (orders, etc.): renders <tr> with intersection observer.
|
||||
# Uses hyperscript for retry with exponential backoff.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~infinite-scroll :url next-url :page p :total-pages tp
|
||||
# :id-prefix "orders" :colspan 5)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INFINITE_SCROLL = r'''
|
||||
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
|
||||
(if (< page total-pages)
|
||||
(raw! (str
|
||||
"<tr id=\"" id-prefix "-sentinel-" page "\""
|
||||
" hx-get=\"" url "\""
|
||||
" hx-trigger=\"intersect once delay:250ms, sentinel:retry\""
|
||||
" hx-swap=\"outerHTML\""
|
||||
" _=\""
|
||||
"init "
|
||||
"if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end "
|
||||
"on sentinel:retry "
|
||||
"remove .hidden from .js-loading in me "
|
||||
"add .hidden to .js-neterr in me "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"trigger htmx:consume on me "
|
||||
"call htmx.trigger(me, 'intersect') "
|
||||
"end "
|
||||
"def backoff() "
|
||||
"add .hidden to .js-loading in me "
|
||||
"remove .hidden from .js-neterr in me "
|
||||
"set myMs to Number(me.dataset.retryMs) "
|
||||
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
|
||||
"js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs) "
|
||||
"end "
|
||||
"on htmx:beforeRequest "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"end "
|
||||
"on htmx:afterSwap set me.dataset.retryMs to 1000 end "
|
||||
"on htmx:sendError call backoff() "
|
||||
"on htmx:responseError call backoff() "
|
||||
"on htmx:timeout call backoff()"
|
||||
"\""
|
||||
" role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
|
||||
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
|
||||
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading… " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
|
||||
"</div>"
|
||||
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading… " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden inset-0 grid place-items-center p-4\"></div>"
|
||||
"</div>"
|
||||
"</td></tr>"))
|
||||
(raw! (str
|
||||
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~status-pill — colored status indicator
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: inline Jinja status pill patterns across templates
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~status-pill :status s :size "sm")', status="paid")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATUS_PILL = '''
|
||||
(defcomp ~status-pill (&key status size)
|
||||
(let* ((s (or status "pending"))
|
||||
(lower (lower s))
|
||||
(sz (or size "xs"))
|
||||
(colours (cond
|
||||
(= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
(or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
|
||||
(= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
|
||||
(= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
true "border-stone-300 bg-stone-50 text-stone-700")))
|
||||
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
|
||||
s)))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~search-mobile — mobile search input with htmx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEARCH_MOBILE = '''
|
||||
(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
|
||||
(div :id "search-mobile-wrapper"
|
||||
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
(input :id "search-mobile"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-mobile
|
||||
:hx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-mobile" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~search-desktop — desktop search input with htmx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEARCH_DESKTOP = '''
|
||||
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
|
||||
(div :id "search-desktop-wrapper"
|
||||
:class "flex flex-row gap-2 items-center"
|
||||
(input :id "search-desktop"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-desktop
|
||||
:hx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-desktop" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~mobile-filter — mobile filter details/summary panel
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: blog/templates/_types/blog/mobile/_filter/summary.html
|
||||
# + macros/layout.html details/filter_summary
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~mobile-filter :filter-summary-html fsh :action-buttons-html abh
|
||||
# :filter-details-html fdh)',
|
||||
# fsh="...", abh="...", fdh="...")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MOBILE_FILTER = '''
|
||||
(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
|
||||
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
|
||||
(summary :class "bg-white/90"
|
||||
(div :class "flex flex-row items-start"
|
||||
(div
|
||||
(div :class "md:hidden mx-2 bg-stone-200 rounded"
|
||||
(span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start"
|
||||
(i :class "fa-solid fa-filter"))
|
||||
(span
|
||||
(svg :aria-hidden "true" :viewBox "0 0 24 24"
|
||||
:class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start"
|
||||
(path :d "M6 9l6 6 6-6" :fill "currentColor")))))
|
||||
(div :id "filter-summary-mobile"
|
||||
:class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
|
||||
(div :class "flex flex-col items-start gap-2"
|
||||
(raw! filter-summary-html)))))
|
||||
(raw! (or action-buttons-html ""))
|
||||
(div :id "filter-details-mobile" :style "display:contents"
|
||||
(raw! (or filter-details-html "")))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~order-summary-card — reusable order summary card
|
||||
# ---------------------------------------------------------------------------
|
||||
_ORDER_SUMMARY_CARD = r'''
|
||||
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
|
||||
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
|
||||
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
|
||||
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
|
||||
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
|
||||
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
|
||||
(p (span :class "font-medium" "Total:") " "
|
||||
(if total-amount
|
||||
(str (or currency "GBP") " " total-amount)
|
||||
"\u2013"))))
|
||||
'''
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
load_sexp_dir(templates_dir)
|
||||
|
||||
@@ -13,6 +13,7 @@ Special forms:
|
||||
(lambda (params...) body) or (fn (params...) body)
|
||||
(define name value)
|
||||
(defcomp ~name (&key param...) body)
|
||||
(defrelation :name :from "type" :to "type" :cardinality :card ...)
|
||||
(begin expr...)
|
||||
(quote expr)
|
||||
(do expr...) — alias for begin
|
||||
@@ -32,7 +33,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Keyword, Lambda, NIL, Symbol
|
||||
from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol
|
||||
from .primitives import _PRIMITIVES
|
||||
|
||||
|
||||
@@ -429,6 +430,75 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
_VALID_CARDINALITIES = {"one-to-one", "one-to-many", "many-to-many"}
|
||||
_VALID_NAV = {"submenu", "tab", "badge", "inline", "hidden"}
|
||||
|
||||
|
||||
def _sf_defrelation(expr: list, env: dict) -> RelationDef:
|
||||
"""``(defrelation :name :from "t" :to "t" :cardinality :card ...)``"""
|
||||
if len(expr) < 2:
|
||||
raise EvalError("defrelation requires a name")
|
||||
|
||||
name_kw = expr[1]
|
||||
if not isinstance(name_kw, Keyword):
|
||||
raise EvalError(f"defrelation name must be a keyword, got {type(name_kw).__name__}")
|
||||
rel_name = name_kw.name
|
||||
|
||||
# Parse keyword args from remaining elements
|
||||
kwargs: dict[str, str | None] = {}
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
key = expr[i]
|
||||
if isinstance(key, Keyword):
|
||||
if i + 1 < len(expr):
|
||||
val = expr[i + 1]
|
||||
if isinstance(val, Keyword):
|
||||
kwargs[key.name] = val.name
|
||||
else:
|
||||
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val
|
||||
i += 2
|
||||
else:
|
||||
kwargs[key.name] = None
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
for field in ("from", "to", "cardinality"):
|
||||
if field not in kwargs:
|
||||
raise EvalError(f"defrelation {rel_name} missing required :{field}")
|
||||
|
||||
card = kwargs["cardinality"]
|
||||
if card not in _VALID_CARDINALITIES:
|
||||
raise EvalError(
|
||||
f"defrelation {rel_name}: invalid cardinality {card!r}, "
|
||||
f"expected one of {_VALID_CARDINALITIES}"
|
||||
)
|
||||
|
||||
nav = kwargs.get("nav", "hidden")
|
||||
if nav not in _VALID_NAV:
|
||||
raise EvalError(
|
||||
f"defrelation {rel_name}: invalid nav {nav!r}, "
|
||||
f"expected one of {_VALID_NAV}"
|
||||
)
|
||||
|
||||
defn = RelationDef(
|
||||
name=rel_name,
|
||||
from_type=kwargs["from"],
|
||||
to_type=kwargs["to"],
|
||||
cardinality=card,
|
||||
inverse=kwargs.get("inverse"),
|
||||
nav=nav,
|
||||
nav_icon=kwargs.get("nav-icon"),
|
||||
nav_label=kwargs.get("nav-label"),
|
||||
)
|
||||
|
||||
from .relations import register_relation
|
||||
register_relation(defn)
|
||||
|
||||
env[f"relation:{rel_name}"] = defn
|
||||
return defn
|
||||
|
||||
|
||||
_SPECIAL_FORMS: dict[str, Any] = {
|
||||
"if": _sf_if,
|
||||
"when": _sf_when,
|
||||
@@ -442,6 +512,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
||||
"fn": _sf_lambda,
|
||||
"define": _sf_define,
|
||||
"defcomp": _sf_defcomp,
|
||||
"defrelation": _sf_defrelation,
|
||||
"begin": _sf_begin,
|
||||
"do": _sf_begin,
|
||||
"quote": _sf_quote,
|
||||
|
||||
@@ -74,6 +74,12 @@ HTML_TAGS = frozenset({
|
||||
"img", "picture", "source", "iframe", "embed", "object", "param",
|
||||
"video", "audio", "track", "canvas", "map", "area",
|
||||
"svg", "math",
|
||||
# SVG child elements
|
||||
"path", "circle", "ellipse", "line", "polygon", "polyline", "rect",
|
||||
"g", "defs", "use", "text", "tspan", "clipPath", "mask",
|
||||
"linearGradient", "radialGradient", "stop", "filter",
|
||||
"feGaussianBlur", "feOffset", "feMerge", "feMergeNode",
|
||||
"animate", "animateTransform",
|
||||
# Table
|
||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
|
||||
"caption", "colgroup", "col",
|
||||
|
||||
@@ -20,6 +20,8 @@ Setup::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import NIL, Symbol
|
||||
@@ -41,6 +43,13 @@ def get_component_env() -> dict[str, Any]:
|
||||
return _COMPONENT_ENV
|
||||
|
||||
|
||||
def load_sexp_dir(directory: str) -> None:
|
||||
"""Load all .sexp files from a directory and register components."""
|
||||
for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))):
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
|
||||
|
||||
def register_components(sexp_source: str) -> None:
|
||||
"""Parse and evaluate s-expression component definitions into the
|
||||
shared environment.
|
||||
|
||||
@@ -46,7 +46,7 @@ class Tokenizer:
|
||||
COMMENT = re.compile(r";[^\n]*")
|
||||
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_-]*")
|
||||
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>-]*")
|
||||
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
|
||||
# <> for the fragment symbol, and & for &key/&rest.
|
||||
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
|
||||
|
||||
101
shared/sexp/relations.py
Normal file
101
shared/sexp/relations.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Relation registry — declarative entity relationship definitions.
|
||||
|
||||
Relations are defined as s-expressions using ``defrelation`` and stored
|
||||
in a global registry. All services load the same definitions at startup
|
||||
via ``load_relation_registry()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sexp.types import RelationDef
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RELATION_REGISTRY: dict[str, RelationDef] = {}
|
||||
|
||||
|
||||
def register_relation(defn: RelationDef) -> None:
|
||||
"""Add a RelationDef to the global registry."""
|
||||
_RELATION_REGISTRY[defn.name] = defn
|
||||
|
||||
|
||||
def get_relation(name: str) -> RelationDef | None:
|
||||
"""Look up a relation by name (e.g. ``"page->market"``)."""
|
||||
return _RELATION_REGISTRY.get(name)
|
||||
|
||||
|
||||
def relations_from(entity_type: str) -> list[RelationDef]:
|
||||
"""All relations where *entity_type* is the ``from`` side."""
|
||||
return [d for d in _RELATION_REGISTRY.values() if d.from_type == entity_type]
|
||||
|
||||
|
||||
def relations_to(entity_type: str) -> list[RelationDef]:
|
||||
"""All relations where *entity_type* is the ``to`` side."""
|
||||
return [d for d in _RELATION_REGISTRY.values() if d.to_type == entity_type]
|
||||
|
||||
|
||||
def all_relations() -> list[RelationDef]:
|
||||
"""Return all registered relations."""
|
||||
return list(_RELATION_REGISTRY.values())
|
||||
|
||||
|
||||
def clear_registry() -> None:
|
||||
"""Clear all registered relations (for testing)."""
|
||||
_RELATION_REGISTRY.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in relation definitions (s-expression source)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BUILTIN_RELATIONS = '''
|
||||
(begin
|
||||
|
||||
(defrelation :page->market
|
||||
:from "page"
|
||||
:to "market"
|
||||
:cardinality :one-to-many
|
||||
:inverse :market->page
|
||||
:nav :submenu
|
||||
:nav-icon "fa fa-shopping-bag"
|
||||
:nav-label "markets")
|
||||
|
||||
(defrelation :page->calendar
|
||||
:from "page"
|
||||
:to "calendar"
|
||||
:cardinality :one-to-many
|
||||
:inverse :calendar->page
|
||||
:nav :submenu
|
||||
:nav-icon "fa fa-calendar"
|
||||
:nav-label "calendars")
|
||||
|
||||
(defrelation :post->calendar_entry
|
||||
:from "post"
|
||||
:to "calendar_entry"
|
||||
:cardinality :many-to-many
|
||||
:inverse :calendar_entry->post
|
||||
:nav :inline
|
||||
:nav-icon "fa fa-file-alt"
|
||||
:nav-label "events")
|
||||
|
||||
(defrelation :page->menu_node
|
||||
:from "page"
|
||||
:to "menu_node"
|
||||
:cardinality :one-to-one
|
||||
:nav :hidden)
|
||||
|
||||
)
|
||||
'''
|
||||
|
||||
|
||||
def load_relation_registry() -> None:
|
||||
"""Parse built-in defrelation s-expressions and populate the registry."""
|
||||
from shared.sexp.evaluator import evaluate
|
||||
from shared.sexp.parser import parse
|
||||
|
||||
tree = parse(_BUILTIN_RELATIONS)
|
||||
evaluate(tree)
|
||||
44
shared/sexp/templates/cards.sexp
Normal file
44
shared/sexp/templates/cards.sexp
Normal file
@@ -0,0 +1,44 @@
|
||||
(defcomp ~post-card (&key title slug href feature-image excerpt
|
||||
status published-at updated-at publish-requested
|
||||
hx-select like-html widgets-html at-bar-html)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when like-html (raw! like-html))
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select hx-select
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(cond
|
||||
(= status "draft")
|
||||
(begin
|
||||
(div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||
(when updated-at
|
||||
(p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
|
||||
published-at
|
||||
(p :class "text-sm text-stone-500" (str "Published: " published-at))))
|
||||
(when feature-image
|
||||
(div :class "mb-4"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt
|
||||
(p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
(when widgets-html (raw! widgets-html))
|
||||
(when at-bar-html (raw! at-bar-html))))
|
||||
|
||||
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
|
||||
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
|
||||
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
|
||||
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
|
||||
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
|
||||
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
|
||||
(p (span :class "font-medium" "Total:") " "
|
||||
(if total-amount
|
||||
(str (or currency "GBP") " " total-amount)
|
||||
"\u2013"))))
|
||||
126
shared/sexp/templates/controls.sexp
Normal file
126
shared/sexp/templates/controls.sexp
Normal file
@@ -0,0 +1,126 @@
|
||||
(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
|
||||
(div :id "search-mobile-wrapper"
|
||||
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
(input :id "search-mobile"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-mobile
|
||||
:hx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-mobile" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
|
||||
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
|
||||
(div :id "search-desktop-wrapper"
|
||||
:class "flex flex-row gap-2 items-center"
|
||||
(input :id "search-desktop"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-desktop
|
||||
:hx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-desktop" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
|
||||
(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
|
||||
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
|
||||
(summary :class "bg-white/90"
|
||||
(div :class "flex flex-row items-start"
|
||||
(div
|
||||
(div :class "md:hidden mx-2 bg-stone-200 rounded"
|
||||
(span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start"
|
||||
(i :class "fa-solid fa-filter"))
|
||||
(span
|
||||
(svg :aria-hidden "true" :viewBox "0 0 24 24"
|
||||
:class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start"
|
||||
(path :d "M6 9l6 6 6-6" :fill "currentColor")))))
|
||||
(div :id "filter-summary-mobile"
|
||||
:class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
|
||||
(div :class "flex flex-col items-start gap-2"
|
||||
(raw! filter-summary-html)))))
|
||||
(raw! (or action-buttons-html ""))
|
||||
(div :id "filter-details-mobile" :style "display:contents"
|
||||
(raw! (or filter-details-html "")))))
|
||||
|
||||
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
|
||||
(if (< page total-pages)
|
||||
(raw! (str
|
||||
"<tr id=\"" id-prefix "-sentinel-" page "\""
|
||||
" hx-get=\"" url "\""
|
||||
" hx-trigger=\"intersect once delay:250ms, sentinel:retry\""
|
||||
" hx-swap=\"outerHTML\""
|
||||
" _=\""
|
||||
"init "
|
||||
"if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end "
|
||||
"on sentinel:retry "
|
||||
"remove .hidden from .js-loading in me "
|
||||
"add .hidden to .js-neterr in me "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"trigger htmx:consume on me "
|
||||
"call htmx.trigger(me, 'intersect') "
|
||||
"end "
|
||||
"def backoff() "
|
||||
"add .hidden to .js-loading in me "
|
||||
"remove .hidden from .js-neterr in me "
|
||||
"set myMs to Number(me.dataset.retryMs) "
|
||||
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
|
||||
"js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs) "
|
||||
"end "
|
||||
"on htmx:beforeRequest "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"end "
|
||||
"on htmx:afterSwap set me.dataset.retryMs to 1000 end "
|
||||
"on htmx:sendError call backoff() "
|
||||
"on htmx:responseError call backoff() "
|
||||
"on htmx:timeout call backoff()"
|
||||
"\""
|
||||
" role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
|
||||
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
|
||||
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
|
||||
"</div>"
|
||||
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden inset-0 grid place-items-center p-4\"></div>"
|
||||
"</div>"
|
||||
"</td></tr>"))
|
||||
(raw! (str
|
||||
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
|
||||
|
||||
(defcomp ~status-pill (&key status size)
|
||||
(let* ((s (or status "pending"))
|
||||
(lower (lower s))
|
||||
(sz (or size "xs"))
|
||||
(colours (cond
|
||||
(= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
(or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
|
||||
(= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
|
||||
(= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
true "border-stone-300 bg-stone-50 text-stone-700")))
|
||||
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
|
||||
s)))
|
||||
62
shared/sexp/templates/fragments.sexp
Normal file
62
shared/sexp/templates/fragments.sexp
Normal file
@@ -0,0 +1,62 @@
|
||||
(defcomp ~link-card (&key link title image icon subtitle detail data-app)
|
||||
(a :href link
|
||||
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
|
||||
:data-fragment "link-card"
|
||||
:data-app data-app
|
||||
:data-hx-disable true
|
||||
(div :class "flex flex-row items-start gap-3 p-3"
|
||||
(if image
|
||||
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
|
||||
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
|
||||
(i :class icon)))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
|
||||
(when subtitle
|
||||
(div :class "text-xs text-stone-500 mt-0.5" subtitle))
|
||||
(when detail
|
||||
(div :class "text-xs text-stone-400 mt-1" detail))))))
|
||||
|
||||
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
|
||||
(div :id "cart-mini"
|
||||
:hx-swap-oob oob
|
||||
(if (= cart-count 0)
|
||||
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
|
||||
(a :href blog-url
|
||||
:class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||
(img :src (str blog-url "static/img/logo.jpg")
|
||||
:class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
|
||||
(a :href cart-url
|
||||
:class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
|
||||
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
cart-count)))))
|
||||
|
||||
(defcomp ~auth-menu (&key user-email account-url)
|
||||
(<>
|
||||
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
|
||||
(if user-email
|
||||
(a :href account-url
|
||||
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
:data-close-details true
|
||||
(i :class "fa-solid fa-user")
|
||||
(span user-email))
|
||||
(a :href account-url
|
||||
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||
:data-close-details true
|
||||
(i :class "fa-solid fa-key")
|
||||
(span "sign in or register"))))
|
||||
(span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
|
||||
(if user-email
|
||||
(a :href account-url :data-close-details true
|
||||
(i :class "fa-solid fa-user")
|
||||
(span user-email))
|
||||
(a :href account-url
|
||||
(i :class "fa-solid fa-key")
|
||||
(span "sign in or register"))))))
|
||||
|
||||
(defcomp ~account-nav-item (&key href label)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
:data-hx-disable true
|
||||
label)))
|
||||
164
shared/sexp/templates/layout.sexp
Normal file
164
shared/sexp/templates/layout.sexp
Normal file
@@ -0,0 +1,164 @@
|
||||
(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
|
||||
(<>
|
||||
(raw! "<!doctype html>")
|
||||
(html :lang "en"
|
||||
(head
|
||||
(meta :charset "utf-8")
|
||||
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||
(meta :name "robots" :content "index,follow")
|
||||
(meta :name "theme-color" :content "#ffffff")
|
||||
(title title)
|
||||
(when meta-html (raw! meta-html))
|
||||
(style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
|
||||
(script :src "https://unpkg.com/htmx.org@2.0.8")
|
||||
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
|
||||
(script :src "https://cdn.tailwindcss.com")
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
|
||||
(link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
|
||||
(script :src "https://unpkg.com/prismjs/prism.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
||||
(script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
|
||||
(script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
|
||||
(style
|
||||
"details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
|
||||
"details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
|
||||
"@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}"
|
||||
"img{max-width:100%;height:auto}"
|
||||
".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}"
|
||||
".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
|
||||
".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
|
||||
"details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}"
|
||||
".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
|
||||
".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
(raw! body-html)
|
||||
(when body-end-html (raw! body-end-html))
|
||||
(script :src (str asset-url "/scripts/body.js"))))))
|
||||
|
||||
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
|
||||
header-rows-html menu-html
|
||||
filter-html aside-html content-html
|
||||
body-end-html)
|
||||
(let* ((colour (or menu-colour "sky")))
|
||||
(~app-shell :title (or title "Rose Ash") :asset-url asset-url
|
||||
:meta-html meta-html :body-end-html body-end-html
|
||||
:body-html (str
|
||||
"<div class=\"max-w-screen-2xl mx-auto py-1 px-1\">"
|
||||
"<div class=\"w-full\">"
|
||||
"<details class=\"group/root p-2\" data-toggle-group=\"mobile-panels\">"
|
||||
"<summary>"
|
||||
"<header class=\"z-50\">"
|
||||
"<div id=\"root-header-summary\" class=\"flex items-start gap-2 p-1 bg-" colour "-500\">"
|
||||
"<div class=\"flex flex-col w-full items-center\">"
|
||||
header-rows-html
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</header>"
|
||||
"</summary>"
|
||||
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
|
||||
(or menu-html "")
|
||||
"</div>"
|
||||
"</details>"
|
||||
"</div>"
|
||||
"<div id=\"filter\">"
|
||||
(or filter-html "")
|
||||
"</div>"
|
||||
"<main id=\"root-panel\" class=\"max-w-full\">"
|
||||
"<div class=\"md:min-h-0\">"
|
||||
"<div class=\"flex flex-row md:h-full md:min-h-0\">"
|
||||
"<aside id=\"aside\" class=\"hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3\">"
|
||||
(or aside-html "")
|
||||
"</aside>"
|
||||
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
|
||||
(or content-html "")
|
||||
"<div class=\"pb-8\"></div>"
|
||||
"</section>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</main>"
|
||||
"</div>"))))
|
||||
|
||||
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
||||
(<>
|
||||
(when oobs-html (raw! oobs-html))
|
||||
(div :id "filter" :hx-swap-oob "outerHTML"
|
||||
(when filter-html (raw! filter-html)))
|
||||
(aside :id "aside" :hx-swap-oob "outerHTML"
|
||||
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
(when aside-html (raw! aside-html)))
|
||||
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu-html (raw! menu-html)))
|
||||
(section :id "main-panel"
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content-html (raw! content-html)))))
|
||||
|
||||
(defcomp ~header-row (&key cart-mini-html blog-url site-title
|
||||
nav-tree-html auth-menu-html nav-panel-html
|
||||
settings-url is-admin oob hamburger-html)
|
||||
(<>
|
||||
(div :id "root-row"
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
|
||||
(div :class "w-full flex flex-row items-top"
|
||||
(when cart-mini-html (raw! cart-mini-html))
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start"
|
||||
(h1 (or site-title ""))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree-html (raw! nav-tree-html))
|
||||
(when auth-menu-html (raw! auth-menu-html))
|
||||
(when nav-panel-html (raw! nav-panel-html))
|
||||
(when (and is-admin settings-url)
|
||||
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
(when hamburger-html (raw! hamburger-html))))
|
||||
(div :class "block md:hidden text-md font-bold"
|
||||
(when auth-menu-html (raw! auth-menu-html)))))
|
||||
|
||||
(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
|
||||
hx-select nav-html child-id child-html oob)
|
||||
(let* ((c (or colour "sky"))
|
||||
(lv (or level 1))
|
||||
(shade (str (- 500 (* lv 100)))))
|
||||
(<>
|
||||
(div :id id
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
|
||||
(div :class "relative nav-group"
|
||||
(a :href link-href
|
||||
:hx-get link-href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(if link-label-html (raw! link-label-html)
|
||||
(when link-label (div link-label)))))
|
||||
(when nav-html
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(raw! nav-html))))
|
||||
(when child-id
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child-html (raw! child-html)))))))
|
||||
|
||||
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class (or aclass
|
||||
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
(or select-colours "")))
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(when label (span label)))))
|
||||
24
shared/sexp/templates/navigation.sexp
Normal file
24
shared/sexp/templates/navigation.sexp
Normal file
@@ -0,0 +1,24 @@
|
||||
(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
|
||||
(a :href href :class nav-class
|
||||
(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 ~calendar-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~market-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(i :class "fa fa-shopping-bag" :aria-hidden "true")
|
||||
(div 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")
|
||||
(when icon
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0"
|
||||
(i :class icon :aria-hidden "true")))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name))))
|
||||
25
shared/sexp/templates/pages.sexp
Normal file
25
shared/sexp/templates/pages.sexp
Normal file
@@ -0,0 +1,25 @@
|
||||
(defcomp ~base-shell (&key title asset-url &rest children)
|
||||
(<>
|
||||
(raw! "<!doctype html>")
|
||||
(html :lang "en"
|
||||
(head
|
||||
(meta :charset "utf-8")
|
||||
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||
(title title)
|
||||
(style
|
||||
"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" :href (str asset-url "/fontawesome/css/all.min.css")))
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
children))))
|
||||
|
||||
(defcomp ~error-page (&key title message image asset-url)
|
||||
(~base-shell :title title :asset-url asset-url
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4"
|
||||
(div message))
|
||||
(when image
|
||||
(div :class "flex justify-center"
|
||||
(img :src image :width "300" :height "300"))))))
|
||||
15
shared/sexp/templates/relations.sexp
Normal file
15
shared/sexp/templates/relations.sexp
Normal file
@@ -0,0 +1,15 @@
|
||||
(defcomp ~relation-attach (&key create-url label icon)
|
||||
(a :href create-url
|
||||
:hx-get create-url
|
||||
:hx-target "#main-panel"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
|
||||
(when icon (i :class icon))
|
||||
(span (or label "Add"))))
|
||||
|
||||
(defcomp ~relation-detach (&key detach-url name)
|
||||
(button :hx-delete detach-url
|
||||
:hx-confirm (str "Remove " (or name "this item") "?")
|
||||
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
|
||||
(i :class "fa fa-times" :aria-hidden "true")))
|
||||
@@ -264,6 +264,80 @@ class TestErrorPage:
|
||||
assert "<img" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationNav:
|
||||
def test_renders_link(self):
|
||||
html = sexp(
|
||||
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
|
||||
)
|
||||
assert 'href="/market/farm/"' in html
|
||||
assert "Farm Shop" in html
|
||||
assert "fa fa-shopping-bag" in html
|
||||
|
||||
def test_no_icon(self):
|
||||
html = sexp(
|
||||
'(~relation-nav :href "/cal/" :name "Events")',
|
||||
)
|
||||
assert 'href="/cal/"' in html
|
||||
assert "Events" in html
|
||||
assert "fa " not in html
|
||||
|
||||
def test_custom_nav_class(self):
|
||||
html = sexp(
|
||||
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
|
||||
**{"nav-class": "custom-class"},
|
||||
)
|
||||
assert 'class="custom-class"' in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-attach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationAttach:
|
||||
def test_renders_button(self):
|
||||
html = sexp(
|
||||
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
|
||||
**{"create-url": "/market/create/"},
|
||||
)
|
||||
assert 'href="/market/create/"' in html
|
||||
assert 'hx-get="/market/create/"' in html
|
||||
assert "Add Market" in html
|
||||
assert "fa fa-plus" in html
|
||||
|
||||
def test_default_label(self):
|
||||
html = sexp(
|
||||
'(~relation-attach :create-url "/create/")',
|
||||
**{"create-url": "/create/"},
|
||||
)
|
||||
assert "Add" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~relation-detach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelationDetach:
|
||||
def test_renders_button(self):
|
||||
html = sexp(
|
||||
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
assert 'hx-delete="/api/unrelate"' in html
|
||||
assert 'hx-confirm="Remove Farm Shop?"' in html
|
||||
assert "fa fa-times" in html
|
||||
|
||||
def test_default_name(self):
|
||||
html = sexp(
|
||||
'(~relation-detach :detach-url "/api/unrelate")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
assert "this item" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_page() helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
245
shared/sexp/tests/test_relations.py
Normal file
245
shared/sexp/tests/test_relations.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests for the relation registry (Phase A)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.evaluator import evaluate, EvalError
|
||||
from shared.sexp.parser import parse
|
||||
from shared.sexp.relations import (
|
||||
_RELATION_REGISTRY,
|
||||
clear_registry,
|
||||
get_relation,
|
||||
load_relation_registry,
|
||||
relations_from,
|
||||
relations_to,
|
||||
all_relations,
|
||||
)
|
||||
from shared.sexp.types import RelationDef
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Clear registry before each test."""
|
||||
clear_registry()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# defrelation parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDefrelation:
|
||||
def test_basic_defrelation(self):
|
||||
tree = parse('''
|
||||
(defrelation :page->market
|
||||
:from "page"
|
||||
:to "market"
|
||||
:cardinality :one-to-many
|
||||
:inverse :market->page
|
||||
:nav :submenu
|
||||
:nav-icon "fa fa-shopping-bag"
|
||||
:nav-label "markets")
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
assert isinstance(result, RelationDef)
|
||||
assert result.name == "page->market"
|
||||
assert result.from_type == "page"
|
||||
assert result.to_type == "market"
|
||||
assert result.cardinality == "one-to-many"
|
||||
assert result.inverse == "market->page"
|
||||
assert result.nav == "submenu"
|
||||
assert result.nav_icon == "fa fa-shopping-bag"
|
||||
assert result.nav_label == "markets"
|
||||
|
||||
def test_defrelation_registered(self):
|
||||
tree = parse('''
|
||||
(defrelation :a->b
|
||||
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
|
||||
''')
|
||||
evaluate(tree)
|
||||
assert get_relation("a->b") is not None
|
||||
assert get_relation("a->b").cardinality == "one-to-one"
|
||||
|
||||
def test_defrelation_one_to_one(self):
|
||||
tree = parse('''
|
||||
(defrelation :page->menu_node
|
||||
:from "page" :to "menu_node"
|
||||
:cardinality :one-to-one :nav :hidden)
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
assert result.cardinality == "one-to-one"
|
||||
assert result.inverse is None
|
||||
assert result.nav == "hidden"
|
||||
|
||||
def test_defrelation_many_to_many(self):
|
||||
tree = parse('''
|
||||
(defrelation :post->entry
|
||||
:from "post" :to "calendar_entry"
|
||||
:cardinality :many-to-many
|
||||
:inverse :entry->post
|
||||
:nav :inline
|
||||
:nav-icon "fa fa-file-alt"
|
||||
:nav-label "events")
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
assert result.cardinality == "many-to-many"
|
||||
|
||||
def test_default_nav_is_hidden(self):
|
||||
tree = parse('''
|
||||
(defrelation :x->y
|
||||
:from "x" :to "y" :cardinality :one-to-many)
|
||||
''')
|
||||
result = evaluate(tree)
|
||||
assert result.nav == "hidden"
|
||||
|
||||
def test_invalid_cardinality_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad
|
||||
:from "a" :to "b" :cardinality :wrong)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="invalid cardinality"):
|
||||
evaluate(tree)
|
||||
|
||||
def test_invalid_nav_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad
|
||||
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="invalid nav"):
|
||||
evaluate(tree)
|
||||
|
||||
def test_missing_from_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :to "b" :cardinality :one-to-one)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :from"):
|
||||
evaluate(tree)
|
||||
|
||||
def test_missing_to_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :from "a" :cardinality :one-to-one)
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :to"):
|
||||
evaluate(tree)
|
||||
|
||||
def test_missing_cardinality_raises(self):
|
||||
tree = parse('''
|
||||
(defrelation :bad :from "a" :to "b")
|
||||
''')
|
||||
with pytest.raises(EvalError, match="missing required :cardinality"):
|
||||
evaluate(tree)
|
||||
|
||||
def test_name_must_be_keyword(self):
|
||||
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
|
||||
with pytest.raises(EvalError, match="must be a keyword"):
|
||||
evaluate(tree)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegistry:
|
||||
def _load_sample(self):
|
||||
tree = parse('''
|
||||
(begin
|
||||
(defrelation :page->market
|
||||
:from "page" :to "market" :cardinality :one-to-many
|
||||
:nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets")
|
||||
(defrelation :page->calendar
|
||||
:from "page" :to "calendar" :cardinality :one-to-many
|
||||
:nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars")
|
||||
(defrelation :post->entry
|
||||
:from "post" :to "calendar_entry" :cardinality :many-to-many
|
||||
:nav :inline)
|
||||
(defrelation :page->menu_node
|
||||
:from "page" :to "menu_node" :cardinality :one-to-one
|
||||
:nav :hidden))
|
||||
''')
|
||||
evaluate(tree)
|
||||
|
||||
def test_get_relation(self):
|
||||
self._load_sample()
|
||||
rel = get_relation("page->market")
|
||||
assert rel is not None
|
||||
assert rel.to_type == "market"
|
||||
|
||||
def test_get_relation_not_found(self):
|
||||
assert get_relation("nonexistent") is None
|
||||
|
||||
def test_relations_from_page(self):
|
||||
self._load_sample()
|
||||
rels = relations_from("page")
|
||||
names = {r.name for r in rels}
|
||||
assert names == {"page->market", "page->calendar", "page->menu_node"}
|
||||
|
||||
def test_relations_from_post(self):
|
||||
self._load_sample()
|
||||
rels = relations_from("post")
|
||||
assert len(rels) == 1
|
||||
assert rels[0].name == "post->entry"
|
||||
|
||||
def test_relations_from_empty(self):
|
||||
self._load_sample()
|
||||
assert relations_from("nonexistent") == []
|
||||
|
||||
def test_relations_to_market(self):
|
||||
self._load_sample()
|
||||
rels = relations_to("market")
|
||||
assert len(rels) == 1
|
||||
assert rels[0].name == "page->market"
|
||||
|
||||
def test_relations_to_calendar_entry(self):
|
||||
self._load_sample()
|
||||
rels = relations_to("calendar_entry")
|
||||
assert len(rels) == 1
|
||||
assert rels[0].name == "post->entry"
|
||||
|
||||
def test_all_relations(self):
|
||||
self._load_sample()
|
||||
assert len(all_relations()) == 4
|
||||
|
||||
def test_clear_registry(self):
|
||||
self._load_sample()
|
||||
assert len(all_relations()) == 4
|
||||
clear_registry()
|
||||
assert len(all_relations()) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_relation_registry() — built-in definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadBuiltins:
|
||||
def test_loads_builtin_relations(self):
|
||||
load_relation_registry()
|
||||
assert get_relation("page->market") is not None
|
||||
assert get_relation("page->calendar") is not None
|
||||
assert get_relation("post->calendar_entry") is not None
|
||||
assert get_relation("page->menu_node") is not None
|
||||
|
||||
def test_builtin_page_market(self):
|
||||
load_relation_registry()
|
||||
rel = get_relation("page->market")
|
||||
assert rel.from_type == "page"
|
||||
assert rel.to_type == "market"
|
||||
assert rel.cardinality == "one-to-many"
|
||||
assert rel.inverse == "market->page"
|
||||
assert rel.nav == "submenu"
|
||||
assert rel.nav_icon == "fa fa-shopping-bag"
|
||||
|
||||
def test_builtin_post_entry(self):
|
||||
load_relation_registry()
|
||||
rel = get_relation("post->calendar_entry")
|
||||
assert rel.cardinality == "many-to-many"
|
||||
assert rel.nav == "inline"
|
||||
|
||||
def test_builtin_page_menu_node(self):
|
||||
load_relation_registry()
|
||||
rel = get_relation("page->menu_node")
|
||||
assert rel.cardinality == "one-to-one"
|
||||
assert rel.nav == "hidden"
|
||||
|
||||
def test_frozen_dataclass(self):
|
||||
load_relation_registry()
|
||||
rel = get_relation("page->market")
|
||||
with pytest.raises(AttributeError):
|
||||
rel.name = "changed"
|
||||
@@ -148,9 +148,29 @@ class Component:
|
||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RelationDef
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelationDef:
|
||||
"""A declared relation between two entity types.
|
||||
|
||||
Created by ``(defrelation :name ...)`` s-expressions.
|
||||
"""
|
||||
name: str # "page->market"
|
||||
from_type: str # "page"
|
||||
to_type: str # "market"
|
||||
cardinality: str # "one-to-one" | "one-to-many" | "many-to-many"
|
||||
inverse: str | None # "market->page"
|
||||
nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden"
|
||||
nav_icon: str | None # "fa fa-shopping-bag"
|
||||
nav_label: str | None # "markets"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# An s-expression value after evaluation
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | list | dict | _Nil | None
|
||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | RelationDef | list | dict | _Nil | None
|
||||
|
||||
Reference in New Issue
Block a user