django-htmx-plus 0.0.0__tar.gz
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.
- django_htmx_plus-0.0.0/LICENSE.md +21 -0
- django_htmx_plus-0.0.0/PKG-INFO +359 -0
- django_htmx_plus-0.0.0/README.md +340 -0
- django_htmx_plus-0.0.0/django_htmx_plus/__init__.py +0 -0
- django_htmx_plus-0.0.0/django_htmx_plus/http.py +47 -0
- django_htmx_plus-0.0.0/django_htmx_plus/middleware.py +70 -0
- django_htmx_plus-0.0.0/django_htmx_plus/mixins.py +41 -0
- django_htmx_plus-0.0.0/django_htmx_plus/static/django_htmx_plus/django-htmx-plus.js +39 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/icons/chevron_down.html +6 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/icons/chevron_left.html +6 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/icons/chevron_right.html +6 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/icons/chevron_up.html +6 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/tables/head.html +5 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/tables/header_cell.html +15 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/tables/htmx_table.html +22 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templates/cotton/tables/pager.html +35 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templatetags/__init__.py +0 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templatetags/cotton_extras.py +17 -0
- django_htmx_plus-0.0.0/django_htmx_plus/templatetags/django_htmx_plus.py +16 -0
- django_htmx_plus-0.0.0/django_htmx_plus/types.py +21 -0
- django_htmx_plus-0.0.0/django_htmx_plus/utils.py +111 -0
- django_htmx_plus-0.0.0/django_htmx_plus/views.py +168 -0
- django_htmx_plus-0.0.0/pyproject.toml +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-htmx-plus
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: django-htmx plus some extras
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE.md
|
|
7
|
+
Author: Tim Davis
|
|
8
|
+
Author-email: binary.god@gmail.com
|
|
9
|
+
Requires-Python: >=3.11,<4
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: django-cotton (>=2.6.2,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# django-htmx-plus
|
|
20
|
+
|
|
21
|
+
A Django utility library that works with [django-cotton](https://github.com/wrabit/django-cotton) to provide ready-made views, mixins, middleware, response helpers, and components for building HTMX-powered list views with filtering, sorting, and pagination.
|
|
22
|
+
|
|
23
|
+
This package was created for my own projects but it was handy enough to share, I learned how to use HTMX with Django and Boostrap Modals from the following two posts from [Josh Karamuth](https://www.linkedin.com/in/josh-karamuth/):
|
|
24
|
+
* [How to show a modal in Django + HTMX](https://joshkaramuth.com/blog/django-htmx-modal/)
|
|
25
|
+
* [Show Django forms inside a modal using HTMX](https://joshkaramuth.com/blog/django-htmx-modal-forms/)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **`HtmxResponse`** – a 204 No Content response that fires `HX-Trigger` events on the client.
|
|
32
|
+
- **`HtmxRedirectResponse`** – a response that sends an `HX-Redirect` header to navigate the browser.
|
|
33
|
+
- **`HtmxMessagesMiddleware`** – automatically forwards Django messages to the client via the `HX-Trigger` header on every HTMX request.
|
|
34
|
+
- **`HtmxFormResponseMixin`** – a `FormView`/`CreateView` mixin that replaces the success redirect with an HTMX trigger response.
|
|
35
|
+
- **`HtmxListView`** – a `ListView` subclass with built-in URL-based filtering, column sorting, and elided pagination.
|
|
36
|
+
- **Filter helpers** – parse `field.filter_type=value` query parameters safely into Django ORM filter dicts.
|
|
37
|
+
- **Cotton components** – drop-in table, header cell, and pager components styled for Bootstrap 5.
|
|
38
|
+
- **Template filters** – `get_attr` and `get_key_value` for accessing object attributes and dict values in templates.
|
|
39
|
+
- **Built-in JavaScript helper** – a `django-htmx-plus.js` ES module that wires Bootstrap 5 Modals and Offcanvases to HTMX swap events, with a `{% htmx_plus_script %}` template tag to include it.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Python 3.11+
|
|
46
|
+
- Django (any version compatible with the above)
|
|
47
|
+
- [django-cotton](https://github.com/wrabit/django-cotton) ≥ 2.6
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install django-htmx-plus
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add to `INSTALLED_APPS` and configure middleware in `settings.py`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
INSTALLED_APPS = [
|
|
61
|
+
# ...
|
|
62
|
+
"django_cotton",
|
|
63
|
+
"django_htmx_plus",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
MIDDLEWARE = [
|
|
67
|
+
# ...
|
|
68
|
+
"django_htmx_plus.middleware.HtmxMessagesMiddleware",
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### HtmxResponse
|
|
77
|
+
|
|
78
|
+
Return a `204 No Content` response that triggers one or more HTMX events on the client:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from django_htmx_plus.http import HtmxResponse
|
|
82
|
+
|
|
83
|
+
def my_view(request):
|
|
84
|
+
# ... do some work ...
|
|
85
|
+
return HtmxResponse(triggers=["itemUpdated", "refreshStats"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The response sets `HX-Trigger: itemUpdated,refreshStats`, which HTMX picks up to re-fetch any elements listening for those events.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### HtmxRedirectResponse
|
|
93
|
+
|
|
94
|
+
Instruct HTMX to navigate the browser to a new URL without a traditional HTTP redirect:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from django_htmx_plus.http import HtmxRedirectResponse
|
|
98
|
+
|
|
99
|
+
def my_view(request):
|
|
100
|
+
return HtmxRedirectResponse(destination="/dashboard/")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### HtmxMessagesMiddleware
|
|
106
|
+
|
|
107
|
+
Once the middleware is installed, any Django message added during an HTMX request is automatically serialised into the `HX-Trigger` header as a `messages` key:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"messages": [
|
|
112
|
+
{"message": "Record saved.", "tags": "success"}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If the view already sets its own `HX-Trigger` header (either string or JSON object syntax), the middleware merges the messages in without overwriting existing triggers.
|
|
118
|
+
|
|
119
|
+
Listen for the `messages` event in your HTMX setup to display the messages, for example:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
document.body.addEventListener("messages", (event) => {
|
|
123
|
+
event.detail.value.forEach(({message, tags}) => {
|
|
124
|
+
showToast(message, tags); // your toast implementation
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### HtmxFormResponseMixin
|
|
132
|
+
|
|
133
|
+
Mix into any `FormView` or `CreateView` to replace the default success redirect with an HTMX trigger response:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from django.views.generic.edit import CreateView
|
|
137
|
+
from django_htmx_plus.mixins import HtmxFormResponseMixin
|
|
138
|
+
from myapp.models import Article
|
|
139
|
+
from myapp.forms import ArticleForm
|
|
140
|
+
|
|
141
|
+
class ArticleCreateView(HtmxFormResponseMixin, CreateView):
|
|
142
|
+
model = Article
|
|
143
|
+
form_class = ArticleForm
|
|
144
|
+
template_name = "articles/form.html"
|
|
145
|
+
|
|
146
|
+
valid_triggers = ["articleCreated"]
|
|
147
|
+
success_message = "Article created successfully."
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
After a valid submission, `form.save()` is called, an optional Django success message is queued, and an `HtmxResponse` carrying `articleCreated` in `HX-Trigger` is returned.
|
|
151
|
+
|
|
152
|
+
| Attribute | Type | Description |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `valid_triggers` | `List[str]` | Events to include in `HX-Trigger` on success. |
|
|
155
|
+
| `success_message` | `str` | Optional Django success message to queue. |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### HtmxListView
|
|
160
|
+
|
|
161
|
+
A drop-in replacement for Django's `ListView` that adds URL-driven filtering, sorting, and elided pagination:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from django.views.generic import ListView
|
|
165
|
+
from django_htmx_plus.views import HtmxListView
|
|
166
|
+
from myapp.models import Article
|
|
167
|
+
|
|
168
|
+
class ArticleListView(HtmxListView):
|
|
169
|
+
model = Article
|
|
170
|
+
template_name = "articles/list.html"
|
|
171
|
+
paginate_by = 20
|
|
172
|
+
target_id = "#article-table"
|
|
173
|
+
|
|
174
|
+
# Restrict filtering and sorting to these fields only
|
|
175
|
+
fields = ("id", "title", "status", "created_at")
|
|
176
|
+
|
|
177
|
+
# Optional custom column labels
|
|
178
|
+
labels = {
|
|
179
|
+
"created_at": "Date Created",
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### URL-based filtering
|
|
184
|
+
|
|
185
|
+
Filters are expressed as `field_name.filter_type=value` query parameters:
|
|
186
|
+
|
|
187
|
+
| Filter key | Django lookup | Example |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `eq` | `exact` | `status.eq=published` |
|
|
190
|
+
| `ieq` | `iexact` | `title.ieq=hello` |
|
|
191
|
+
| `gt` / `gte` / `lt` / `lte` | `gt` / `gte` / `lt` / `lte` | `views.gte=100` |
|
|
192
|
+
| `like` / `ilike` | `like` / `ilike` | `title.ilike=django` |
|
|
193
|
+
| `sw` / `isw` | `startswith` / `istartswith` | `title.sw=Django` |
|
|
194
|
+
| `ew` / `iew` | `endswith` / `iendswith` | `title.ew=plus` |
|
|
195
|
+
| `in` | `in` | `status.in=['draft','published']` |
|
|
196
|
+
| `nl` | `isnull` | `deleted_at.nl=True` |
|
|
197
|
+
| `rng` | `range` | `created_at.rng=['2024-01-01','2024-12-31']` |
|
|
198
|
+
| `sch` | `search` | `body.sch=htmx` |
|
|
199
|
+
|
|
200
|
+
Only fields listed in `fields` are accepted. Setting `fields = ("__all__",)` lifts the restriction.
|
|
201
|
+
|
|
202
|
+
#### Sorting
|
|
203
|
+
|
|
204
|
+
Add `order_by=field_name` (or `order_by=-field_name` for descending) to the query string. Only fields in `fields` are permitted.
|
|
205
|
+
|
|
206
|
+
#### Context variables
|
|
207
|
+
|
|
208
|
+
| Variable | Description |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `query_params` | URL-encoded query string (without `page`). |
|
|
211
|
+
| `page_range` | Elided page range for pagination controls. |
|
|
212
|
+
| `order_by` | The currently active ordering field. |
|
|
213
|
+
| `path` | The current request path. |
|
|
214
|
+
| `target_id` | The HTMX target element ID. |
|
|
215
|
+
| `query` | Full query string including `order_by`. |
|
|
216
|
+
| `filter_query` | Query string containing only filter parameters. |
|
|
217
|
+
| `filters` | Template-ready dict mapping plain field names to their filter values. |
|
|
218
|
+
| `fields` | Dict with `keys` (field names) and `labels` (display names). |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### Cotton Table Components
|
|
223
|
+
|
|
224
|
+
`django-htmx-plus` ships a set of [django-cotton](https://github.com/wrabit/django-cotton) components for rendering sortable, paginated HTMX tables with Bootstrap 5.
|
|
225
|
+
|
|
226
|
+
#### `<c-tables.htmx_table />`
|
|
227
|
+
|
|
228
|
+
Renders a full table with auto-generated sortable headers, rows, and an optional pager:
|
|
229
|
+
|
|
230
|
+
```html
|
|
231
|
+
{% load cotton_extras %}
|
|
232
|
+
|
|
233
|
+
<c-tables.htmx_table class="table table-striped" />
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The component uses the `fields`, `objects`, `order_by`, `path`, `filter_query`, `target_id`, `page_obj`, and `paginator` context variables provided automatically by `HtmxListView`.
|
|
237
|
+
|
|
238
|
+
#### `<c-tables.header_cell name="field_name" />`
|
|
239
|
+
|
|
240
|
+
Renders a single `<th>` with an `hx-get` attribute that toggles ascending/descending order and a chevron icon indicating the current sort direction:
|
|
241
|
+
|
|
242
|
+
```html
|
|
243
|
+
<c-tables.head>
|
|
244
|
+
<c-tables.header_cell name="title">Title</c-tables.header_cell>
|
|
245
|
+
<c-tables.header_cell name="created_at">Date</c-tables.header_cell>
|
|
246
|
+
</c-tables.head>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
#### `<c-tables.pager />`
|
|
250
|
+
|
|
251
|
+
Renders a Bootstrap 5 pagination control with previous/next buttons and elided page numbers, all wired to `hx-get`.
|
|
252
|
+
|
|
253
|
+
#### Icon components
|
|
254
|
+
|
|
255
|
+
| Component | Description |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `<c-icons.chevron_up />` | Up chevron (ascending sort indicator). |
|
|
258
|
+
| `<c-icons.chevron_down />` | Down chevron (descending sort indicator). |
|
|
259
|
+
| `<c-icons.chevron_left />` | Left chevron (previous page). |
|
|
260
|
+
| `<c-icons.chevron_right />` | Right chevron (next page). |
|
|
261
|
+
|
|
262
|
+
All icon components accept an optional `add_class` attribute to append extra CSS classes.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
### Template filters
|
|
267
|
+
|
|
268
|
+
Load the `cotton_extras` tag library in any template:
|
|
269
|
+
|
|
270
|
+
```html
|
|
271
|
+
{% load cotton_extras %}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| Filter | Description | Example |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| `get_attr` | Get an attribute from an object by name. | `{{ item\|get_attr:"title" }}` |
|
|
277
|
+
| `get_key_value` | Get a value from a dict by key. | `{{ my_dict\|get_key_value:"name" }}` |
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
### Built-in JavaScript Helper
|
|
282
|
+
|
|
283
|
+
`django-htmx-plus` ships a small ES module (`django-htmx-plus.js`) that integrates Bootstrap 5 **Modals** and **Offcanvases** with HTMX swap events, so they open and close automatically based on HTMX responses.
|
|
284
|
+
|
|
285
|
+
#### How it works
|
|
286
|
+
|
|
287
|
+
| Behaviour | Trigger |
|
|
288
|
+
|---|---|
|
|
289
|
+
| Show a Modal or Offcanvas | HTMX swaps content into the element → `htmx:afterSwap` fires and calls `.show()`. |
|
|
290
|
+
| Hide a Modal or Offcanvas | HTMX receives an **empty** response targeting the element → `htmx:beforeSwap` fires, calls `.hide()`, and cancels the swap. |
|
|
291
|
+
| Reset Modal body | Bootstrap's `hidden.bs.modal` event fires → the modal body is cleared to `""`. |
|
|
292
|
+
|
|
293
|
+
#### Setup
|
|
294
|
+
|
|
295
|
+
Mark your Bootstrap Modal root elements with `data-htmx-plus-modal="<id>"` and your Offcanvas root elements with `data-htmx-plus-offcanvas="<id>"`, where id is the id of the element that will be swapped,:
|
|
296
|
+
|
|
297
|
+
```html
|
|
298
|
+
<!-- Bootstrap Modal managed by django-htmx-plus -->
|
|
299
|
+
<div class="modal fade" data-htmx-plus-modal="dialog">
|
|
300
|
+
<div id="dialog" class="modal-dialog">
|
|
301
|
+
<!-- Modal content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<!-- Bootstrap Offcanvas managed by django-htmx-plus -->
|
|
306
|
+
<div id="flyout" class="offcanvas offcanvas-end" data-htmx-plus-offcanvas="flyout">
|
|
307
|
+
<!-- Offcanvas content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
|
|
308
|
+
</div>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Then point an HTMX element at the matching target ID:
|
|
312
|
+
|
|
313
|
+
```html
|
|
314
|
+
<button hx-get="/person/add/" hx-target="#dialog">
|
|
315
|
+
Add Person
|
|
316
|
+
</button>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- When the response has content the modal/offcanvas is shown automatically.
|
|
320
|
+
- When the server returns an empty `200` (or you use `HtmxResponse`) the modal/offcanvas is hidden automatically.
|
|
321
|
+
|
|
322
|
+
#### Including the script
|
|
323
|
+
|
|
324
|
+
Use the `{% htmx_plus_script %}` template tag to render the `<script>` tag. The script is loaded as an ES module and optionally forwards a CSP nonce if one is present in the template context:
|
|
325
|
+
|
|
326
|
+
```html
|
|
327
|
+
{% load django_htmx_plus %}
|
|
328
|
+
|
|
329
|
+
<!-- Place near the bottom of your base template, after Bootstrap JS -->
|
|
330
|
+
{% htmx_plus_script %}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This renders:
|
|
334
|
+
|
|
335
|
+
```html
|
|
336
|
+
<script src="/static/django_htmx_plus/django-htmx-plus.js" type="module"></script>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
If a `nonce` variable is present in the template context it is automatically added as a `nonce="..."` attribute.
|
|
340
|
+
|
|
341
|
+
> **Note:** The script imports `Modal` and `Offcanvas` from `bootstrap`, so Bootstrap 5 must be available as an ES module (e.g. via an import map or a bundler). If you load Bootstrap as a plain global script instead, adjust your bundler or import map accordingly.
|
|
342
|
+
|
|
343
|
+
For example
|
|
344
|
+
```html
|
|
345
|
+
<script type="importmap">
|
|
346
|
+
{
|
|
347
|
+
"imports": {
|
|
348
|
+
"@popperjs/core": "{% static '@popperjs/core/dist/esm/index.js' %}",
|
|
349
|
+
"bootstrap": "{% static 'bootstrap/js/index.esm.js' %}",
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
</script>
|
|
353
|
+
```
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## License
|
|
357
|
+
|
|
358
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
359
|
+
|