# 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 (`