django-hilo 0.1.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_hilo-0.1.0/.github/workflows/publish.yml +65 -0
- django_hilo-0.1.0/.github/workflows/test.yml +49 -0
- django_hilo-0.1.0/.gitignore +17 -0
- django_hilo-0.1.0/LICENSE +21 -0
- django_hilo-0.1.0/PKG-INFO +176 -0
- django_hilo-0.1.0/README.md +144 -0
- django_hilo-0.1.0/conftest.py +5 -0
- django_hilo-0.1.0/pyproject.toml +52 -0
- django_hilo-0.1.0/src/hilo/__init__.py +10 -0
- django_hilo-0.1.0/src/hilo/apps.py +7 -0
- django_hilo-0.1.0/src/hilo/decorators.py +107 -0
- django_hilo-0.1.0/src/hilo/middleware/__init__.py +3 -0
- django_hilo-0.1.0/src/hilo/middleware/hilo.py +137 -0
- django_hilo-0.1.0/src/hilo/response.py +92 -0
- django_hilo-0.1.0/src/hilo/templatetags/__init__.py +0 -0
- django_hilo-0.1.0/src/hilo/templatetags/hilo.py +207 -0
- django_hilo-0.1.0/tests/__init__.py +0 -0
- django_hilo-0.1.0/tests/settings.py +30 -0
- django_hilo-0.1.0/tests/test_decorators.py +52 -0
- django_hilo-0.1.0/tests/test_middleware.py +120 -0
- django_hilo-0.1.0/tests/urls.py +25 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
target:
|
|
9
|
+
description: 'Publish target'
|
|
10
|
+
required: true
|
|
11
|
+
default: 'testpypi'
|
|
12
|
+
type: choice
|
|
13
|
+
options:
|
|
14
|
+
- testpypi
|
|
15
|
+
- pypi
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
name: Build distribution
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: '3.12'
|
|
26
|
+
- name: Install build tools
|
|
27
|
+
run: pip install build
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: python -m build
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish-pypi:
|
|
36
|
+
name: Publish to PyPI
|
|
37
|
+
needs: build
|
|
38
|
+
if: github.event_name == 'release'
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
environment: pypi
|
|
41
|
+
permissions:
|
|
42
|
+
id-token: write
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/download-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
49
|
+
|
|
50
|
+
publish-testpypi:
|
|
51
|
+
name: Publish to TestPyPI
|
|
52
|
+
needs: build
|
|
53
|
+
if: github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'testpypi'
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
environment: testpypi
|
|
56
|
+
permissions:
|
|
57
|
+
id-token: write
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/download-artifact@v4
|
|
60
|
+
with:
|
|
61
|
+
name: dist
|
|
62
|
+
path: dist/
|
|
63
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
64
|
+
with:
|
|
65
|
+
repository-url: https://test.pypi.org/legacy/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Python ${{ matrix.python }} / Django ${{ matrix.django }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python: ['3.10', '3.11', '3.12', '3.13']
|
|
17
|
+
django: ['4.2', '5.0', '5.1', '6.0']
|
|
18
|
+
exclude:
|
|
19
|
+
- python: '3.10'
|
|
20
|
+
django: '6.0'
|
|
21
|
+
- python: '3.11'
|
|
22
|
+
django: '6.0'
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python }}
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: |
|
|
30
|
+
pip install -e ".[dev]" 2>/dev/null || pip install -e .
|
|
31
|
+
pip install "Django~=${{ matrix.django }}.0"
|
|
32
|
+
pip install pytest pytest-django
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: pytest tests/ -v
|
|
35
|
+
|
|
36
|
+
lint:
|
|
37
|
+
name: Lint
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
- uses: actions/setup-python@v5
|
|
42
|
+
with:
|
|
43
|
+
python-version: '3.12'
|
|
44
|
+
- name: Install ruff
|
|
45
|
+
run: pip install ruff
|
|
46
|
+
- name: Check format
|
|
47
|
+
run: ruff format --check src/ tests/
|
|
48
|
+
- name: Check lint
|
|
49
|
+
run: ruff check src/ tests/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ERPlora
|
|
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,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-hilo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SPA framework for Django — one script tag turns Django templates into a full SPA
|
|
5
|
+
Project-URL: Homepage, https://github.com/ERPlora/django-hilo
|
|
6
|
+
Project-URL: Repository, https://github.com/ERPlora/django-hilo
|
|
7
|
+
Project-URL: Issues, https://github.com/ERPlora/django-hilo/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ERPlora/django-hilo/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Ioan Beilic <ioanbeilic@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 6.0
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: django>=4.2
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# django-hilo
|
|
34
|
+
|
|
35
|
+
SPA framework for Django. One `<script>` tag turns Django templates into a full SPA.
|
|
36
|
+
|
|
37
|
+
Works with [Hilo.js](https://github.com/ERPlora/hilo) — the client-side library that handles navigation, DOM morphing, signals, and real-time communication.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install django-hilo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# settings.py
|
|
47
|
+
INSTALLED_APPS = [
|
|
48
|
+
...
|
|
49
|
+
'hilo',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
MIDDLEWARE = [
|
|
53
|
+
...
|
|
54
|
+
'hilo.middleware.HiloMiddleware',
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### 1. Base template
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
{% load hilo %}
|
|
64
|
+
<!DOCTYPE html>
|
|
65
|
+
<html>
|
|
66
|
+
<head>
|
|
67
|
+
<title>{% block title %}My App{% endblock %}</title>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<nav data-permanent>
|
|
71
|
+
<a href="/">Home</a>
|
|
72
|
+
<a href="/products/">Products</a>
|
|
73
|
+
</nav>
|
|
74
|
+
|
|
75
|
+
{% hilo_content %}
|
|
76
|
+
{% block content %}{% endblock %}
|
|
77
|
+
{% endhilo_content %}
|
|
78
|
+
|
|
79
|
+
{% hilo_scripts %}
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Views with `@fragment`
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from hilo.decorators import fragment
|
|
88
|
+
|
|
89
|
+
@fragment('products/list.html')
|
|
90
|
+
def product_list(request):
|
|
91
|
+
return {'products': Product.objects.all()}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That's it. Links are intercepted automatically. No `hx-get`, no `hx-target`, no attributes needed.
|
|
95
|
+
|
|
96
|
+
## Features
|
|
97
|
+
|
|
98
|
+
### Middleware
|
|
99
|
+
|
|
100
|
+
`HiloMiddleware` handles:
|
|
101
|
+
- **Redirect conversion** — Django redirects become `X-Hilo-Redirect` headers (no full page reload)
|
|
102
|
+
- **Asset versioning** — `X-Hilo-Version` header for cache busting
|
|
103
|
+
- **Request detection** — `request.is_hilo` boolean available in views
|
|
104
|
+
|
|
105
|
+
### `@fragment` decorator
|
|
106
|
+
|
|
107
|
+
Renders the appropriate template based on request type:
|
|
108
|
+
- **Hilo request** → renders only the partial template (fragment)
|
|
109
|
+
- **Normal request** → renders the full page (with base layout)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
@fragment('products/list.html', 'products/page.html')
|
|
113
|
+
def product_list(request):
|
|
114
|
+
return {'products': Product.objects.all()}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Response helpers
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from hilo.response import hilo_redirect, hilo_trigger, hilo_title, hilo_url
|
|
121
|
+
|
|
122
|
+
# Redirect via Hilo (no full reload)
|
|
123
|
+
return hilo_redirect('/products/')
|
|
124
|
+
|
|
125
|
+
# Trigger client-side events
|
|
126
|
+
response = render(request, 'template.html', context)
|
|
127
|
+
return hilo_trigger(response, {'showMessage': {'message': 'Saved!', 'type': 'success'}})
|
|
128
|
+
|
|
129
|
+
# Set page title
|
|
130
|
+
return hilo_title(response, 'Products')
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Streaming actions (like Turbo Streams)
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from hilo.response import HiloStreamResponse
|
|
137
|
+
|
|
138
|
+
def add_message(request):
|
|
139
|
+
response = HiloStreamResponse()
|
|
140
|
+
response.append('#messages', '<div class="message">New message!</div>')
|
|
141
|
+
response.update('#counter', '<span>42</span>')
|
|
142
|
+
response.remove('#typing-indicator')
|
|
143
|
+
return response
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Template tags
|
|
147
|
+
|
|
148
|
+
```html
|
|
149
|
+
{% load hilo %}
|
|
150
|
+
|
|
151
|
+
{% hilo_scripts %} {# Inject hilo.min.js + config #}
|
|
152
|
+
{% hilo_content %}...{% endhilo_content %} {# Main content area #}
|
|
153
|
+
{% hilo_permanent %}...{% endhilo_permanent %} {# Never morphed #}
|
|
154
|
+
|
|
155
|
+
{# Streaming actions in templates #}
|
|
156
|
+
{% hilo_stream "append" "#messages" %}
|
|
157
|
+
<div class="message">{{ message.text }}</div>
|
|
158
|
+
{% endhilo_stream %}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Configuration
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# settings.py
|
|
165
|
+
HILO = {
|
|
166
|
+
'VERSION': 'auto', # Asset version ('auto', fixed string, or '')
|
|
167
|
+
'JS_PATH': 'js/hilo.min.js', # Path to hilo.min.js in STATIC_URL
|
|
168
|
+
'PREFIX': 'h', # Behavior prefix (data-h="toggle")
|
|
169
|
+
'DEBUG': False, # Enable debug logging
|
|
170
|
+
'NAV': True, # Enable SPA navigation (True, False, or dict)
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# django-hilo
|
|
2
|
+
|
|
3
|
+
SPA framework for Django. One `<script>` tag turns Django templates into a full SPA.
|
|
4
|
+
|
|
5
|
+
Works with [Hilo.js](https://github.com/ERPlora/hilo) — the client-side library that handles navigation, DOM morphing, signals, and real-time communication.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install django-hilo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# settings.py
|
|
15
|
+
INSTALLED_APPS = [
|
|
16
|
+
...
|
|
17
|
+
'hilo',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
MIDDLEWARE = [
|
|
21
|
+
...
|
|
22
|
+
'hilo.middleware.HiloMiddleware',
|
|
23
|
+
]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### 1. Base template
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
{% load hilo %}
|
|
32
|
+
<!DOCTYPE html>
|
|
33
|
+
<html>
|
|
34
|
+
<head>
|
|
35
|
+
<title>{% block title %}My App{% endblock %}</title>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<nav data-permanent>
|
|
39
|
+
<a href="/">Home</a>
|
|
40
|
+
<a href="/products/">Products</a>
|
|
41
|
+
</nav>
|
|
42
|
+
|
|
43
|
+
{% hilo_content %}
|
|
44
|
+
{% block content %}{% endblock %}
|
|
45
|
+
{% endhilo_content %}
|
|
46
|
+
|
|
47
|
+
{% hilo_scripts %}
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Views with `@fragment`
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from hilo.decorators import fragment
|
|
56
|
+
|
|
57
|
+
@fragment('products/list.html')
|
|
58
|
+
def product_list(request):
|
|
59
|
+
return {'products': Product.objects.all()}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That's it. Links are intercepted automatically. No `hx-get`, no `hx-target`, no attributes needed.
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
### Middleware
|
|
67
|
+
|
|
68
|
+
`HiloMiddleware` handles:
|
|
69
|
+
- **Redirect conversion** — Django redirects become `X-Hilo-Redirect` headers (no full page reload)
|
|
70
|
+
- **Asset versioning** — `X-Hilo-Version` header for cache busting
|
|
71
|
+
- **Request detection** — `request.is_hilo` boolean available in views
|
|
72
|
+
|
|
73
|
+
### `@fragment` decorator
|
|
74
|
+
|
|
75
|
+
Renders the appropriate template based on request type:
|
|
76
|
+
- **Hilo request** → renders only the partial template (fragment)
|
|
77
|
+
- **Normal request** → renders the full page (with base layout)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@fragment('products/list.html', 'products/page.html')
|
|
81
|
+
def product_list(request):
|
|
82
|
+
return {'products': Product.objects.all()}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Response helpers
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from hilo.response import hilo_redirect, hilo_trigger, hilo_title, hilo_url
|
|
89
|
+
|
|
90
|
+
# Redirect via Hilo (no full reload)
|
|
91
|
+
return hilo_redirect('/products/')
|
|
92
|
+
|
|
93
|
+
# Trigger client-side events
|
|
94
|
+
response = render(request, 'template.html', context)
|
|
95
|
+
return hilo_trigger(response, {'showMessage': {'message': 'Saved!', 'type': 'success'}})
|
|
96
|
+
|
|
97
|
+
# Set page title
|
|
98
|
+
return hilo_title(response, 'Products')
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Streaming actions (like Turbo Streams)
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from hilo.response import HiloStreamResponse
|
|
105
|
+
|
|
106
|
+
def add_message(request):
|
|
107
|
+
response = HiloStreamResponse()
|
|
108
|
+
response.append('#messages', '<div class="message">New message!</div>')
|
|
109
|
+
response.update('#counter', '<span>42</span>')
|
|
110
|
+
response.remove('#typing-indicator')
|
|
111
|
+
return response
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Template tags
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
{% load hilo %}
|
|
118
|
+
|
|
119
|
+
{% hilo_scripts %} {# Inject hilo.min.js + config #}
|
|
120
|
+
{% hilo_content %}...{% endhilo_content %} {# Main content area #}
|
|
121
|
+
{% hilo_permanent %}...{% endhilo_permanent %} {# Never morphed #}
|
|
122
|
+
|
|
123
|
+
{# Streaming actions in templates #}
|
|
124
|
+
{% hilo_stream "append" "#messages" %}
|
|
125
|
+
<div class="message">{{ message.text }}</div>
|
|
126
|
+
{% endhilo_stream %}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# settings.py
|
|
133
|
+
HILO = {
|
|
134
|
+
'VERSION': 'auto', # Asset version ('auto', fixed string, or '')
|
|
135
|
+
'JS_PATH': 'js/hilo.min.js', # Path to hilo.min.js in STATIC_URL
|
|
136
|
+
'PREFIX': 'h', # Behavior prefix (data-h="toggle")
|
|
137
|
+
'DEBUG': False, # Enable debug logging
|
|
138
|
+
'NAV': True, # Enable SPA navigation (True, False, or dict)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-hilo"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SPA framework for Django — one script tag turns Django templates into a full SPA"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "Ioan Beilic", email = "ioanbeilic@gmail.com" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Web Environment",
|
|
12
|
+
"Framework :: Django",
|
|
13
|
+
"Framework :: Django :: 4.2",
|
|
14
|
+
"Framework :: Django :: 5.0",
|
|
15
|
+
"Framework :: Django :: 5.1",
|
|
16
|
+
"Framework :: Django :: 6.0",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["Django>=4.2"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/ERPlora/django-hilo"
|
|
32
|
+
Repository = "https://github.com/ERPlora/django-hilo"
|
|
33
|
+
Issues = "https://github.com/ERPlora/django-hilo/issues"
|
|
34
|
+
Changelog = "https://github.com/ERPlora/django-hilo/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/hilo"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
45
|
+
pythonpath = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
line-length = 120
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "I", "W"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hilo view decorators for Django.
|
|
3
|
+
|
|
4
|
+
@fragment — The core decorator that makes Django views SPA-ready.
|
|
5
|
+
Renders a full page for normal requests, or just the fragment for Hilo requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from django.http import HttpRequest, HttpResponse
|
|
12
|
+
from django.shortcuts import render
|
|
13
|
+
from django.template.response import TemplateResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fragment(
|
|
17
|
+
partial_template: str,
|
|
18
|
+
full_template: str | None = None,
|
|
19
|
+
*,
|
|
20
|
+
title: str | None = None,
|
|
21
|
+
status: int = 200,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
View decorator that renders a partial template for Hilo (AJAX) requests
|
|
25
|
+
and a full template for normal browser requests.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
@fragment('products/list.html')
|
|
29
|
+
def product_list(request):
|
|
30
|
+
return {'products': Product.objects.all()}
|
|
31
|
+
|
|
32
|
+
@fragment('products/list.html', 'products/page.html')
|
|
33
|
+
def product_list(request):
|
|
34
|
+
return {'products': Product.objects.all()}
|
|
35
|
+
|
|
36
|
+
How it works:
|
|
37
|
+
- Hilo request (X-Hilo: true) → renders partial_template only
|
|
38
|
+
- HTMX request (HX-Request: true) → renders partial_template only (backward compat)
|
|
39
|
+
- Normal request → renders full_template (wraps partial in base layout)
|
|
40
|
+
- If full_template is None, tries to find it automatically:
|
|
41
|
+
- Looks for the partial name without 'partials/' prefix
|
|
42
|
+
- Falls back to rendering the partial directly
|
|
43
|
+
|
|
44
|
+
The view function should return:
|
|
45
|
+
- A dict (context) → rendered with the appropriate template
|
|
46
|
+
- An HttpResponse → returned as-is (for redirects, errors, etc.)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def decorator(view_func: Callable) -> Callable:
|
|
50
|
+
@functools.wraps(view_func)
|
|
51
|
+
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
52
|
+
result = view_func(request, *args, **kwargs)
|
|
53
|
+
|
|
54
|
+
# If the view returns an HttpResponse, pass it through
|
|
55
|
+
if isinstance(result, (HttpResponse, TemplateResponse)):
|
|
56
|
+
# Add Hilo headers if it's a Hilo request
|
|
57
|
+
if is_hilo_request(request):
|
|
58
|
+
_add_hilo_headers(result, title=title)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
# Result should be a dict (context)
|
|
62
|
+
context = result if isinstance(result, dict) else {}
|
|
63
|
+
|
|
64
|
+
if is_hilo_request(request):
|
|
65
|
+
# Hilo/HTMX request → render partial only
|
|
66
|
+
response = render(request, partial_template, context, status=status)
|
|
67
|
+
_add_hilo_headers(response, title=title)
|
|
68
|
+
return response
|
|
69
|
+
else:
|
|
70
|
+
# Normal request → render full page
|
|
71
|
+
template = full_template or _infer_full_template(partial_template)
|
|
72
|
+
# Pass partial template name so the full template can include it
|
|
73
|
+
context.setdefault("partial_template", partial_template)
|
|
74
|
+
return render(request, template, context, status=status)
|
|
75
|
+
|
|
76
|
+
return wrapper
|
|
77
|
+
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_hilo_request(request: HttpRequest) -> bool:
|
|
82
|
+
"""Check if this is a Hilo SPA navigation request."""
|
|
83
|
+
return (
|
|
84
|
+
request.headers.get("X-Hilo") == "true"
|
|
85
|
+
or request.headers.get("HX-Request") == "true" # backward compat with HTMX
|
|
86
|
+
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _add_hilo_headers(response: HttpResponse, title: str | None = None) -> None:
|
|
91
|
+
"""Add Hilo response headers."""
|
|
92
|
+
if title:
|
|
93
|
+
response["X-Hilo-Title"] = title
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _infer_full_template(partial_template: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Try to infer the full template path from the partial template.
|
|
99
|
+
'app/partials/list.html' → 'app/list.html'
|
|
100
|
+
'app/list_partial.html' → 'app/list.html'
|
|
101
|
+
"""
|
|
102
|
+
if "/partials/" in partial_template:
|
|
103
|
+
return partial_template.replace("/partials/", "/")
|
|
104
|
+
if "_partial." in partial_template:
|
|
105
|
+
return partial_template.replace("_partial.", ".")
|
|
106
|
+
# Fallback: use the partial as the full template
|
|
107
|
+
return partial_template
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hilo Middleware for Django.
|
|
3
|
+
|
|
4
|
+
Detects Hilo SPA requests and adds appropriate response headers.
|
|
5
|
+
Handles redirects, page titles, asset versioning, and trigger events.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
from django.http import HttpRequest, HttpResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HiloMiddleware:
|
|
17
|
+
"""
|
|
18
|
+
Middleware that integrates Hilo.js SPA navigation with Django.
|
|
19
|
+
|
|
20
|
+
Features:
|
|
21
|
+
- Detects Hilo requests (X-Hilo: true header)
|
|
22
|
+
- Converts Django redirects to X-Hilo-Redirect headers (prevents full page reload)
|
|
23
|
+
- Adds X-Hilo-Version header for asset cache busting
|
|
24
|
+
- Passes X-Hilo-Title from response context
|
|
25
|
+
- Supports X-Hilo-Trigger for server-initiated events
|
|
26
|
+
|
|
27
|
+
Usage in settings.py:
|
|
28
|
+
MIDDLEWARE = [
|
|
29
|
+
...
|
|
30
|
+
'hilo.middleware.HiloMiddleware',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Optional settings
|
|
34
|
+
HILO = {
|
|
35
|
+
'VERSION': 'auto', # 'auto' generates from static files, or set a fixed string
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
|
40
|
+
self.get_response = get_response
|
|
41
|
+
self._version: str | None = None
|
|
42
|
+
|
|
43
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
44
|
+
# Mark request as Hilo
|
|
45
|
+
request.is_hilo = self._is_hilo(request) # type: ignore[attr-defined]
|
|
46
|
+
|
|
47
|
+
response = self.get_response(request)
|
|
48
|
+
|
|
49
|
+
if not request.is_hilo: # type: ignore[attr-defined]
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
# Convert redirects to Hilo redirect headers
|
|
53
|
+
if response.status_code in (301, 302, 303, 307, 308):
|
|
54
|
+
redirect_url = response.get("Location", "")
|
|
55
|
+
if redirect_url:
|
|
56
|
+
# Return a 200 with X-Hilo-Redirect instead
|
|
57
|
+
new_response = HttpResponse(status=200)
|
|
58
|
+
new_response["X-Hilo-Redirect"] = redirect_url
|
|
59
|
+
# Copy any trigger headers
|
|
60
|
+
for header in ("X-Hilo-Trigger", "X-Hilo-Title"):
|
|
61
|
+
if header in response:
|
|
62
|
+
new_response[header] = response[header]
|
|
63
|
+
return new_response
|
|
64
|
+
|
|
65
|
+
# Add version header for cache busting
|
|
66
|
+
version = self._get_version()
|
|
67
|
+
if version:
|
|
68
|
+
response["X-Hilo-Version"] = version
|
|
69
|
+
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
def _is_hilo(self, request: HttpRequest) -> bool:
|
|
73
|
+
"""Check if request comes from Hilo.js navigation."""
|
|
74
|
+
return request.headers.get("X-Hilo") == "true"
|
|
75
|
+
|
|
76
|
+
def _get_version(self) -> str:
|
|
77
|
+
"""Get the asset version string."""
|
|
78
|
+
if self._version:
|
|
79
|
+
return self._version
|
|
80
|
+
|
|
81
|
+
hilo_settings = getattr(settings, "HILO", {})
|
|
82
|
+
version = hilo_settings.get("VERSION", "")
|
|
83
|
+
|
|
84
|
+
if version == "auto":
|
|
85
|
+
# Generate version from Django's STATIC_URL or a hash
|
|
86
|
+
static_url = getattr(settings, "STATIC_URL", "/static/")
|
|
87
|
+
self._version = hashlib.md5(static_url.encode()).hexdigest()[:8]
|
|
88
|
+
elif version:
|
|
89
|
+
self._version = str(version)
|
|
90
|
+
else:
|
|
91
|
+
self._version = ""
|
|
92
|
+
|
|
93
|
+
return self._version
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- Helper functions for views ---
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def hilo_redirect(url: str) -> HttpResponse:
|
|
100
|
+
"""
|
|
101
|
+
Return a response that tells Hilo to navigate to the given URL.
|
|
102
|
+
Use this instead of Django's redirect() when you want Hilo
|
|
103
|
+
to handle the navigation without a full page reload.
|
|
104
|
+
"""
|
|
105
|
+
response = HttpResponse(status=200)
|
|
106
|
+
response["X-Hilo-Redirect"] = url
|
|
107
|
+
return response
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def hilo_trigger(response: HttpResponse, events: dict[str, Any] | str) -> HttpResponse:
|
|
111
|
+
"""
|
|
112
|
+
Add trigger events to a Hilo response.
|
|
113
|
+
These events will be dispatched as CustomEvents on the client.
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
response = render(request, 'template.html', context)
|
|
117
|
+
return hilo_trigger(response, {'showMessage': {'message': 'Saved!', 'type': 'success'}})
|
|
118
|
+
# or simple event:
|
|
119
|
+
return hilo_trigger(response, 'refreshList')
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(events, str):
|
|
122
|
+
response["X-Hilo-Trigger"] = events
|
|
123
|
+
else:
|
|
124
|
+
response["X-Hilo-Trigger"] = json.dumps(events)
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def hilo_title(response: HttpResponse, title: str) -> HttpResponse:
|
|
129
|
+
"""Set the page title for Hilo navigation."""
|
|
130
|
+
response["X-Hilo-Title"] = title
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def hilo_url(response: HttpResponse, url: str) -> HttpResponse:
|
|
135
|
+
"""Override the URL that Hilo pushes to browser history."""
|
|
136
|
+
response["X-Hilo-URL"] = url
|
|
137
|
+
return response
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hilo response helpers.
|
|
3
|
+
|
|
4
|
+
Convenience functions for building Hilo-aware responses from Django views.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from django.http import HttpResponse, JsonResponse
|
|
8
|
+
|
|
9
|
+
from .middleware.hilo import hilo_redirect, hilo_title, hilo_trigger, hilo_url
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"hilo_redirect",
|
|
13
|
+
"hilo_trigger",
|
|
14
|
+
"hilo_title",
|
|
15
|
+
"hilo_url",
|
|
16
|
+
"HiloStreamResponse",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HiloStreamResponse(HttpResponse):
|
|
21
|
+
"""
|
|
22
|
+
Response that sends streaming DOM actions to Hilo.js.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
response = HiloStreamResponse()
|
|
26
|
+
response.append('#messages', '<div>New message</div>')
|
|
27
|
+
response.update('#counter', '<span>42</span>')
|
|
28
|
+
response.remove('#old-item')
|
|
29
|
+
return response
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, **kwargs):
|
|
33
|
+
kwargs.setdefault("content_type", "text/html")
|
|
34
|
+
super().__init__(**kwargs)
|
|
35
|
+
self._actions: list[str] = []
|
|
36
|
+
|
|
37
|
+
def append(self, target: str, html: str) -> "HiloStreamResponse":
|
|
38
|
+
self._actions.append(
|
|
39
|
+
f'<div data-stream="append" data-target="{target}">{html}</div>'
|
|
40
|
+
)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def prepend(self, target: str, html: str) -> "HiloStreamResponse":
|
|
44
|
+
self._actions.append(
|
|
45
|
+
f'<div data-stream="prepend" data-target="{target}">{html}</div>'
|
|
46
|
+
)
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def replace(self, target: str, html: str) -> "HiloStreamResponse":
|
|
50
|
+
self._actions.append(
|
|
51
|
+
f'<div data-stream="replace" data-target="{target}">{html}</div>'
|
|
52
|
+
)
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def update(self, target: str, html: str) -> "HiloStreamResponse":
|
|
56
|
+
self._actions.append(
|
|
57
|
+
f'<div data-stream="update" data-target="{target}">{html}</div>'
|
|
58
|
+
)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def remove(self, target: str) -> "HiloStreamResponse":
|
|
62
|
+
self._actions.append(
|
|
63
|
+
f'<div data-stream="remove" data-target="{target}"></div>'
|
|
64
|
+
)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def before(self, target: str, html: str) -> "HiloStreamResponse":
|
|
68
|
+
self._actions.append(
|
|
69
|
+
f'<div data-stream="before" data-target="{target}">{html}</div>'
|
|
70
|
+
)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def after(self, target: str, html: str) -> "HiloStreamResponse":
|
|
74
|
+
self._actions.append(
|
|
75
|
+
f'<div data-stream="after" data-target="{target}">{html}</div>'
|
|
76
|
+
)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def morph(self, target: str, html: str) -> "HiloStreamResponse":
|
|
80
|
+
self._actions.append(
|
|
81
|
+
f'<div data-stream="morph" data-target="{target}">{html}</div>'
|
|
82
|
+
)
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def content(self) -> bytes:
|
|
87
|
+
return "\n".join(self._actions).encode()
|
|
88
|
+
|
|
89
|
+
@content.setter
|
|
90
|
+
def content(self, value):
|
|
91
|
+
# Allow Django internals to set content
|
|
92
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hilo template tags for Django.
|
|
3
|
+
|
|
4
|
+
{% load hilo %}
|
|
5
|
+
{% hilo_scripts %} — Inject hilo.min.js + auto-config
|
|
6
|
+
{% hilo_content %}...{% endhilo_content %} — Mark the main content area
|
|
7
|
+
{% hilo_permanent %}...{% endhilo_permanent %} — Mark permanent elements (not morphed)
|
|
8
|
+
{% hilo_stream action target_id %}...{% endhilo_stream %} — Stream action element
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from django import template
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
from django.utils.safestring import mark_safe
|
|
14
|
+
|
|
15
|
+
register = template.Library()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register.simple_tag(takes_context=True)
|
|
19
|
+
def hilo_scripts(context):
|
|
20
|
+
"""
|
|
21
|
+
Inject Hilo.js script tag and auto-configuration.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
{% load hilo %}
|
|
25
|
+
{% hilo_scripts %}
|
|
26
|
+
|
|
27
|
+
Renders:
|
|
28
|
+
<script src="/static/js/hilo.min.js" defer nonce="..."></script>
|
|
29
|
+
<script nonce="...">
|
|
30
|
+
Hilo.config({ csrf: '...', prefix: 'h' });
|
|
31
|
+
</script>
|
|
32
|
+
"""
|
|
33
|
+
request = context.get("request")
|
|
34
|
+
nonce = ""
|
|
35
|
+
|
|
36
|
+
# Get CSP nonce if available
|
|
37
|
+
csp_nonce = context.get("csp_nonce", "")
|
|
38
|
+
if not csp_nonce and request:
|
|
39
|
+
csp_nonce = getattr(request, "csp_nonce", "")
|
|
40
|
+
if csp_nonce:
|
|
41
|
+
nonce = f' nonce="{csp_nonce}"'
|
|
42
|
+
|
|
43
|
+
# Get CSRF token
|
|
44
|
+
csrf_token = context.get("csrf_token", "")
|
|
45
|
+
|
|
46
|
+
# Get Hilo settings
|
|
47
|
+
hilo_settings = getattr(settings, "HILO", {})
|
|
48
|
+
static_url = getattr(settings, "STATIC_URL", "/static/")
|
|
49
|
+
js_path = hilo_settings.get("JS_PATH", "js/hilo.min.js")
|
|
50
|
+
prefix = hilo_settings.get("PREFIX", "h")
|
|
51
|
+
debug = hilo_settings.get("DEBUG", getattr(settings, "DEBUG", False))
|
|
52
|
+
nav_config = hilo_settings.get("NAV", True)
|
|
53
|
+
|
|
54
|
+
# Build config options
|
|
55
|
+
config_parts = [f"csrf: '{csrf_token}'"]
|
|
56
|
+
config_parts.append(f"prefix: '{prefix}'")
|
|
57
|
+
|
|
58
|
+
if isinstance(nav_config, dict):
|
|
59
|
+
nav_items = []
|
|
60
|
+
for key, val in nav_config.items():
|
|
61
|
+
if isinstance(val, bool):
|
|
62
|
+
nav_items.append(f"{key}: {'true' if val else 'false'}")
|
|
63
|
+
elif isinstance(val, str):
|
|
64
|
+
nav_items.append(f"{key}: '{val}'")
|
|
65
|
+
elif isinstance(val, (int, float)):
|
|
66
|
+
nav_items.append(f"{key}: {val}")
|
|
67
|
+
config_parts.append(f"nav: {{ {', '.join(nav_items)} }}")
|
|
68
|
+
elif nav_config is False:
|
|
69
|
+
config_parts.append("nav: false")
|
|
70
|
+
|
|
71
|
+
if debug:
|
|
72
|
+
config_parts.append("debug: true")
|
|
73
|
+
|
|
74
|
+
config_str = ", ".join(config_parts)
|
|
75
|
+
|
|
76
|
+
html = (
|
|
77
|
+
f'<script src="{static_url}{js_path}" defer{nonce}></script>\n'
|
|
78
|
+
f"<script{nonce}>\n"
|
|
79
|
+
f"document.addEventListener('DOMContentLoaded', function() {{\n"
|
|
80
|
+
f" Hilo.config({{ {config_str} }});\n"
|
|
81
|
+
f"}});\n"
|
|
82
|
+
f"</script>"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return mark_safe(html)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@register.tag("hilo_content")
|
|
89
|
+
def do_hilo_content(parser, token):
|
|
90
|
+
"""
|
|
91
|
+
Mark the main content area that Hilo will swap during navigation.
|
|
92
|
+
|
|
93
|
+
Usage:
|
|
94
|
+
{% hilo_content %}
|
|
95
|
+
{% block content %}{% endblock %}
|
|
96
|
+
{% endhilo_content %}
|
|
97
|
+
|
|
98
|
+
Renders:
|
|
99
|
+
<div data-hilo-content>
|
|
100
|
+
...content...
|
|
101
|
+
</div>
|
|
102
|
+
"""
|
|
103
|
+
nodelist = parser.parse(("endhilo_content",))
|
|
104
|
+
parser.delete_first_token()
|
|
105
|
+
|
|
106
|
+
# Parse optional id argument
|
|
107
|
+
bits = token.split_contents()
|
|
108
|
+
element_id = bits[1] if len(bits) > 1 else "main-content-area"
|
|
109
|
+
|
|
110
|
+
return HiloContentNode(nodelist, element_id)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class HiloContentNode(template.Node):
|
|
114
|
+
def __init__(self, nodelist, element_id):
|
|
115
|
+
self.nodelist = nodelist
|
|
116
|
+
self.element_id = element_id
|
|
117
|
+
|
|
118
|
+
def render(self, context):
|
|
119
|
+
content = self.nodelist.render(context)
|
|
120
|
+
return f'<div id="{self.element_id}" data-hilo-content>{content}</div>'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@register.tag("hilo_permanent")
|
|
124
|
+
def do_hilo_permanent(parser, token):
|
|
125
|
+
"""
|
|
126
|
+
Mark an element as permanent — it won't be touched during DOM morphing.
|
|
127
|
+
|
|
128
|
+
Usage:
|
|
129
|
+
{% hilo_permanent %}
|
|
130
|
+
<nav id="sidebar">...</nav>
|
|
131
|
+
{% endhilo_permanent %}
|
|
132
|
+
|
|
133
|
+
Renders:
|
|
134
|
+
<div data-permanent>
|
|
135
|
+
<nav id="sidebar">...</nav>
|
|
136
|
+
</div>
|
|
137
|
+
"""
|
|
138
|
+
nodelist = parser.parse(("endhilo_permanent",))
|
|
139
|
+
parser.delete_first_token()
|
|
140
|
+
return HiloPermanentNode(nodelist)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class HiloPermanentNode(template.Node):
|
|
144
|
+
def __init__(self, nodelist):
|
|
145
|
+
self.nodelist = nodelist
|
|
146
|
+
|
|
147
|
+
def render(self, context):
|
|
148
|
+
content = self.nodelist.render(context)
|
|
149
|
+
return f'<div data-permanent>{content}</div>'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register.tag("hilo_stream")
|
|
153
|
+
def do_hilo_stream(parser, token):
|
|
154
|
+
"""
|
|
155
|
+
Create a streaming action element (like Turbo Streams).
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
{% hilo_stream "append" "#messages" %}
|
|
159
|
+
<div class="message">New message!</div>
|
|
160
|
+
{% endhilo_stream %}
|
|
161
|
+
|
|
162
|
+
Renders:
|
|
163
|
+
<div data-stream="append" data-target="#messages">
|
|
164
|
+
<div class="message">New message!</div>
|
|
165
|
+
</div>
|
|
166
|
+
"""
|
|
167
|
+
bits = token.split_contents()
|
|
168
|
+
if len(bits) < 3:
|
|
169
|
+
raise template.TemplateSyntaxError(
|
|
170
|
+
f"'{bits[0]}' tag requires at least 2 arguments: action and target"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
action = bits[1].strip("'\"")
|
|
174
|
+
target = bits[2].strip("'\"")
|
|
175
|
+
|
|
176
|
+
nodelist = parser.parse(("endhilo_stream",))
|
|
177
|
+
parser.delete_first_token()
|
|
178
|
+
return HiloStreamNode(nodelist, action, target)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class HiloStreamNode(template.Node):
|
|
182
|
+
def __init__(self, nodelist, action, target):
|
|
183
|
+
self.nodelist = nodelist
|
|
184
|
+
self.action = action
|
|
185
|
+
self.target = target
|
|
186
|
+
|
|
187
|
+
def render(self, context):
|
|
188
|
+
content = self.nodelist.render(context)
|
|
189
|
+
return f'<div data-stream="{self.action}" data-target="{self.target}">{content}</div>'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@register.simple_tag
|
|
193
|
+
def hilo_oob(element_id):
|
|
194
|
+
"""
|
|
195
|
+
Helper to create an OOB (out-of-band) wrapper.
|
|
196
|
+
The content inside will be swapped into the element with the given ID.
|
|
197
|
+
|
|
198
|
+
Usage:
|
|
199
|
+
{% hilo_oob "notification-count" %}
|
|
200
|
+
"""
|
|
201
|
+
return mark_safe(f'<div data-oob id="{element_id}">')
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@register.simple_tag
|
|
205
|
+
def endhilo_oob():
|
|
206
|
+
"""Close an OOB wrapper."""
|
|
207
|
+
return mark_safe("</div>")
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
SECRET_KEY = "test-secret-key-for-hilo-django"
|
|
2
|
+
DEBUG = True
|
|
3
|
+
INSTALLED_APPS = [
|
|
4
|
+
"django.contrib.contenttypes",
|
|
5
|
+
"django.contrib.auth",
|
|
6
|
+
"hilo",
|
|
7
|
+
]
|
|
8
|
+
MIDDLEWARE = [
|
|
9
|
+
"hilo.middleware.HiloMiddleware",
|
|
10
|
+
]
|
|
11
|
+
ROOT_URLCONF = "tests.urls"
|
|
12
|
+
TEMPLATES = [
|
|
13
|
+
{
|
|
14
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
15
|
+
"DIRS": [],
|
|
16
|
+
"APP_DIRS": True,
|
|
17
|
+
"OPTIONS": {
|
|
18
|
+
"context_processors": [
|
|
19
|
+
"django.template.context_processors.request",
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
DATABASES = {
|
|
25
|
+
"default": {
|
|
26
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
27
|
+
"NAME": ":memory:",
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
STATIC_URL = "/static/"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for Hilo decorators."""
|
|
2
|
+
|
|
3
|
+
from django.http import HttpResponse
|
|
4
|
+
from django.test import RequestFactory, TestCase
|
|
5
|
+
|
|
6
|
+
from hilo.decorators import fragment, is_hilo_request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IsHiloRequestTest(TestCase):
|
|
10
|
+
"""Test is_hilo_request detection."""
|
|
11
|
+
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.factory = RequestFactory()
|
|
14
|
+
|
|
15
|
+
def test_normal_request(self):
|
|
16
|
+
request = self.factory.get("/")
|
|
17
|
+
assert not is_hilo_request(request)
|
|
18
|
+
|
|
19
|
+
def test_hilo_header(self):
|
|
20
|
+
request = self.factory.get("/", headers={"X-Hilo": "true"})
|
|
21
|
+
assert is_hilo_request(request)
|
|
22
|
+
|
|
23
|
+
def test_htmx_header_backward_compat(self):
|
|
24
|
+
request = self.factory.get("/", headers={"HX-Request": "true"})
|
|
25
|
+
assert is_hilo_request(request)
|
|
26
|
+
|
|
27
|
+
def test_xhr_header(self):
|
|
28
|
+
request = self.factory.get(
|
|
29
|
+
"/", headers={"X-Requested-With": "XMLHttpRequest"}
|
|
30
|
+
)
|
|
31
|
+
assert is_hilo_request(request)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FragmentDecoratorTest(TestCase):
|
|
35
|
+
"""Test @fragment decorator."""
|
|
36
|
+
|
|
37
|
+
def test_fragment_returns_httpresponse_passthrough(self):
|
|
38
|
+
@fragment("partial.html")
|
|
39
|
+
def view(request):
|
|
40
|
+
return HttpResponse("direct response")
|
|
41
|
+
|
|
42
|
+
request = RequestFactory().get("/")
|
|
43
|
+
response = view(request)
|
|
44
|
+
assert response.status_code == 200
|
|
45
|
+
assert response.content == b"direct response"
|
|
46
|
+
|
|
47
|
+
def test_infer_full_template(self):
|
|
48
|
+
from hilo.decorators import _infer_full_template
|
|
49
|
+
|
|
50
|
+
assert _infer_full_template("app/partials/list.html") == "app/list.html"
|
|
51
|
+
assert _infer_full_template("app/list_partial.html") == "app/list.html"
|
|
52
|
+
assert _infer_full_template("app/list.html") == "app/list.html"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tests for Hilo middleware."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from django.test import RequestFactory, TestCase, override_settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HiloMiddlewareTest(TestCase):
|
|
8
|
+
"""Test HiloMiddleware behavior."""
|
|
9
|
+
|
|
10
|
+
def test_normal_request_passthrough(self):
|
|
11
|
+
"""Normal requests are not modified."""
|
|
12
|
+
response = self.client.get("/destination/")
|
|
13
|
+
assert response.status_code == 200
|
|
14
|
+
assert "X-Hilo-Redirect" not in response
|
|
15
|
+
|
|
16
|
+
def test_hilo_request_detected(self):
|
|
17
|
+
"""Hilo requests are detected via X-Hilo header."""
|
|
18
|
+
response = self.client.get(
|
|
19
|
+
"/destination/",
|
|
20
|
+
headers={"X-Hilo": "true"},
|
|
21
|
+
)
|
|
22
|
+
assert response.status_code == 200
|
|
23
|
+
|
|
24
|
+
def test_redirect_converted_for_hilo(self):
|
|
25
|
+
"""Django redirects become X-Hilo-Redirect for Hilo requests."""
|
|
26
|
+
response = self.client.get(
|
|
27
|
+
"/redirect/",
|
|
28
|
+
headers={"X-Hilo": "true"},
|
|
29
|
+
)
|
|
30
|
+
assert response.status_code == 200
|
|
31
|
+
assert response["X-Hilo-Redirect"] == "/destination/"
|
|
32
|
+
|
|
33
|
+
def test_redirect_normal_for_browser(self):
|
|
34
|
+
"""Normal browser requests get standard redirects."""
|
|
35
|
+
response = self.client.get("/redirect/")
|
|
36
|
+
assert response.status_code == 302
|
|
37
|
+
assert response["Location"] == "/destination/"
|
|
38
|
+
|
|
39
|
+
@override_settings(HILO={"VERSION": "abc123"})
|
|
40
|
+
def test_version_header(self):
|
|
41
|
+
"""X-Hilo-Version header is sent for Hilo requests."""
|
|
42
|
+
response = self.client.get(
|
|
43
|
+
"/destination/",
|
|
44
|
+
headers={"X-Hilo": "true"},
|
|
45
|
+
)
|
|
46
|
+
assert response["X-Hilo-Version"] == "abc123"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HiloResponseHelpersTest(TestCase):
|
|
50
|
+
"""Test response helper functions."""
|
|
51
|
+
|
|
52
|
+
def test_hilo_redirect(self):
|
|
53
|
+
from hilo.response import hilo_redirect
|
|
54
|
+
|
|
55
|
+
response = hilo_redirect("/new-url/")
|
|
56
|
+
assert response.status_code == 200
|
|
57
|
+
assert response["X-Hilo-Redirect"] == "/new-url/"
|
|
58
|
+
|
|
59
|
+
def test_hilo_trigger(self):
|
|
60
|
+
from django.http import HttpResponse
|
|
61
|
+
from hilo.response import hilo_trigger
|
|
62
|
+
|
|
63
|
+
response = HttpResponse("ok")
|
|
64
|
+
hilo_trigger(response, {"showMessage": {"message": "Saved!", "type": "success"}})
|
|
65
|
+
assert "showMessage" in response["X-Hilo-Trigger"]
|
|
66
|
+
|
|
67
|
+
def test_hilo_trigger_simple(self):
|
|
68
|
+
from django.http import HttpResponse
|
|
69
|
+
from hilo.response import hilo_trigger
|
|
70
|
+
|
|
71
|
+
response = HttpResponse("ok")
|
|
72
|
+
hilo_trigger(response, "refreshList")
|
|
73
|
+
assert response["X-Hilo-Trigger"] == "refreshList"
|
|
74
|
+
|
|
75
|
+
def test_hilo_title(self):
|
|
76
|
+
from django.http import HttpResponse
|
|
77
|
+
from hilo.response import hilo_title
|
|
78
|
+
|
|
79
|
+
response = HttpResponse("ok")
|
|
80
|
+
hilo_title(response, "Products")
|
|
81
|
+
assert response["X-Hilo-Title"] == "Products"
|
|
82
|
+
|
|
83
|
+
def test_hilo_url(self):
|
|
84
|
+
from django.http import HttpResponse
|
|
85
|
+
from hilo.response import hilo_url
|
|
86
|
+
|
|
87
|
+
response = HttpResponse("ok")
|
|
88
|
+
hilo_url(response, "/products/")
|
|
89
|
+
assert response["X-Hilo-URL"] == "/products/"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HiloStreamResponseTest(TestCase):
|
|
93
|
+
"""Test HiloStreamResponse."""
|
|
94
|
+
|
|
95
|
+
def test_append(self):
|
|
96
|
+
from hilo.response import HiloStreamResponse
|
|
97
|
+
|
|
98
|
+
response = HiloStreamResponse()
|
|
99
|
+
response.append("#list", "<li>New item</li>")
|
|
100
|
+
content = response.content.decode()
|
|
101
|
+
assert 'data-stream="append"' in content
|
|
102
|
+
assert 'data-target="#list"' in content
|
|
103
|
+
assert "<li>New item</li>" in content
|
|
104
|
+
|
|
105
|
+
def test_remove(self):
|
|
106
|
+
from hilo.response import HiloStreamResponse
|
|
107
|
+
|
|
108
|
+
response = HiloStreamResponse()
|
|
109
|
+
response.remove("#old-item")
|
|
110
|
+
content = response.content.decode()
|
|
111
|
+
assert 'data-stream="remove"' in content
|
|
112
|
+
assert 'data-target="#old-item"' in content
|
|
113
|
+
|
|
114
|
+
def test_chaining(self):
|
|
115
|
+
from hilo.response import HiloStreamResponse
|
|
116
|
+
|
|
117
|
+
response = HiloStreamResponse()
|
|
118
|
+
response.append("#list", "<li>1</li>").update("#counter", "42").remove("#old")
|
|
119
|
+
content = response.content.decode()
|
|
120
|
+
assert content.count("data-stream=") == 3
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.http import HttpResponse
|
|
2
|
+
from django.urls import path
|
|
3
|
+
|
|
4
|
+
from hilo.decorators import fragment
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@fragment("tests/partial.html")
|
|
8
|
+
def test_view(request):
|
|
9
|
+
return {"message": "Hello from Hilo"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def redirect_view(request):
|
|
13
|
+
from django.shortcuts import redirect
|
|
14
|
+
return redirect("/destination/")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def destination_view(request):
|
|
18
|
+
return HttpResponse("Destination reached")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
urlpatterns = [
|
|
22
|
+
path("test/", test_view, name="test"),
|
|
23
|
+
path("redirect/", redirect_view, name="redirect"),
|
|
24
|
+
path("destination/", destination_view, name="destination"),
|
|
25
|
+
]
|