3 Commits

Author SHA1 Message Date
53c4a0a1e0 Externalize sexp component templates and delete redundant HTML fragments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
Move 24 defcomp definitions from Python string constants in components.py
to 7 grouped .sexp files under shared/sexp/templates/. Add load_sexp_dir()
to jinja_bridge.py for file-based loading. Migrate events and market
link-card fragment handlers from render_template to sexp. Delete 9
superseded Jinja HTML fragment templates.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:35:17 +00:00
37 changed files with 1391 additions and 1008 deletions

View File

@@ -1,36 +0,0 @@
{# Desktop auth menu #}
<span id="auth-menu-desktop" class="hidden md:inline-flex">
{% if user_email %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>
{# Mobile auth menu #}
<span id="auth-menu-mobile" class="block md:hidden text-md font-bold">
{% if user_email %}
<a href="{{ account_url('/') }}" data-close-details>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a href="{{ account_url('/') }}">
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>

View File

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

View File

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

View File

@@ -176,6 +176,7 @@ def register():
async def _link_card_handler(): async def _link_card_handler():
from shared.infrastructure.urls import events_url from shared.infrastructure.urls import events_url
from shared.sexp.jinja_bridge import sexp as render_sexp
slug = request.args.get("slug", "") slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "") keys_raw = request.args.get("keys", "")
@@ -193,12 +194,10 @@ def register():
g.s, "page", post.id, g.s, "page", post.id,
) )
cal_names = ", ".join(c.name for c in calendars) if calendars else "" cal_names = ", ".join(c.name for c in calendars) if calendars else ""
parts.append(await render_template( parts.append(render_sexp(
"fragments/link_card.html", '(~link-card :title title :image image :subtitle subtitle :link link)',
title=post.title, title=post.title, image=post.feature_image,
feature_image=post.feature_image, subtitle=cal_names, link=events_url(f"/{post.slug}"),
calendar_names=cal_names,
link=events_url(f"/{post.slug}"),
)) ))
return "\n".join(parts) return "\n".join(parts)
@@ -213,12 +212,10 @@ def register():
g.s, "page", post.id, g.s, "page", post.id,
) )
cal_names = ", ".join(c.name for c in calendars) if calendars else "" cal_names = ", ".join(c.name for c in calendars) if calendars else ""
return await render_template( return render_sexp(
"fragments/link_card.html", '(~link-card :title title :image image :subtitle subtitle :link link)',
title=post.title, title=post.title, image=post.feature_image,
feature_image=post.feature_image, subtitle=cal_names, link=events_url(f"/{post.slug}"),
calendar_names=cal_names,
link=events_url(f"/{post.slug}"),
) )
_handlers["link-card"] = _link_card_handler _handlers["link-card"] = _link_card_handler

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ by other coop apps via the fragment client.
from __future__ import annotations 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.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services from shared.services.registry import services
@@ -65,6 +65,7 @@ def register():
from sqlalchemy import select from sqlalchemy import select
from shared.models.market import Product from shared.models.market import Product
from shared.infrastructure.urls import market_url from shared.infrastructure.urls import market_url
from shared.sexp.jinja_bridge import sexp as render_sexp
slug = request.args.get("slug", "") slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "") keys_raw = request.args.get("keys", "")
@@ -79,14 +80,16 @@ def register():
await g.s.execute(select(Product).where(Product.slug == s)) await g.s.execute(select(Product).where(Product.slug == s))
).scalar_one_or_none() ).scalar_one_or_none()
if product: if product:
parts.append(await render_template( subtitle = product.brand or ""
"fragments/link_card.html", detail = ""
title=product.title, if product.special_price:
image=product.image, detail = f"<s>{product.regular_price}</s> {product.special_price}"
description_short=product.description_short, elif product.regular_price:
brand=product.brand, detail = str(product.regular_price)
regular_price=product.regular_price, parts.append(render_sexp(
special_price=product.special_price, '(~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}/"), link=market_url(f"/product/{product.slug}/"),
)) ))
return "\n".join(parts) return "\n".join(parts)
@@ -99,14 +102,16 @@ def register():
).scalar_one_or_none() ).scalar_one_or_none()
if not product: if not product:
return "" return ""
return await render_template( subtitle = product.brand or ""
"fragments/link_card.html", detail = ""
title=product.title, if product.special_price:
image=product.image, detail = f"<s>{product.regular_price}</s> {product.special_price}"
description_short=product.description_short, elif product.regular_price:
brand=product.brand, detail = str(product.regular_price)
regular_price=product.regular_price, return render_sexp(
special_price=product.special_price, '(~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}/"), link=market_url(f"/product/{product.slug}/"),
) )

