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 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
+ ]