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.
@@ -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,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .tox/
9
+ .coverage
10
+ htmlcov/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ *.db
15
+ *.sqlite3
16
+ .env
17
+ .venv/
@@ -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,5 @@
1
+ import sys
2
+ import os
3
+
4
+ # Add project root to Python path so 'tests' package is importable
5
+ sys.path.insert(0, os.path.dirname(__file__))
@@ -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,10 @@
1
+ """
2
+ django-hilo — SPA framework for Django
3
+
4
+ One script tag turns Django templates into a full SPA.
5
+ Navigation, DOM morphing, signals, real-time communication.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ default_app_config = "hilo.apps.HiloConfig"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class HiloConfig(AppConfig):
5
+ name = "hilo"
6
+ verbose_name = "Hilo SPA Framework"
7
+ default_auto_field = "django.db.models.BigAutoField"
@@ -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,3 @@
1
+ from .hilo import HiloMiddleware
2
+
3
+ __all__ = ["HiloMiddleware"]
@@ -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
+ ]