View File

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

View File

@@ -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">&pound;{{ "%.2f"|format(special_price) }}</span>
<span class="text-stone-400 line-through ml-1">&pound;{{ "%.2f"|format(regular_price) }}</span>
{% elif regular_price %}
<span class="text-stone-700 font-medium">&pound;{{ "%.2f"|format(regular_price) }}</span>
{% endif %}
</div>
</div>
</div>
</a>

View File

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

View File

@@ -3,7 +3,7 @@ import path_setup # noqa: F401
from shared.infrastructure.factory import create_base_app 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 from services import register_domain_services
@@ -15,6 +15,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_fragments())
return app return app

View File

@@ -1,2 +1,3 @@
from .data.routes import register as register_data from .data.routes import register as register_data
from .actions.routes import register as register_actions from .actions.routes import register as register_actions
from .fragments.routes import register as register_fragments

View File

@@ -46,6 +46,8 @@ def register() -> Blueprint:
child_id=data["child_id"], child_id=data["child_id"],
label=data.get("label"), label=data.get("label"),
sort_order=data.get("sort_order"), sort_order=data.get("sort_order"),
relation_type=data.get("relation_type"),
metadata=data.get("metadata"),
) )
return { return {
"id": rel.id, "id": rel.id,
@@ -54,6 +56,7 @@ def register() -> Blueprint:
"child_type": rel.child_type, "child_type": rel.child_type,
"child_id": rel.child_id, "child_id": rel.child_id,
"sort_order": rel.sort_order, "sort_order": rel.sort_order,
"relation_type": rel.relation_type,
} }
_handlers["attach-child"] = _attach_child _handlers["attach-child"] = _attach_child
@@ -70,9 +73,122 @@ def register() -> Blueprint:
parent_id=data["parent_id"], parent_id=data["parent_id"],
child_type=data["child_type"], child_type=data["child_type"],
child_id=data["child_id"], child_id=data["child_id"],
relation_type=data.get("relation_type"),
) )
return {"deleted": deleted} return {"deleted": deleted}
_handlers["detach-child"] = _detach_child _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 return bp

View File

@@ -35,11 +35,36 @@ def register() -> Blueprint:
parent_type = request.args.get("parent_type", "") parent_type = request.args.get("parent_type", "")
parent_id = request.args.get("parent_id", type=int) parent_id = request.args.get("parent_id", type=int)
child_type = request.args.get("child_type") child_type = request.args.get("child_type")
relation_type = request.args.get("relation_type")
if not parent_type or parent_id is None: if not parent_type or parent_id is None:
return [] return []
rels = await get_children(g.s, parent_type, parent_id, child_type) rels = await get_children(g.s, parent_type, parent_id, child_type, relation_type=relation_type)
return [ 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, "id": r.id,
"parent_type": r.parent_type, "parent_type": r.parent_type,
"parent_id": r.parent_id, "parent_id": r.parent_id,
@@ -47,10 +72,6 @@ def register() -> Blueprint:
"child_id": r.child_id, "child_id": r.child_id,
"sort_order": r.sort_order, "sort_order": r.sort_order,
"label": r.label, "label": r.label,
"relation_type": r.relation_type,
"metadata": r.metadata_,
} }
for r in rels
]
_handlers["get-children"] = _get_children
return bp

View File

View 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

View File

