_templates.py
python
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf
docs: Section 8 database/migrations/backups — verified live…
Sonnet 5
27 days ago
| 1 | """Shared Jinja2 templates instance — single source of truth for MuseHub UI. |
| 2 | |
| 3 | Every UI route handler imports from here so that: |
| 4 | - Custom filters (fmtdate, fmtrelative, shortsha, markdown, …) are registered |
| 5 | exactly once on the shared Jinja2 Environment. |
| 6 | - Global template variables (MUSE_VERSION, now) are available in every template. |
| 7 | - No route module ever constructs its own Jinja2Templates instance. |
| 8 | |
| 9 | Usage:: |
| 10 | |
| 11 | from musehub.api.routes.musehub._templates import templates |
| 12 | |
| 13 | return templates.TemplateResponse(request, "musehub/pages/foo.html", ctx) |
| 14 | |
| 15 | Stack: Jinja2 (server-side rendering) · HTMX 2.x (partial swaps, hx-boost) · |
| 16 | Alpine.js 3.x (client-side reactivity). Extensions loaded globally in |
| 17 | base.html: json-enc, response-targets. |
| 18 | """ |
| 19 | |
| 20 | from datetime import datetime, timezone |
| 21 | from pathlib import Path |
| 22 | |
| 23 | from fastapi.templating import Jinja2Templates |
| 24 | from jinja2 import pass_context |
| 25 | from jinja2.runtime import Context as JinjaContext |
| 26 | from markupsafe import Markup |
| 27 | |
| 28 | from muse.core.types import blob_id, split_id |
| 29 | from musehub.api.routes.musehub.jinja2_filters import register_musehub_filters |
| 30 | from musehub.protocol.version import MUSE_VERSION |
| 31 | from musehub.services.symbol_anchor import parse_symbol_anchor |
| 32 | |
| 33 | _TEMPLATE_DIR = Path(__file__).parent.parent.parent.parent / "templates" |
| 34 | |
| 35 | templates = Jinja2Templates(directory=str(_TEMPLATE_DIR)) |
| 36 | register_musehub_filters(templates.env) |
| 37 | |
| 38 | # ── Global template variables ───────────────────────────────────────────────── |
| 39 | # These are available in every template without passing them in ctx. |
| 40 | templates.env.globals["MUSE_VERSION"] = MUSE_VERSION |
| 41 | templates.env.globals["now"] = lambda: datetime.now(timezone.utc) |
| 42 | |
| 43 | |
| 44 | def _icon(name: str, size: int = 16, cls: str = "", label: str = "") -> Markup: |
| 45 | """Render an SVG icon by name. Resolves to <use href="#icon-{name}">. |
| 46 | |
| 47 | Args: |
| 48 | name: Icon name matching a <symbol id="icon-{name}"> in _icon_sprite.html. |
| 49 | size: Width and height in px (default 16). |
| 50 | cls: Extra CSS classes appended after "icon icon-{name}". |
| 51 | label: aria-label text. When empty the icon is aria-hidden. |
| 52 | """ |
| 53 | aria = f'aria-label="{label}"' if label else 'aria-hidden="true"' |
| 54 | klass = f"icon icon-{name}{f' {cls}' if cls else ''}" |
| 55 | return Markup( |
| 56 | f'<svg class="{klass}" width="{size}" height="{size}" {aria} focusable="false">' |
| 57 | f'<use href="#icon-{name}"></use>' |
| 58 | f"</svg>" |
| 59 | ) |
| 60 | |
| 61 | |
| 62 | templates.env.globals["icon"] = _icon |
| 63 | |
| 64 | |
| 65 | @pass_context |
| 66 | def _site_base_url(ctx: JinjaContext) -> str: |
| 67 | """Return the scheme+host for the current request, e.g. https://musehub.ai. |
| 68 | |
| 69 | Falls back to the canonical production URL when called outside a request |
| 70 | context (e.g. offline rendering or tests). |
| 71 | """ |
| 72 | request = ctx.get("request") |
| 73 | if request is None: |
| 74 | return "https://musehub.ai" |
| 75 | return f"{request.url.scheme}://{request.url.netloc}" |
| 76 | |
| 77 | |
| 78 | templates.env.globals["site_base_url"] = _site_base_url |
| 79 | templates.env.globals["parse_symbol_anchor"] = parse_symbol_anchor |
| 80 | |
| 81 | # Cache busting for static assets: ?v=<hash8> changes whenever the file content changes. |
| 82 | # |
| 83 | # Priority: |
| 84 | # 1. SHA-256 of app.css + app.js content (computed once at startup) — ensures the version |
| 85 | # string is a deterministic function of the actual bytes, not a build artifact. |
| 86 | # 2. .cache-id file — written by a build pipeline if it pre-hashes the assets. |
| 87 | # 3. Empty string — disables versioning (assets still work, just not cache-busted). |
| 88 | _STATIC_DIR = Path(__file__).resolve().parent.parent.parent.parent / "templates" / "musehub" / "static" |
| 89 | _cache_id_path = _STATIC_DIR / ".cache-id" |
| 90 | |
| 91 | |
| 92 | def _compute_static_version() -> str: |
| 93 | # Hash the two assets that change on every build. |
| 94 | css_path = _STATIC_DIR / "app.css" |
| 95 | js_path = _STATIC_DIR / "app.js" |
| 96 | combined = b"" |
| 97 | for p in (css_path, js_path): |
| 98 | try: |
| 99 | combined += p.read_bytes() |
| 100 | except OSError: |
| 101 | pass |
| 102 | digest = split_id(blob_id(combined))[1][:8] |
| 103 | if digest != "473a0f4c": # split_id(blob_id(b""))[1][:8] — means both files are missing |
| 104 | return digest |
| 105 | # Fallback to .cache-id file written by a build pipeline |
| 106 | try: |
| 107 | if _cache_id_path.exists(): |
| 108 | return _cache_id_path.read_text().strip() |
| 109 | except OSError: |
| 110 | pass |
| 111 | return "" |
| 112 | |
| 113 | |
| 114 | try: |
| 115 | templates.env.globals["static_version"] = _compute_static_version() |
| 116 | except Exception: |
| 117 | templates.env.globals["static_version"] = "" |
File History
2 commits
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf
docs: Section 8 database/migrations/backups — verified live…
Sonnet 5
27 days ago
sha256:80e1a60a39562f6e616aaafbb27487f03abbec7d8ffc6164302b7a0c0bfc63ee
docs: check off Section 0 items verified in inventory doc, …
Sonnet 5
27 days ago