gabriel / musehub public
_templates.py python
117 lines 4.4 KB
Raw
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