@@ -30,6 +30,7 @@ from .jinja_setup import setup_jinja
from .user_loader import load_current_user from .user_loader import load_current_user
from shared.sexp.jinja_bridge import setup_sexp_bridge from shared.sexp.jinja_bridge import setup_sexp_bridge
from shared.sexp.components import load_shared_components from shared.sexp.components import load_shared_components
from shared.sexp.relations import load_relation_registry
# Async init of config (runs once at import) # Async init of config (runs once at import)
@@ -108,6 +109,7 @@ def create_base_app(
setup_jinja(app) setup_jinja(app)
setup_sexp_bridge(app) setup_sexp_bridge(app)
load_shared_components() load_shared_components()
load_relation_registry()
errors(app) errors(app)
# Auto-register OAuth client blueprint for non-account apps # Auto-register OAuth client blueprint for non-account apps

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy.orm import Mapped, mapped_column 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 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_parent", "parent_type", "parent_id"),
Index("ix_container_relations_child", "child_type", "child_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) 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_type: Mapped[str] = mapped_column(String(32), nullable=False)
child_id: Mapped[int] = mapped_column(Integer, 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) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)

View File

@@ -15,6 +15,8 @@ async def attach_child(
child_id: int, child_id: int,
label: str | None = None, label: str | None = None,
sort_order: int | None = None, sort_order: int | None = None,
relation_type: str | None = None,
metadata: dict | None = None,
) -> ContainerRelation: ) -> ContainerRelation:
""" """
Create a ContainerRelation and emit container.child_attached event. Create a ContainerRelation and emit container.child_attached event.
@@ -39,6 +41,10 @@ async def attach_child(
existing.sort_order = sort_order existing.sort_order = sort_order
if label is not None: if label is not None:
existing.label = label 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 session.flush()
await emit_activity( await emit_activity(
session, session,
@@ -50,12 +56,25 @@ async def attach_child(
"parent_id": parent_id, "parent_id": parent_id,
"child_type": child_type, "child_type": child_type,
"child_id": child_id, "child_id": child_id,
**({"relation_type": relation_type} if relation_type else {}),
}, },
source_type="container_relation", source_type="container_relation",
source_id=existing.id, source_id=existing.id,
) )
return existing 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 return existing
if sort_order is None: if sort_order is None:
@@ -75,6 +94,8 @@ async def attach_child(
child_id=child_id, child_id=child_id,
label=label, label=label,
sort_order=sort_order, sort_order=sort_order,
relation_type=relation_type,
metadata_=metadata,
) )
session.add(rel) session.add(rel)
await session.flush() await session.flush()
@@ -89,6 +110,7 @@ async def attach_child(
"parent_id": parent_id, "parent_id": parent_id,
"child_type": child_type, "child_type": child_type,
"child_id": child_id, "child_id": child_id,
**({"relation_type": relation_type} if relation_type else {}),
}, },
source_type="container_relation", source_type="container_relation",
source_id=rel.id, source_id=rel.id,
@@ -102,8 +124,9 @@ async def get_children(
parent_type: str, parent_type: str,
parent_id: int, parent_id: int,
child_type: str | None = None, child_type: str | None = None,
relation_type: str | None = None,
) -> list[ContainerRelation]: ) -> 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( stmt = select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type, ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id, ContainerRelation.parent_id == parent_id,
@@ -111,6 +134,8 @@ async def get_children(
) )
if child_type is not None: if child_type is not None:
stmt = stmt.where(ContainerRelation.child_type == child_type) 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( stmt = stmt.order_by(
ContainerRelation.sort_order.asc(), ContainerRelation.id.asc() ContainerRelation.sort_order.asc(), ContainerRelation.id.asc()
@@ -119,23 +144,49 @@ async def get_children(
return list(result.scalars().all()) 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( async def detach_child(
session: AsyncSession, session: AsyncSession,
parent_type: str, parent_type: str,
parent_id: int, parent_id: int,
child_type: str, child_type: str,
child_id: int, child_id: int,
relation_type: str | None = None,
) -> bool: ) -> bool:
"""Soft-delete a ContainerRelation and emit container.child_detached event.""" """Soft-delete a ContainerRelation and emit container.child_detached event."""
result = await session.execute( stmt = select(ContainerRelation).where(
select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type, ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id, ContainerRelation.parent_id == parent_id,
ContainerRelation.child_type == child_type, ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id, ContainerRelation.child_id == child_id,
ContainerRelation.deleted_at.is_(None), 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() rel = result.scalar_one_or_none()
if not rel: if not rel:
return False return False
@@ -153,6 +204,7 @@ async def detach_child(
"parent_id": parent_id, "parent_id": parent_id,
"child_type": child_type, "child_type": child_type,
"child_id": child_id, "child_id": child_id,
**({"relation_type": rel.relation_type} if rel.relation_type else {}),
}, },
source_type="container_relation", source_type="container_relation",
source_id=rel.id, source_id=rel.id,

View File

@@ -2,769 +2,17 @@
Shared s-expression component definitions. Shared s-expression component definitions.
Loaded at app startup via ``load_shared_components()``. Each component Loaded at app startup via ``load_shared_components()``. Each component
replaces a per-service Jinja fragment template with a single reusable is defined in an external ``.sexp`` file under ``templates/``.
s-expression definition.
""" """
from __future__ import annotations from __future__ import annotations
from .jinja_bridge import register_components import os
from .jinja_bridge import load_sexp_dir
def load_shared_components() -> None: def load_shared_components() -> None:
"""Register all shared s-expression components.""" """Register all shared s-expression components."""
register_components(_LINK_CARD) templates_dir = os.path.join(os.path.dirname(__file__), "templates")
register_components(_CART_MINI) load_sexp_dir(templates_dir)
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"))))
'''

View File

@@ -13,6 +13,7 @@ Special forms:
(lambda (params...) body) or (fn (params...) body) (lambda (params...) body) or (fn (params...) body)
(define name value) (define name value)
(defcomp ~name (&key param...) body) (defcomp ~name (&key param...) body)
(defrelation :name :from "type" :to "type" :cardinality :card ...)
(begin expr...) (begin expr...)
(quote expr) (quote expr)
(do expr...) — alias for begin (do expr...) — alias for begin
@@ -32,7 +33,7 @@ from __future__ import annotations
from typing import Any 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 from .primitives import _PRIMITIVES
@@ -429,6 +430,75 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
return value 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] = { _SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if, "if": _sf_if,
"when": _sf_when, "when": _sf_when,
@@ -442,6 +512,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"fn": _sf_lambda, "fn": _sf_lambda,
"define": _sf_define, "define": _sf_define,
"defcomp": _sf_defcomp, "defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin, "begin": _sf_begin,
"do": _sf_begin, "do": _sf_begin,
"quote": _sf_quote, "quote": _sf_quote,

View File

@@ -74,6 +74,12 @@ HTML_TAGS = frozenset({
"img", "picture", "source", "iframe", "embed", "object", "param", "img", "picture", "source", "iframe", "embed", "object", "param",
"video", "audio", "track", "canvas", "map", "area", "video", "audio", "track", "canvas", "map", "area",
"svg", "math", "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
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "table", "thead", "tbody", "tfoot", "tr", "th", "td",
"caption", "colgroup", "col", "caption", "colgroup", "col",

View File

@@ -20,6 +20,8 @@ Setup::
from __future__ import annotations from __future__ import annotations
import glob
import os
from typing import Any from typing import Any
from .types import NIL, Symbol from .types import NIL, Symbol
@@ -41,6 +43,13 @@ def get_component_env() -> dict[str, Any]:
return _COMPONENT_ENV 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: def register_components(sexp_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the """Parse and evaluate s-expression component definitions into the
shared environment. shared environment.

View File

@@ -46,7 +46,7 @@ class Tokenizer:
COMMENT = re.compile(r";[^\n]*") COMMENT = re.compile(r";[^\n]*")
STRING = re.compile(r'"(?:[^"\\]|\\.)*"') STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?") 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, # Symbols may start with alpha, _, or common operator chars, plus ~ for components,
# <> for the fragment symbol, and & for &key/&rest. # <> for the fragment symbol, and & for &key/&rest.
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*") SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")

101
shared/sexp/relations.py Normal file
View 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)

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

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

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

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

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

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

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

View File

@@ -264,6 +264,80 @@ class TestErrorPage:
assert "<img" not in html 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 # render_page() helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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

View File

@@ -148,9 +148,29 @@ class Component:
return f"<Component ~{self.name}({', '.join(self.params)})>" 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 # Type alias
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# An s-expression value after evaluation # 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