pyjoist 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- joist/__init__.py +41 -0
- joist/component.py +398 -0
- joist/htmx.py +234 -0
- joist/integrators/__init__.py +35 -0
- joist/integrators/fastapi.py +194 -0
- joist/py.typed +0 -0
- joist/templates/joist/page.html +12 -0
- pyjoist-0.1.0.dist-info/METADATA +450 -0
- pyjoist-0.1.0.dist-info/RECORD +12 -0
- pyjoist-0.1.0.dist-info/WHEEL +5 -0
- pyjoist-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyjoist-0.1.0.dist-info/top_level.txt +1 -0
joist/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""joist — Build HTML UI with Python dataclasses + Jinja2 templates.
|
|
2
|
+
|
|
3
|
+
joist gives you a single architectural primitive: Component = dataclass + template.
|
|
4
|
+
Everything else is a consequence of that choice.
|
|
5
|
+
|
|
6
|
+
You define components as dataclasses, build component trees with plain
|
|
7
|
+
functions (builders), and render them through Jinja2. The framework stays
|
|
8
|
+
out of your way.
|
|
9
|
+
|
|
10
|
+
Core exports:
|
|
11
|
+
|
|
12
|
+
* :class:`Component` — base class for dataclass-driven UI components.
|
|
13
|
+
* :class:`Fragment` — lightweight variant for HTMX partials.
|
|
14
|
+
* :class:`Page` — full HTML document shell.
|
|
15
|
+
* :func:`setup` — configure the Jinja2 environment.
|
|
16
|
+
* :func:`get_env` — access the global Jinja2 environment.
|
|
17
|
+
* :func:`render_component` — render a component to ``Markup``.
|
|
18
|
+
* :func:`render_template` — render a template by name with only context.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from joist.component import (
|
|
22
|
+
Component,
|
|
23
|
+
ComponentError,
|
|
24
|
+
Fragment,
|
|
25
|
+
Page,
|
|
26
|
+
get_env,
|
|
27
|
+
render_component,
|
|
28
|
+
render_template,
|
|
29
|
+
setup,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Component",
|
|
34
|
+
"ComponentError",
|
|
35
|
+
"Fragment",
|
|
36
|
+
"Page",
|
|
37
|
+
"get_env",
|
|
38
|
+
"render_component",
|
|
39
|
+
"render_template",
|
|
40
|
+
"setup",
|
|
41
|
+
]
|
joist/component.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""joist — Build HTML UI with Python dataclasses + Jinja2 templates.
|
|
2
|
+
|
|
3
|
+
Architecture
|
|
4
|
+
------------
|
|
5
|
+
|
|
6
|
+
joist implements a server-side component layer on top of Jinja2, designed
|
|
7
|
+
for HTMX-heavy applications where the same UI fragment can be rendered
|
|
8
|
+
as a full page, a partial swap, or an out-of-band update.
|
|
9
|
+
|
|
10
|
+
The pattern follows three layers:
|
|
11
|
+
|
|
12
|
+
Routes (load data)
|
|
13
|
+
↓
|
|
14
|
+
Builders (assemble component trees) ← joist lives here
|
|
15
|
+
↓
|
|
16
|
+
Templates (render markup)
|
|
17
|
+
|
|
18
|
+
- **Routes** handle HTTP, authentication, and data loading. They call
|
|
19
|
+
builders and return the rendered result.
|
|
20
|
+
- **Builders** are plain functions that create and compose Component
|
|
21
|
+
instances. Builders are the primary way to assemble UI from data.
|
|
22
|
+
- **Templates** render markup and place child components. They receive
|
|
23
|
+
typed context from the Component's dataclass fields.
|
|
24
|
+
|
|
25
|
+
Component = ``@dataclass(kw_only=True)`` + Jinja2 template.
|
|
26
|
+
|
|
27
|
+
This module provides the primitive: :class:`Component`, :class:`Fragment`
|
|
28
|
+
for HTMX partials, :class:`Page` for the document shell, and helpers
|
|
29
|
+
for template environment setup.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import inspect
|
|
35
|
+
from dataclasses import dataclass, field, fields
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, ClassVar
|
|
38
|
+
|
|
39
|
+
from jinja2 import Environment, FileSystemLoader
|
|
40
|
+
from markupsafe import Markup
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Component",
|
|
44
|
+
"Fragment",
|
|
45
|
+
"Page",
|
|
46
|
+
"setup",
|
|
47
|
+
"get_env",
|
|
48
|
+
"render_component",
|
|
49
|
+
"render_template",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Global Jinja2 environment ──────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
_env: Environment | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def setup(
|
|
59
|
+
*,
|
|
60
|
+
template_dirs: list[str | Path] | None = None,
|
|
61
|
+
extensions: list[str] | None = None,
|
|
62
|
+
environment: Environment | None = None,
|
|
63
|
+
autoescape: bool = True,
|
|
64
|
+
**env_kwargs: Any,
|
|
65
|
+
) -> Environment:
|
|
66
|
+
"""Configure the global Jinja2 environment.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
template_dirs: Additional directories to search for templates
|
|
70
|
+
(built-in templates are included automatically).
|
|
71
|
+
extensions: Jinja2 extensions to load.
|
|
72
|
+
environment: An existing Environment to use instead of creating one.
|
|
73
|
+
autoescape: Enable autoescaping (default True). Ignored when
|
|
74
|
+
``environment`` is provided.
|
|
75
|
+
**env_kwargs: Additional kwargs passed to ``Environment``.
|
|
76
|
+
Ignored when ``environment`` is provided.
|
|
77
|
+
|
|
78
|
+
The built-in templates directory is automatically prepended to the
|
|
79
|
+
loader chain so joist's own templates (e.g. ``Page``) are always
|
|
80
|
+
resolvable regardless of your project's template directories.
|
|
81
|
+
"""
|
|
82
|
+
global _env
|
|
83
|
+
if environment is not None:
|
|
84
|
+
_env = environment
|
|
85
|
+
return _env
|
|
86
|
+
|
|
87
|
+
builtin_dir = str(Path(__file__).parent / "templates")
|
|
88
|
+
all_dirs = [builtin_dir]
|
|
89
|
+
if template_dirs:
|
|
90
|
+
all_dirs.extend(str(d) for d in template_dirs)
|
|
91
|
+
|
|
92
|
+
_env = Environment(
|
|
93
|
+
loader=FileSystemLoader(all_dirs),
|
|
94
|
+
autoescape=autoescape,
|
|
95
|
+
extensions=extensions or [],
|
|
96
|
+
**env_kwargs,
|
|
97
|
+
)
|
|
98
|
+
return _env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_env() -> Environment:
|
|
102
|
+
"""Return the global Jinja2 environment, creating a default if needed.
|
|
103
|
+
|
|
104
|
+
The first call without an explicit ``setup()`` will create a default
|
|
105
|
+
environment pointing only at joist's built-in templates. Call
|
|
106
|
+
``setup()`` first if you need to add custom template directories.
|
|
107
|
+
"""
|
|
108
|
+
if _env is None:
|
|
109
|
+
return setup()
|
|
110
|
+
return _env
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Component base class ───────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(kw_only=True)
|
|
117
|
+
class Component:
|
|
118
|
+
"""Base class for UI components.
|
|
119
|
+
|
|
120
|
+
A component is a typed data contract that maps to a Jinja2 template.
|
|
121
|
+
Dataclass fields become template context automatically. Override
|
|
122
|
+
:meth:`get_props` to add computed properties. Methods named
|
|
123
|
+
``get_*()`` are callable from templates.
|
|
124
|
+
|
|
125
|
+
This is the core primitive of the three-layer architecture:
|
|
126
|
+
|
|
127
|
+
*Routes* load data and pass it to builders.
|
|
128
|
+
*Builders* create and compose Component instances into a tree.
|
|
129
|
+
*Templates* render their own markup and place children.
|
|
130
|
+
|
|
131
|
+
Nested ``Component`` instances are automatically pre-rendered to
|
|
132
|
+
``Markup`` before reaching the template — you never need ``|safe``
|
|
133
|
+
or manual ``render()`` calls for children.
|
|
134
|
+
|
|
135
|
+
Usage::
|
|
136
|
+
|
|
137
|
+
@dataclass(kw_only=True)
|
|
138
|
+
class Greeting(Component):
|
|
139
|
+
template_path: ClassVar = "greeting.html"
|
|
140
|
+
name: str = ""
|
|
141
|
+
items: list = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
def get_props(self):
|
|
144
|
+
return {"count": len(self.items)}
|
|
145
|
+
|
|
146
|
+
The template at ``greeting.html`` receives ``{{ name }}``,
|
|
147
|
+
``{{ items }}``, and ``{{ count }}``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
template_path: ClassVar[str] = ""
|
|
151
|
+
_joist_env: ClassVar[Environment | None] = None
|
|
152
|
+
|
|
153
|
+
#: Reserved — excluded from template context. Pass a request-like
|
|
154
|
+
#: object when you need URL generation or other request data in
|
|
155
|
+
#: ``get_props()``.
|
|
156
|
+
request: Any | None = field(default=None, repr=False)
|
|
157
|
+
|
|
158
|
+
# ── introspection ────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def has_any(self) -> bool:
|
|
162
|
+
"""Whether the component yields visible content.
|
|
163
|
+
|
|
164
|
+
Override to ``False`` when the component should be invisible
|
|
165
|
+
(e.g. empty list, missing data). Parent components can check
|
|
166
|
+
this to decide whether to render a wrapper.
|
|
167
|
+
"""
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
# ── rendering ───────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def get_props(self) -> dict[str, Any]:
|
|
173
|
+
"""Override to inject computed properties into the template context.
|
|
174
|
+
|
|
175
|
+
The returned dict is merged *on top of* the dataclass fields so
|
|
176
|
+
it can shadow raw field values with formatted versions.
|
|
177
|
+
"""
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
def props(self) -> dict[str, Any]:
|
|
181
|
+
"""Return the full template context dict (public, for introspection).
|
|
182
|
+
|
|
183
|
+
Order of precedence (later wins):
|
|
184
|
+
1. Dataclass fields (excluding ``request``, private fields).
|
|
185
|
+
2. ``get_props()`` computed values.
|
|
186
|
+
3. ``get_*`` methods (callable from templates, but do not
|
|
187
|
+
override fields or props).
|
|
188
|
+
|
|
189
|
+
You can inspect this dict for debugging or testing without
|
|
190
|
+
rendering the template.
|
|
191
|
+
"""
|
|
192
|
+
ctx: dict[str, Any] = {}
|
|
193
|
+
|
|
194
|
+
for f in fields(self):
|
|
195
|
+
if f.name == "request" or f.name.startswith("_"):
|
|
196
|
+
continue
|
|
197
|
+
value = getattr(self, f.name)
|
|
198
|
+
if value is None and f.metadata.get("exclude_if_none") is True:
|
|
199
|
+
continue
|
|
200
|
+
ctx[f.name] = _resolve(value)
|
|
201
|
+
|
|
202
|
+
ctx.update(self.get_props())
|
|
203
|
+
|
|
204
|
+
for method_name, method in inspect.getmembers(
|
|
205
|
+
self, predicate=inspect.ismethod
|
|
206
|
+
):
|
|
207
|
+
if method_name.startswith("get_") and not method_name.startswith(
|
|
208
|
+
"_get_"
|
|
209
|
+
):
|
|
210
|
+
if method_name not in ctx:
|
|
211
|
+
ctx[method_name] = method
|
|
212
|
+
|
|
213
|
+
return ctx
|
|
214
|
+
|
|
215
|
+
def render_html(self) -> Markup:
|
|
216
|
+
"""Render the component to safe HTML markup.
|
|
217
|
+
|
|
218
|
+
Returns a ``Markup`` object that Jinja2 and other template
|
|
219
|
+
engines treat as safe — no ``|safe`` filter needed when
|
|
220
|
+
embedding in parent templates.
|
|
221
|
+
"""
|
|
222
|
+
if not self.template_path:
|
|
223
|
+
raise ComponentError(
|
|
224
|
+
f"{type(self).__name__} has no template_path set"
|
|
225
|
+
)
|
|
226
|
+
env = self._resolve_env()
|
|
227
|
+
template = env.get_template(self.template_path)
|
|
228
|
+
return Markup(template.render(self.props()))
|
|
229
|
+
|
|
230
|
+
def render(self) -> str:
|
|
231
|
+
"""Render to a plain HTML string (same as ``str(component)``)."""
|
|
232
|
+
return str(self.render_html())
|
|
233
|
+
|
|
234
|
+
def __str__(self) -> str:
|
|
235
|
+
return self.render()
|
|
236
|
+
|
|
237
|
+
def __html__(self) -> str:
|
|
238
|
+
"""Jinja2 / MarkupSafe protocol — same as ``render_html()``.
|
|
239
|
+
|
|
240
|
+
This allows ``Markup(component)`` and direct embedding in
|
|
241
|
+
parent templates via the ``|safe`` protocol.
|
|
242
|
+
"""
|
|
243
|
+
return str(self.render_html())
|
|
244
|
+
|
|
245
|
+
# ── utilities ───────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def _resolve_env(cls) -> Environment:
|
|
249
|
+
if cls._joist_env is None:
|
|
250
|
+
cls._joist_env = get_env()
|
|
251
|
+
return cls._joist_env
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── Fragment (inline HTMX partial) ─────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(kw_only=True)
|
|
258
|
+
class Fragment(Component):
|
|
259
|
+
"""Lightweight component for HTMX partials.
|
|
260
|
+
|
|
261
|
+
Unlike :class:`Component`, ``template_path`` is an *instance* field,
|
|
262
|
+
which lets you create fragments inline for HTMX partial routes::
|
|
263
|
+
|
|
264
|
+
@router.get("/users/{uid}/card")
|
|
265
|
+
async def user_card(request: Request, uid: str):
|
|
266
|
+
user = await load_user(uid)
|
|
267
|
+
return Fragment(
|
|
268
|
+
template_path="users/_card.html",
|
|
269
|
+
name=user.name,
|
|
270
|
+
role=user.role,
|
|
271
|
+
).render_html()
|
|
272
|
+
|
|
273
|
+
For reusable HTMX partials, subclass with typed fields::
|
|
274
|
+
|
|
275
|
+
@dataclass(kw_only=True)
|
|
276
|
+
class UserRow(Fragment):
|
|
277
|
+
template_path: str = "users/_row.html"
|
|
278
|
+
user_id: str = ""
|
|
279
|
+
name: str = ""
|
|
280
|
+
role: str = ""
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
template_path: str = ""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── Page shell ─────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(kw_only=True)
|
|
290
|
+
class Page(Component):
|
|
291
|
+
"""Full HTML document shell.
|
|
292
|
+
|
|
293
|
+
Wraps content in a ``<!DOCTYPE html>`` document with ``<head>``
|
|
294
|
+
and ``<body>``. The default template is minimal — override or
|
|
295
|
+
subclass for your own layout.
|
|
296
|
+
|
|
297
|
+
Designed for the :func:`~joist.htmx.render_page_or_fragment`
|
|
298
|
+
pattern: direct navigation returns a ``Page``, HTMX requests
|
|
299
|
+
return just the content component.
|
|
300
|
+
|
|
301
|
+
The ``content`` field accepts either pre-rendered ``Markup``
|
|
302
|
+
(from ``component.render_html()``) or a ``Component`` instance
|
|
303
|
+
directly. When a ``Component`` is passed, it is automatically
|
|
304
|
+
rendered before reaching the template.
|
|
305
|
+
|
|
306
|
+
Usage::
|
|
307
|
+
|
|
308
|
+
# Explicit (builder pattern — recommended):
|
|
309
|
+
Page(page_title="Hi", content=card.render_html())
|
|
310
|
+
|
|
311
|
+
# Implicit (convenient — works with AI agents):
|
|
312
|
+
Page(page_title="Hi", content=card)
|
|
313
|
+
|
|
314
|
+
The default template produces::
|
|
315
|
+
|
|
316
|
+
<!DOCTYPE html>
|
|
317
|
+
<html lang="zh-CN">
|
|
318
|
+
<head>
|
|
319
|
+
<meta charset="utf-8">
|
|
320
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
321
|
+
<title>{{ page_title }}</title>
|
|
322
|
+
{{ head_extras }}
|
|
323
|
+
</head>
|
|
324
|
+
<body>
|
|
325
|
+
{{ content }}
|
|
326
|
+
</body>
|
|
327
|
+
</html>
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
template_path: ClassVar[str] = "joist/page.html"
|
|
331
|
+
|
|
332
|
+
page_title: str = ""
|
|
333
|
+
content: Component | Markup | str = Markup("")
|
|
334
|
+
head_extras: Markup = Markup("")
|
|
335
|
+
body_attrs: dict[str, str] = field(default_factory=dict)
|
|
336
|
+
lang: str = "zh-CN"
|
|
337
|
+
|
|
338
|
+
def get_props(self) -> dict[str, Any]:
|
|
339
|
+
"""Ensure content is pre-rendered if a Component was passed.
|
|
340
|
+
|
|
341
|
+
This makes ``Page(content=my_component)`` work without an
|
|
342
|
+
explicit ``.render_html()`` call — convenient when an AI
|
|
343
|
+
agent or a new user is assembling the page.
|
|
344
|
+
"""
|
|
345
|
+
if isinstance(self.content, Component):
|
|
346
|
+
return {"content": self.content.render_html()}
|
|
347
|
+
return {}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ── Pure helpers ───────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def render_component(component: Component) -> Markup:
|
|
354
|
+
"""Render a component to ``Markup``.
|
|
355
|
+
|
|
356
|
+
Convenience alias for ``component.render_html()``.
|
|
357
|
+
"""
|
|
358
|
+
return component.render_html()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def render_template(template_name: str, **context: Any) -> Markup:
|
|
362
|
+
"""Render a template directly without a component class.
|
|
363
|
+
|
|
364
|
+
Useful for one-off snippets or when migrating existing code.
|
|
365
|
+
|
|
366
|
+
Example::
|
|
367
|
+
|
|
368
|
+
html = render_template("emails/welcome.html", name="Alice")
|
|
369
|
+
"""
|
|
370
|
+
env = get_env()
|
|
371
|
+
return Markup(env.get_template(template_name).render(context))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ── Internals ──────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class ComponentError(Exception):
|
|
378
|
+
"""Raised for component system errors."""
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resolve(value: Any) -> Any:
|
|
382
|
+
"""Recursively render nested ``Component`` instances to ``Markup``.
|
|
383
|
+
|
|
384
|
+
This is what makes the component tree work:
|
|
385
|
+
- A ``Component`` child becomes pre-rendered ``Markup``.
|
|
386
|
+
- Lists and dicts are traversed recursively.
|
|
387
|
+
- Everything else passes through unchanged.
|
|
388
|
+
|
|
389
|
+
Templates can therefore use ``{{ child }}`` directly instead of
|
|
390
|
+
``{{ child.render_html() }}``.
|
|
391
|
+
"""
|
|
392
|
+
if isinstance(value, Component):
|
|
393
|
+
return value.render_html()
|
|
394
|
+
if isinstance(value, dict):
|
|
395
|
+
return {k: _resolve(v) for k, v in value.items()}
|
|
396
|
+
if isinstance(value, (list, tuple)):
|
|
397
|
+
return [_resolve(v) for v in value]
|
|
398
|
+
return value
|
joist/htmx.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""joist/htmx — HTMX request detection and response utilities.
|
|
2
|
+
|
|
3
|
+
Provides helpers for the HTMX response pattern:
|
|
4
|
+
|
|
5
|
+
* :func:`is_hx_request` — detect HTMX requests
|
|
6
|
+
* :class:`HX` — namespace for response header builders
|
|
7
|
+
* :class:`RenderResult` — typed result of rendering a component
|
|
8
|
+
* :func:`render_page_or_fragment` — one path for both HTMX and direct
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from markupsafe import Markup
|
|
17
|
+
|
|
18
|
+
from joist.component import Component, Page, render_component
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"is_hx_request",
|
|
22
|
+
"HX",
|
|
23
|
+
"RenderResult",
|
|
24
|
+
"render_page_or_fragment",
|
|
25
|
+
"hx_trigger",
|
|
26
|
+
"hx_retarget",
|
|
27
|
+
"hx_redirect",
|
|
28
|
+
"hx_refresh",
|
|
29
|
+
"hx_push_url",
|
|
30
|
+
"hx_replace_url",
|
|
31
|
+
"hx_reselect",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_hx_request(request: Any) -> bool:
|
|
36
|
+
"""Return ``True`` when *request* is an HTMX request.
|
|
37
|
+
|
|
38
|
+
Accepts any object with a ``.headers`` mapping (FastAPI ``Request``,
|
|
39
|
+
Starlette ``Request``, Django ``HttpRequest``, etc.).
|
|
40
|
+
"""
|
|
41
|
+
return request.headers.get("HX-Request") == "true"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── HX-Response-* header builders ──────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HX:
|
|
48
|
+
"""Namespace for HTMX response header builders.
|
|
49
|
+
|
|
50
|
+
Each method returns a ``dict`` of response headers that can be
|
|
51
|
+
unpacked into a ``Response`` constructor::
|
|
52
|
+
|
|
53
|
+
from joist.htmx import HX
|
|
54
|
+
|
|
55
|
+
response = HTMLResponse(content=html, **HX.trigger("toast", "Saved!"))
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def trigger(
|
|
60
|
+
name: str, value: Any = None, *, after: str | None = None
|
|
61
|
+
) -> dict[str, str]:
|
|
62
|
+
"""Return ``HX-Trigger`` headers.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Event name (e.g. ``"toast"``, ``"reload-table"``).
|
|
66
|
+
value: Optional JSON-serialisable payload.
|
|
67
|
+
after: ``"settle"`` or ``"swap"`` — use
|
|
68
|
+
``HX-Trigger-After-Settle`` or
|
|
69
|
+
``HX-Trigger-After-Swap`` instead of ``HX-Trigger``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
``{"HX-Trigger": '...'}`` (or ``-After-Settle`` /
|
|
73
|
+
``-After-Swap`` variant).
|
|
74
|
+
|
|
75
|
+
A trigger without a payload is emitted as a plain string::
|
|
76
|
+
|
|
77
|
+
HX.trigger("toast") # HX-Trigger: toast
|
|
78
|
+
|
|
79
|
+
A trigger with a payload is serialised as a JSON object::
|
|
80
|
+
|
|
81
|
+
HX.trigger("toast", {"msg": "Saved!"}) # HX-Trigger: {"toast": ...}
|
|
82
|
+
"""
|
|
83
|
+
header = "HX-Trigger"
|
|
84
|
+
if after == "settle":
|
|
85
|
+
header = "HX-Trigger-After-Settle"
|
|
86
|
+
elif after == "swap":
|
|
87
|
+
header = "HX-Trigger-After-Swap"
|
|
88
|
+
|
|
89
|
+
if value is not None:
|
|
90
|
+
import json
|
|
91
|
+
raw = json.dumps({name: value}, ensure_ascii=False)
|
|
92
|
+
else:
|
|
93
|
+
raw = name
|
|
94
|
+
|
|
95
|
+
return {header: raw}
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def retarget(target: str) -> dict[str, str]:
|
|
99
|
+
"""Return ``HX-Retarget`` header to change the swap target.
|
|
100
|
+
|
|
101
|
+
Example::
|
|
102
|
+
|
|
103
|
+
return HTMLResponse(content=html, **HX.retarget("#toast-container"))
|
|
104
|
+
"""
|
|
105
|
+
return {"HX-Retarget": target}
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def redirect(url: str) -> dict[str, str]:
|
|
109
|
+
"""Return ``HX-Redirect`` header for client-side redirect."""
|
|
110
|
+
return {"HX-Redirect": url}
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def refresh() -> dict[str, str]:
|
|
114
|
+
"""Return ``HX-Refresh`` header to refresh the current page."""
|
|
115
|
+
return {"HX-Refresh": "true"}
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def push_url(url: str) -> dict[str, str]:
|
|
119
|
+
"""Return ``HX-Push-Url`` header to push a new browser history entry."""
|
|
120
|
+
return {"HX-Push-Url": url}
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def replace_url(url: str) -> dict[str, str]:
|
|
124
|
+
"""Return ``HX-Replace-Url`` header to replace the current URL."""
|
|
125
|
+
return {"HX-Replace-Url": url}
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def reselect(css_selector: str) -> dict[str, str]:
|
|
129
|
+
"""Return ``HX-Reselect`` header to override the swap target selection."""
|
|
130
|
+
return {"HX-Reselect": css_selector}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Convenience aliases
|
|
134
|
+
hx_trigger = HX.trigger
|
|
135
|
+
hx_retarget = HX.retarget
|
|
136
|
+
hx_redirect = HX.redirect
|
|
137
|
+
hx_refresh = HX.refresh
|
|
138
|
+
hx_push_url = HX.push_url
|
|
139
|
+
hx_replace_url = HX.replace_url
|
|
140
|
+
hx_reselect = HX.reselect
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Render result ──────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class RenderResult:
|
|
148
|
+
"""Typed result of rendering a component as an HTMX fragment or
|
|
149
|
+
full-page document.
|
|
150
|
+
|
|
151
|
+
Returned by :func:`render_page_or_fragment`. Framework integrations
|
|
152
|
+
(e.g. FastAPI) consume this to produce a proper ``Response``.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
content: The rendered HTML string.
|
|
156
|
+
headers: Response headers (e.g. ``HX-Trigger``).
|
|
157
|
+
is_fragment: ``True`` when the result is an HTMX fragment
|
|
158
|
+
(just the component), ``False`` when it is a
|
|
159
|
+
full ``Page`` document.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
content: str = ""
|
|
163
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
164
|
+
is_fragment: bool = False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Render helpers ─────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_page_or_fragment(
|
|
171
|
+
request: Any,
|
|
172
|
+
page_title: str,
|
|
173
|
+
content: Component,
|
|
174
|
+
*,
|
|
175
|
+
page_cls: type[Page] = Page,
|
|
176
|
+
page_kwargs: dict[str, Any] | None = None,
|
|
177
|
+
hx_trigger_headers: dict[str, str] | None = None,
|
|
178
|
+
) -> RenderResult:
|
|
179
|
+
"""Render a fragment for HTMX, or a full page for direct navigation.
|
|
180
|
+
|
|
181
|
+
HTMX requests receive only the content component's HTML.
|
|
182
|
+
Direct navigation receives a ``Page`` shell wrapping that content.
|
|
183
|
+
|
|
184
|
+
Framework integrations (FastAPI, Starlette) consume the returned
|
|
185
|
+
``RenderResult`` to produce a proper ``Response``. Bare users can
|
|
186
|
+
use the ``.content`` and ``.headers`` attributes directly.
|
|
187
|
+
|
|
188
|
+
Usage with FastAPI::
|
|
189
|
+
|
|
190
|
+
@router.get("/users")
|
|
191
|
+
async def users_page(request: Request):
|
|
192
|
+
table = UserTable(users=await load_users())
|
|
193
|
+
result = render_page_or_fragment(request, "Users", table)
|
|
194
|
+
return HTMLResponse(
|
|
195
|
+
content=result.content,
|
|
196
|
+
headers=result.headers or None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
request: Incoming request object (must have ``.headers``).
|
|
201
|
+
page_title: Value for ``<title>``.
|
|
202
|
+
content: The main content component.
|
|
203
|
+
page_cls: Page shell class (default :class:`Page`).
|
|
204
|
+
page_kwargs: Extra kwargs for the ``Page`` constructor.
|
|
205
|
+
hx_trigger_headers: Additional HTMX trigger headers.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A :class:`RenderResult` with ``.content``, ``.headers``,
|
|
209
|
+
and ``.is_fragment``.
|
|
210
|
+
"""
|
|
211
|
+
content_html = render_component(content)
|
|
212
|
+
headers: dict[str, str] = {}
|
|
213
|
+
if hx_trigger_headers:
|
|
214
|
+
headers.update(hx_trigger_headers)
|
|
215
|
+
|
|
216
|
+
if is_hx_request(request):
|
|
217
|
+
return RenderResult(
|
|
218
|
+
content=str(content_html),
|
|
219
|
+
headers=headers,
|
|
220
|
+
is_fragment=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
kw = page_kwargs or {}
|
|
224
|
+
page = page_cls(
|
|
225
|
+
request=request,
|
|
226
|
+
page_title=page_title,
|
|
227
|
+
content=content_html,
|
|
228
|
+
**kw,
|
|
229
|
+
)
|
|
230
|
+
return RenderResult(
|
|
231
|
+
content=str(page.render_html()),
|
|
232
|
+
headers=headers,
|
|
233
|
+
is_fragment=False,
|
|
234
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""joist FastAPI integration.
|
|
2
|
+
|
|
3
|
+
Provides :func:`setup_fastapi` to attach joist's rendering to a
|
|
4
|
+
FastAPI application.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
from joist.integrators import setup_fastapi
|
|
10
|
+
from joist import Fragment
|
|
11
|
+
|
|
12
|
+
app = FastAPI()
|
|
13
|
+
joist = setup_fastapi(app, template_dirs=["templates"])
|
|
14
|
+
|
|
15
|
+
@app.get("/hello")
|
|
16
|
+
async def hello(request: Request):
|
|
17
|
+
frag = Fragment(template_path="hello.html", name="World")
|
|
18
|
+
return joist.response(frag)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from joist.integrators.fastapi import (
|
|
22
|
+
Joist,
|
|
23
|
+
fragment_or_page,
|
|
24
|
+
html,
|
|
25
|
+
page,
|
|
26
|
+
setup_fastapi,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Joist",
|
|
31
|
+
"fragment_or_page",
|
|
32
|
+
"html",
|
|
33
|
+
"page",
|
|
34
|
+
"setup_fastapi",
|
|
35
|
+
]
|