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