diff --git a/docs/fragment-composition-plan.md b/docs/fragment-composition-plan.md new file mode 100644 index 0000000..7619ae9 --- /dev/null +++ b/docs/fragment-composition-plan.md @@ -0,0 +1,502 @@ +# Eliminate Shared Templates: Server-Side Micro-Frontend Composition + +## Context + +All 6 coop apps share ~291 templates via `shared/browser/templates/`. This couples every app's UI to a shared submodule. The widget registry (`NavWidget`, `CardWidget`, `AccountPageWidget`, `AccountNavLink`) adds cross-app UI coupling. + +**Goal:** Each app owns ALL its templates. Apps expose HTML **fragments** via internal HTTP endpoints. Consuming apps fetch and compose these fragments. No shared templates, no shared widget system. + +**Pattern:** [Server-side template composition](https://martinfowler.com/articles/micro-frontends.html) — each app is both a **container** (assembling fragments from others into its pages) and a **micro-frontend** (providing fragments to others). Authentication centralized in account app, injected via fragment. Fowler's "harvest" approach for shared UI: allow duplication first, extract proven patterns later. + +--- + +## Architecture + +### Fragment API Contract + +Each app exposes `GET /internal/fragments/{type}` (internal Docker network only). + +- **Response**: Raw HTML (`text/html`), ready for `{{ html | safe }}` +- **Errors**: Return `200` with empty body (graceful degradation) +- **Header**: `X-Fragment-Request: 1` on requests +- **Context**: Query params for per-user/per-page data (e.g. `?user_id=5`) +- **Variant**: Optional `?variant=compact|full|inline|card` query param — provider renders a layout-appropriate version (see Consumer-Side Styling below) + +### Fragment Catalog + +| Fragment | Provider | Consumers | TTL | +|----------|----------|-----------|-----| +| `nav-item` | each app | all apps | 300s | +| `nav-tree` | blog | all apps | 60s | +| `auth-menu` | account | all apps | 60s | +| `cart-mini` | cart | all apps | 10s | +| `container-nav` | events, market | blog, events | 30s | +| `container-cards` | events | blog | 30s | +| `account-nav-item` | events, cart, federation | account | 300s | +| `account-page/{slug}` | events, cart | account | 30s | +| `link-card` | blog, market, events, federation | any app | 300s | +| `/oembed` (public) | blog, market, events | Mastodon, Slack, Discord, etc. | 300s | +| `/{slug}/embed` (public) | blog, events | Slack, Discord, WordPress (oEmbed iframe) | — | +| OG `` tags | all apps | Twitter/X, Facebook, LinkedIn | — | +| AP `Video` attachment | federation | Mastodon, Pleroma, Misskey | — | + +### Fragment Client + +New file: `shared/infrastructure/fragments.py` + +```python +async def fetch_fragment(app_name, fragment_type, *, params=None, timeout=2.0) -> str: + """Fetch HTML fragment from another app. Returns '' on error.""" + +async def fetch_fragments(requests: list[tuple[str, str, dict|None]]) -> list[str]: + """Fetch multiple fragments concurrently via asyncio.gather.""" + +async def fetch_fragment_cached(app_name, fragment_type, *, params=None, ttl=30) -> str: + """fetch_fragment + Redis cache layer.""" + +async def fetch_fragment_batch( + app_name, fragment_type, *, keys: list[str], params=None, ttl=30, +) -> dict[str, str]: + """Fetch a batch of keyed fragments in a single HTTP call. + + Sends keys as comma-separated query param. Provider returns HTML with + comment markers. Client splits and returns + a dict mapping each key to its HTML (missing keys → ''). + + Example: fetch_fragment_batch("cart", "cart-mini", keys=["flour", "bread"], + params={"user_id": 5}) + → GET /internal/fragments/cart-mini?keys=flour,bread&user_id=5 + → {"flour": "
...
", "bread": "
...
"} + """ +``` + +Uses `INTERNAL_URL_{APP}` env vars (e.g. `http://blog:8000`). Falls back to `http://{app}:8000`. + +### Batch Fragments (list pages) + +Fragments that appear per-item on list pages (product cards, timeline posts, etc.) +support batch fetching to avoid N+1 HTTP calls. One request returns all items, +delimited by `` comment markers. + +**Pattern (already proven by `container-cards`):** + +``` +GET /internal/fragments/cart-mini?keys=flour,bread,milk&user_id=5 +``` + +```html + +
...
+ +
...
+ +
...
+``` + +The client helper `fetch_fragment_batch()` splits by markers and returns `dict[key, html]`. +Templates access per-item HTML via the dict: + +```jinja2 +{% for product in products %} + {{ batch_cart_minis[product.slug] | safe }} +{% endfor %} +``` + +**Batch-eligible fragments:** + +| Fragment | Key field | Use case | +|----------|-----------|----------| +| `cart-mini` | `product_slug` | Add-to-cart buttons on browse/listing pages | +| `like-button` | `slug` | Like buttons on product cards, blog listings | +| `link-card` | `slug` | Multiple rich link previews in a blog post | +| `container-cards` | `post_id` | Calendar entry cards on blog listing (already implemented) | + +**Provider-side:** batch handlers receive `keys` query param, query all items in one +DB call (e.g. `WHERE slug IN (...)`), render each to HTML, join with comment markers. +This is a single SQL query + template render loop, not N separate handler calls. + +### Jinja Integration + +Register `fragment` as Jinja async global in `jinja_setup.py`: +```jinja2 +{{ fragment("cart", "cart-mini", user_id=uid) | safe }} +``` + +**Preferred pattern**: Pre-fetch all fragments in context processor with `asyncio.gather`, pass as template vars. + +### Consumer-Side Styling + +Fragments need to look different depending on where they appear. A cart-mini button +on a product card looks different from one in a sidebar or a mobile nav. The consumer +controls the presentation context; the provider supplies the content. + +**Three complementary mechanisms:** + +**1. Provider variants (`?variant=`)** — the provider renders a structurally different +version. Use when layout differs significantly (not just CSS tweaks): + +```python +# Context processor +cart_mini_html = await fetch_fragment("cart", "cart-mini", + params={"product_slug": "flour", "variant": "compact"}) +``` + +Standard variant names: +- `full` — default, complete rendering (product pages, dedicated sections) +- `compact` — minimal version (listing cards, sidebars, tight spaces) +- `inline` — single-line/inline-flow version (within text, table cells) +- `icon` — icon-only, no text (toolbars, mobile nav) + +Provider templates: `fragments/cart_mini.html` checks `variant` param and renders +accordingly. One template with conditionals, or separate includes per variant. + +**2. CSS wrapper classes** — the consumer wraps the fragment in a styled container. +Use for spacing, sizing, color scheme adaptation: + +```jinja2 +{# Same fragment, different contexts #} +
+ {{ batch_cart_minis[product.slug] | safe }} +
+ + +``` + +Fragments use BEM-style or data-attribute selectors (`[data-fragment="cart-mini"]`) +so consumers can target them with CSS without relying on internal class names. + +**3. Data attributes for JS hooks** — fragments include `data-fragment="type"` +and `data-app="provider"` attributes on their root element. Consumers can attach +behavior without coupling to internal markup: + +```html + +
+ ... +
+ + + +``` + +**Convention:** Every fragment's root element MUST include: +- `data-fragment="{type}"` — the fragment type name +- `data-app="{provider}"` — the app that rendered it +- A single root element (no bare text nodes) so wrapping/targeting works reliably + +--- + +## Implementation Phases + +### Phase 1: Fragment Infrastructure (foundation, no breaking changes) + +**New files:** +- `shared/infrastructure/fragments.py` — HTTP client + Redis cache +- `{blog,market,cart,events,federation,account}/bp/fragments/__init__.py` + `routes.py` — fragment blueprints +- `art-dag/celery/app/routers/fragments.py` — artdag fragment endpoints + +**Modified files:** +- `shared/infrastructure/jinja_setup.py` — add `fragment` Jinja global +- Each app's `app.py` — register fragments blueprint + +### Phase 2: Cart + Account Fragments (highest cross-app value) + +Cart implements: `cart-mini`, `nav-item`, `account-nav-item` +Account implements: `auth-menu`, `sign-in` +Blog updates context processor to fetch these fragments. + +### Phase 3: Navigation Fragments + +Blog implements: `nav-tree` (renders `menu_items` to HTML) +All apps update context processors to fetch `nav-tree` from blog. +Copy `_types/root/` templates to each app, replace nav includes with fragment slots. + +### Phase 4: Container Widgets → Fragments + +Events implements: `container-nav`, `container-cards` +Market implements: `container-nav` +Blog/events replace `widgets.container_nav`/`widgets.container_cards` with fragment fetches. + +### Phase 5: Link Card Fragments (rich internal links) + +Any app can request a rich link preview from any other app: + +```python +html = await fetch_fragment("blog", "link-card", params={"slug": "about-us"}) +html = await fetch_fragment("market", "link-card", params={"slug": "organic-flour"}) +html = await fetch_fragment("events", "link-card", params={"slug": "summer-fair", "calendar_id": "42"}) +``` + +Each app's `link-card` handler returns a self-contained HTML card with domain-appropriate data: + +| Provider | Card contents | +|----------|--------------| +| **blog** | title, feature image, excerpt | +| **market** | product name, image, price, availability | +| **events** | event name, date/time, image, ticket status | +| **federation** | actor display name, avatar, bio | + +**Key design principles:** + +1. **Provider owns the base markup** — the card HTML includes sensible default styling (image, title, description), but is wrapped in a simple container (`