├── .gitignore ├── README.md ├── home ├── __init__.py ├── apps.py ├── recommendations.py ├── templates │ ├── chooser.html │ ├── home │ │ ├── _item.html │ │ ├── home.html │ │ └── home_sse.html │ ├── shell.html │ └── shell_htmx.html └── views │ ├── __init__.py │ ├── jinja.py │ ├── sse.py │ └── stream.py ├── jinja ├── _item.html ├── home.html └── shell.html ├── manage.py ├── requirements.in ├── requirements.txt ├── static ├── images │ ├── avatar.png │ ├── banner-bg.jpg │ ├── category │ │ ├── category-1.jpg │ │ ├── category-2.jpg │ │ ├── category-3.jpg │ │ ├── category-4.jpg │ │ ├── category-5.jpg │ │ └── category-6.jpg │ ├── complete.png │ ├── favicon │ │ ├── about.txt │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── site.webmanifest │ ├── icons │ │ ├── bed-2.svg │ │ ├── bed.svg │ │ ├── delivery-van.svg │ │ ├── money-back.svg │ │ ├── office.svg │ │ ├── outdoor-cafe.svg │ │ ├── phone.svg │ │ ├── restaurant.svg │ │ ├── service-hours.svg │ │ ├── sofa.svg │ │ └── terrace.svg │ ├── logo.svg │ ├── methods.png │ ├── offer.jpg │ └── products │ │ ├── product1.jpg │ │ ├── product10.jpg │ │ ├── product11.jpg │ │ ├── product12.jpg │ │ ├── product2.jpg │ │ ├── product3.jpg │ │ ├── product4.jpg │ │ ├── product5.jpg │ │ ├── product6.jpg │ │ ├── product7.jpg │ │ ├── product8.jpg │ │ └── product9.jpg └── js │ ├── htmx.1.9.4.min.js │ └── htmx.1.9.4_dist_ext_sse.js └── stream ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | ### Python template 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proof of concept: Streaming HTML with Python 2 | 3 | Inspired by Taylor Hunt's incredible explanation of how he turned [a slow Fortune 20 webapp into a snappy experience, even on a cheap Android phone](https://dev.to/tigt/making-the-worlds-fastest-website-and-other-mistakes-56na), I made this repo to show the current capability of Django to render streaming HTML. 4 | 5 | This technique is best used to improve the perceptual performance for expensive database queries or slow API calls. The idea is that the user could start seeing the page come together while the query or calls are happening. Rendering of the page will pause once it hits the block that is waiting on the data, but being able to see the page render should make the user feel as though the website is performing faster. 6 | 7 | Referencing Taylor's blog post: 8 | 9 | > Both of [these pages](https://assets.codepen.io/183091/HTML+streaming+vs.+non.mp4) show search results in 2.5 seconds. But they sure don't _feel_ the same. 10 | 11 | This concept shows how a recommendation engine takes some time to recommend four products, based for the current user. 12 | 13 | ## Viewing the concept 14 | 15 | Open a terminal at the root of this project and type the following: 16 | 17 | ```shell 18 | python -m venv .venv --prompt stream_python 19 | # If in Windows: 20 | .venv/Scripts/activate 21 | # otherwise 22 | source .venv/bin/ 23 | # Then 24 | pip install -r requirements.txt 25 | uvicorn stream.asgi:application --reload 26 | ``` 27 | 28 | You can then click the link in the terminal or go to http://127.0.0.1:8000 to view the page. 29 | 30 | 31 | ## Thanks 32 | 33 | Thanks to https://github.com/fajar7xx/ecommerce-template-tailwind-1 for the HTML template. 34 | -------------------------------------------------------------------------------- /home/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/home/__init__.py -------------------------------------------------------------------------------- /home/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HomeConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "home" 7 | -------------------------------------------------------------------------------- /home/recommendations.py: -------------------------------------------------------------------------------- 1 | customized_recommendations = [ 2 | dict(name='Comfy Chair', discount_price=745.00, normal_price=800.00, review_count=1550, img='product1.jpg'), 3 | dict(name='Bed King Size', discount_price=1055.00, normal_price=1599.99, review_count=720, img='product4.jpg'), 4 | dict(name='Lounge pairs', discount_price=350.00, normal_price=499.99, review_count=952, img='product2.jpg'), 5 | dict(name='Air mattress', discount_price=189.99, normal_price=250.00, review_count=153, img='product3.jpg'), 6 | ] 7 | -------------------------------------------------------------------------------- /home/templates/chooser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /home/templates/home/_item.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | product 1 4 | 17 |
18 |
19 | 20 |

{{ recommendation.name }}

21 |
22 |
23 |

${{ recommendation.discount_price | floatformat }}

24 |

${{ recommendation.normal_price | floatformat }} 25 |

26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 |
34 |
({{ recommendation.review_count }})
35 |
36 |
37 | Add 39 | to cart 40 |
41 | -------------------------------------------------------------------------------- /home/templates/home/home.html: -------------------------------------------------------------------------------- 1 | {% extends "shell.html" %} 2 | 3 | {% block main %} 4 | {% csrf_token %} 5 | 6 |
7 |
8 |

9 | best collection for
home decoration 10 |

11 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Aperiam
12 | accusantium perspiciatis, sapiente 13 | magni eos dolorum ex quos dolores odio

14 |
15 | Shop Now 17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 | Delivery 27 |
28 |

Free Shipping

29 |

Order over $200

30 |
31 |
32 |
33 | Delivery 34 |
35 |

Money Returns

36 |

30 days money returns

37 |
38 |
39 |
40 | Delivery 41 |
42 |

24/7 Support

43 |

Customer support

44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |

recommended for you

53 |
54 | {% for item in recommendations %} 55 | {% include 'home/_item.html' with recommendation=item %} 56 | {% endfor %} 57 | 58 |
59 |
60 | 61 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /home/templates/home/home_sse.html: -------------------------------------------------------------------------------- 1 | {% extends "shell_htmx.html" %} 2 | 3 | {% block main %} 4 | 5 | 6 |
7 |
8 |

9 | best collection for
home decoration 10 |

11 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Aperiam
12 | accusantium perspiciatis, sapiente 13 | magni eos dolorum ex quos dolores odio

14 |
15 | Shop Now 17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 | Delivery 27 |
28 |

Free Shipping

29 |

Order over $200

30 |
31 |
32 |
33 | Delivery 34 |
35 |

Money Returns

36 |

30 days money returns

37 |
38 |
39 |
40 | Delivery 41 |
42 |

24/7 Support

43 |

Customer support

44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |

recommended for you

53 |
57 | {% for item in recommendations %} 58 | {% include 'home/_item.html' with recommendation=item %} 59 | {% empty %} 60 |
66 | {% endfor %} 67 |
68 |
69 | 70 | 71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /home/templates/shell.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Example Stream 9 | 10 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 46 | 129 | 130 | 131 | 132 | 133 | 134 |
135 |
136 | 137 | Logo 138 | 139 | 140 |
141 | 142 | 143 | 144 | 147 | 151 |
152 | 153 | 181 |
182 |
183 | 184 | 185 | 186 | 235 | 236 | 237 | {% block main %} {% endblock %} 238 | 239 | 240 | 308 | 309 | 310 | 311 |
312 |
313 |

© TailCommerce - All Right Reserved

314 |
315 | methods 316 |
317 |
318 |
319 | 320 | 321 | 322 | 323 | -------------------------------------------------------------------------------- /home/templates/shell_htmx.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Example Stream 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 22 | 47 | 130 | 131 | 132 | 133 | 134 | 135 |
136 |
137 | 138 | Logo 139 | 140 | 141 |
142 | 143 | 144 | 145 | 148 | 152 |
153 | 154 | 182 |
183 |
184 | 185 | 186 | 187 | 236 | 237 | 238 | {% block main %} {% endblock %} 239 | 240 | 241 | 309 | 310 | 311 | 312 |
313 |
314 |

© TailCommerce - All Right Reserved

315 |
316 | methods 317 |
318 |
319 |
320 | 321 | 322 | 323 | 324 | -------------------------------------------------------------------------------- /home/views/__init__.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | from django.shortcuts import render 3 | 4 | 5 | def chooser(request): 6 | return render(request, 'chooser.html') 7 | -------------------------------------------------------------------------------- /home/views/jinja.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from django.http import StreamingHttpResponse 4 | from django.template.loader import get_template 5 | 6 | from home.recommendations import customized_recommendations 7 | 8 | 9 | async def stream_homepage_content(): 10 | for item in customized_recommendations: 11 | # Faking an expensive database query or slow API 12 | await asyncio.sleep(.7) 13 | yield item 14 | 15 | 16 | async def index(request): 17 | template = get_template('home.html') 18 | return StreamingHttpResponse( 19 | template.template.generate_async({ 20 | 'recommendations': stream_homepage_content() 21 | }) 22 | ) 23 | -------------------------------------------------------------------------------- /home/views/sse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import re 4 | 5 | from django.http import StreamingHttpResponse 6 | from django.shortcuts import render 7 | from django.template.loader import render_to_string 8 | 9 | from home.recommendations import customized_recommendations 10 | 11 | 12 | def index(request): 13 | return render(request, 'home/home_sse.html') 14 | 15 | 16 | async def sse_recommendation(): 17 | recommendations = [] 18 | for item in customized_recommendations: 19 | await asyncio.sleep(.7) 20 | content = render_to_string('home/_item.html', dict(recommendation=item)) 21 | recommendations.append( 22 | re.sub('\n', '', content) 23 | ) 24 | all_recommendations = ''.join(recommendations) 25 | yield f'data: {all_recommendations}\n\n' 26 | 27 | 28 | def handle_sse(request): 29 | return StreamingHttpResponse(streaming_content=sse_recommendation(), content_type='text/event-stream') 30 | -------------------------------------------------------------------------------- /home/views/stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from django.http import StreamingHttpResponse 4 | from django.template.loader import render_to_string 5 | 6 | from home.recommendations import customized_recommendations 7 | 8 | 9 | async def stream_homepage_content(request): 10 | pre_shell, post_shell = render_to_string('home/home.html', request=request).split('') 11 | yield pre_shell 12 | for item in customized_recommendations: 13 | await asyncio.sleep(.7) # Faking an expensive database query or slow API 14 | yield render_to_string('home/_item.html', dict(recommendation=item)) 15 | yield post_shell 16 | 17 | 18 | async def index(request): 19 | return StreamingHttpResponse(stream_homepage_content(request)) 20 | -------------------------------------------------------------------------------- /jinja/_item.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | product 1 4 | 17 |
18 |
19 | 20 |

{{ recommendation.name }}

21 |
22 |
23 |

${{ recommendation.discount_price }}

24 |

${{ recommendation.normal_price }} 25 |

26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 |
34 |
({{ recommendation.review_count }})
35 |
36 |
37 | Add 39 | to cart 40 |
41 | -------------------------------------------------------------------------------- /jinja/home.html: -------------------------------------------------------------------------------- 1 | {% extends "shell.html" %} 2 | 3 | {% block main %} 4 | 5 | 6 |
7 |
8 |

9 | best collection for
home decoration 10 |

11 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Aperiam
12 | accusantium perspiciatis, sapiente 13 | magni eos dolorum ex quos dolores odio

14 |
15 | Shop Now 17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 | Delivery 27 |
28 |

Free Shipping

29 |

Order over $200

30 |
31 |
32 |
33 | Delivery 34 |
35 |

Money Returns

36 |

30 days money returns

37 |
38 |
39 |
40 | Delivery 41 |
42 |

24/7 Support

43 |

Customer support

44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |

recommended for you

53 |
54 | {# {{ recommendations }}#} 55 | {% for recommendation in recommendations %} 56 | {% include '_item.html' %} 57 | {% endfor %} 58 | 59 |
60 |
61 | 62 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /jinja/shell.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Example Stream 9 | 10 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 46 | 129 | 130 | 131 | 132 | 133 | 134 |
135 |
136 | 137 | Logo 138 | 139 | 140 |
141 | 142 | 143 | 144 | 147 | 151 |
152 | 153 | 181 |
182 |
183 | 184 | 185 | 186 | 235 | 236 | 237 | {% block main %} {% endblock %} 238 | 239 | 240 | 308 | 309 | 310 | 311 |
312 |
313 |

© TailCommerce - All Right Reserved

314 |
315 | methods 316 |
317 |
318 |
319 | 320 | 321 | 322 | 323 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stream.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | django 2 | uvicorn 3 | jinja2 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | asgiref==3.6.0 8 | # via django 9 | click==8.1.3 10 | # via uvicorn 11 | django==4.2.1 12 | # via -r requirements.in 13 | h11==0.14.0 14 | # via uvicorn 15 | jinja2==3.1.2 16 | # via -r requirements.in 17 | markupsafe==2.1.3 18 | # via jinja2 19 | sqlparse==0.4.4 20 | # via django 21 | uvicorn==0.22.0 22 | # via -r requirements.in 23 | -------------------------------------------------------------------------------- /static/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/avatar.png -------------------------------------------------------------------------------- /static/images/banner-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/banner-bg.jpg -------------------------------------------------------------------------------- /static/images/category/category-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-1.jpg -------------------------------------------------------------------------------- /static/images/category/category-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-2.jpg -------------------------------------------------------------------------------- /static/images/category/category-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-3.jpg -------------------------------------------------------------------------------- /static/images/category/category-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-4.jpg -------------------------------------------------------------------------------- /static/images/category/category-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-5.jpg -------------------------------------------------------------------------------- /static/images/category/category-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/category/category-6.jpg -------------------------------------------------------------------------------- /static/images/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/complete.png -------------------------------------------------------------------------------- /static/images/favicon/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Roboto 4 | - Font Author: Copyright 2011 Google Inc. All Rights Reserved. 5 | - Font Source: http://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmWUlvAx05IsDqlA.ttf 6 | - Font License: Apache License, version 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)) 7 | -------------------------------------------------------------------------------- /static/images/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/images/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /static/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /static/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /static/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/favicon/favicon.ico -------------------------------------------------------------------------------- /static/images/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /static/images/icons/bed-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /static/images/icons/bed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/images/icons/delivery-van.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/images/icons/money-back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /static/images/icons/office.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/images/icons/outdoor-cafe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 23 | 27 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /static/images/icons/phone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /static/images/icons/restaurant.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/icons/service-hours.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /static/images/icons/sofa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /static/images/icons/terrace.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/images/methods.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/methods.png -------------------------------------------------------------------------------- /static/images/offer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/offer.jpg -------------------------------------------------------------------------------- /static/images/products/product1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product1.jpg -------------------------------------------------------------------------------- /static/images/products/product10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product10.jpg -------------------------------------------------------------------------------- /static/images/products/product11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product11.jpg -------------------------------------------------------------------------------- /static/images/products/product12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product12.jpg -------------------------------------------------------------------------------- /static/images/products/product2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product2.jpg -------------------------------------------------------------------------------- /static/images/products/product3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product3.jpg -------------------------------------------------------------------------------- /static/images/products/product4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product4.jpg -------------------------------------------------------------------------------- /static/images/products/product5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product5.jpg -------------------------------------------------------------------------------- /static/images/products/product6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product6.jpg -------------------------------------------------------------------------------- /static/images/products/product7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product7.jpg -------------------------------------------------------------------------------- /static/images/products/product8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product8.jpg -------------------------------------------------------------------------------- /static/images/products/product9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/static/images/products/product9.jpg -------------------------------------------------------------------------------- /static/js/htmx.1.9.4.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var G={onLoad:t,process:Nt,on:le,off:ue,trigger:oe,ajax:xr,find:b,findAll:f,closest:d,values:function(e,t){var r=er(e,t||"post");return r.values},remove:U,addClass:B,removeClass:n,toggleClass:V,takeClass:j,defineExtension:Cr,removeExtension:Rr,logAll:X,logNone:F,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"]},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=G.config.wsBinaryType;return t},version:"1.9.4"};var C={addTriggerHandler:bt,bodyContains:re,canAccessLocalStorage:M,findThisElement:he,filterValues:ar,hasAttribute:o,getAttributeValue:Z,getClosestAttributeValue:Y,getClosestMatch:c,getExpressionVars:gr,getHeaders:ir,getInputValues:er,getInternalData:ee,getSwapSpecification:sr,getTriggerSpecs:Ge,getTarget:de,makeFragment:l,mergeObjects:ne,makeSettleInfo:S,oobSwap:me,querySelectorExt:ie,selectAndSwap:De,settleImmediately:Wt,shouldCancel:Qe,triggerEvent:oe,triggerErrorEvent:ae,withExtensions:w};var R=["get","post","put","delete","patch"];var O=R.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function J(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function Z(e,t){return J(e,t)||J(e,"data-"+t)}function u(e){return e.parentElement}function K(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function T(e,t,r){var n=Z(t,r);var i=Z(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function Y(t,r){var n=null;c(t,function(e){return n=T(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=K().createDocumentFragment()}return i}function H(e){return e.match(/",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i(""+e+"
",1);case"col":return i(""+e+"
",2);case"tr":return i(""+e+"
",2);case"td":case"th":return i(""+e+"
",3);case"script":return i("
"+e+"
",1);default:return i(e,0)}}}function Q(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ee(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r=0}function re(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return K().body.contains(e.getRootNode().host)}else{return K().body.contains(e)}}function P(e){return e.trim().split(/\s+/)}function ne(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function y(e){try{return JSON.parse(e)}catch(e){x(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return hr(K().body,function(){return eval(e)})}function t(t){var e=G.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){G.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function F(){G.logger=null}function b(e,t){if(t){return e.querySelector(t)}else{return b(K(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(K(),e)}}function U(e,t){e=s(e);if(t){setTimeout(function(){U(e);e=null},t)}else{e.parentElement.removeChild(e)}}function B(e,t,r){e=s(e);if(r){setTimeout(function(){B(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function V(e,t){e=s(e);e.classList.toggle(t)}function j(e,t){e=s(e);te(e.parentElement.children,function(e){n(e,t)});B(e,t)}function d(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function r(e){var t=e.trim();if(t.startsWith("<")&&t.endsWith("/>")){return t.substring(1,t.length-2)}else{return t}}function W(e,t){if(t.indexOf("closest ")===0){return[d(e,r(t.substr(8)))]}else if(t.indexOf("find ")===0){return[b(e,r(t.substr(5)))]}else if(t.indexOf("next ")===0){return[_(e,r(t.substr(5)))]}else if(t.indexOf("previous ")===0){return[z(e,r(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else{return K().querySelectorAll(r(t))}}var _=function(e,t){var r=K().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ie(e,t){if(t){return W(e,t)[0]}else{return W(K().body,e)[0]}}function s(e){if(L(e,"String")){return b(e)}else{return e}}function $(e,t,r){if(A(t)){return{target:K().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function le(t,r,n){Tr(function(){var e=$(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=A(r);return e?r:n}function ue(t,r,n){Tr(function(){var e=$(t,r,n);e.target.removeEventListener(e.event,e.listener)});return A(r)?r:n}var fe=K().createElement("output");function ce(e,t){var r=Y(e,t);if(r){if(r==="this"){return[he(e,t)]}else{var n=W(e,r);if(n.length===0){x('The selector "'+r+'" on '+t+" returned no matches!");return[fe]}else{return n}}}}function he(e,t){return c(e,function(e){return Z(e,t)!=null})}function de(e){var t=Y(e,"hx-target");if(t){if(t==="this"){return he(e,"hx-target")}else{return ie(e,t)}}else{var r=ee(e);if(r.boosted){return K().body}else{return e}}}function ve(e){var t=G.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=K().querySelectorAll(t);if(r){te(r,function(e){var t;var r=i.cloneNode(true);t=K().createDocumentFragment();t.appendChild(r);if(!pe(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!oe(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Pe(o,e,e,t,a)}te(a.elts,function(e){oe(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);ae(K().body,"htmx:oobErrorNoTarget",{content:i})}return e}function xe(e,t,r){var n=Y(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();ge(e,i);s.tasks.push(function(){ge(e,a)})}}})}function we(e){return function(){n(e,G.config.addedClass);Nt(e);St(e);Se(e);oe(e,"htmx:load")}}function Se(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){be(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;B(i,G.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(we(i))}}}function Ee(e,t){var r=0;while(r-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function De(e,t,r,n,i,a){i.title=Me(n);var o=l(n);if(o){xe(r,o,i);o=ke(r,o,a);ye(o);return Pe(e,r,t,o,i)}}function Xe(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=y(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!N(o)){o={value:o}}oe(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=hr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){ae(K().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(_e(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function m(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}var $e="input, textarea, select";function Ge(e){var t=Z(e,"hx-trigger");var r=[];if(t){var n=We(t);do{m(n,je);var i=n.length;var a=m(n,/[,\[\s]/);if(a!==""){if(a==="every"){var o={trigger:"every"};m(n,je);o.pollInterval=v(m(n,/[,\[\s]/));m(n,je);var s=ze(e,n,"event");if(s){o.eventFilter=s}r.push(o)}else if(a.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:a.substr(4)})}else{var l={trigger:a};var s=ze(e,n,"event");if(s){l.eventFilter=s}while(n.length>0&&n[0]!==","){m(n,je);var u=n.shift();if(u==="changed"){l.changed=true}else if(u==="once"){l.once=true}else if(u==="consume"){l.consume=true}else if(u==="delay"&&n[0]===":"){n.shift();l.delay=v(m(n,p))}else if(u==="from"&&n[0]===":"){n.shift();var f=m(n,p);if(f==="closest"||f==="find"||f==="next"||f==="previous"){n.shift();f+=" "+m(n,p)}l.from=f}else if(u==="target"&&n[0]===":"){n.shift();l.target=m(n,p)}else if(u==="throttle"&&n[0]===":"){n.shift();l.throttle=v(m(n,p))}else if(u==="queue"&&n[0]===":"){n.shift();l.queue=m(n,p)}else if((u==="root"||u==="threshold")&&n[0]===":"){n.shift();l[u]=m(n,p)}else{ae(e,"htmx:syntax:error",{token:n.shift()})}}r.push(l)}}if(n.length===i){ae(e,"htmx:syntax:error",{token:n.shift()})}m(n,je)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,$e)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function Je(e){ee(e).cancelled=true}function Ze(e,t,r){var n=ee(e);n.timeout=setTimeout(function(){if(re(e)&&n.cancelled!==true){if(!tt(r,e,kt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}Ze(e,t,r)}},r.pollInterval)}function Ke(e){return location.hostname===e.hostname&&J(e,"href")&&J(e,"href").indexOf("#")!==0}function Ye(t,r,e){if(t.tagName==="A"&&Ke(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=t.href}else{var a=J(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=J(t,"action")}e.forEach(function(e){rt(t,function(e,t){if(d(e,G.config.disableSelector)){g(e);return}se(n,i,e,t)},r,e,true)})}}function Qe(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&d(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function et(e,t){return ee(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function tt(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){ae(K().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function rt(a,o,e,s,l){var u=ee(a);var t;if(s.from){t=W(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ee(e);t.lastValue=e.value})}te(t,function(n){var i=function(e){if(!re(a)){n.removeEventListener(s.trigger,i);return}if(et(a,e)){return}if(l||Qe(e,a)){e.preventDefault()}if(tt(s,a,e)){return}var t=ee(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ee(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{oe(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var nt=false;var it=null;function at(){if(!it){it=function(){nt=true};window.addEventListener("scroll",it);setInterval(function(){if(nt){nt=false;te(K().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){ot(e)})}},200)}}function ot(t){if(!o(t,"data-hx-revealed")&&k(t)){t.setAttribute("data-hx-revealed","true");var e=ee(t);if(e.initHash){oe(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){oe(t,"revealed")},{once:true})}}}function st(e,t,r){var n=P(r);for(var i=0;i=0){var t=ct(n);setTimeout(function(){lt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ee(s).webSocket=t;t.addEventListener("message",function(e){if(ut(s)){return}var t=e.data;w(s,function(e){t=e.transformResponse(t,null,s)});var r=S(s);var n=l(t);var i=I(n.children);for(var a=0;a0){oe(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(Qe(e,u)){e.preventDefault()}})}else{ae(u,"htmx:noWebSocketSourceError")}}function ct(e){var t=G.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}x('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function ht(e,t,r){var n=P(r);for(var i=0;i0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Tt(o)}for(var l in r){qt(e,l,r[l])}}}function Lt(t){Re(t);for(var e=0;eG.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ae(K().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Ft(e){if(!M()){return null}e=D(e);var t=y(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){oe(K().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Dt();var r=S(t);var n=Me(this.response);if(n){var i=b("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ie(t,e,r);Wt(r.tasks);Mt=a;oe(K().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{ae(K().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function zt(e){Bt();e=e||location.pathname+location.search;var t=Ft(e);if(t){var r=l(t.content);var n=Dt();var i=S(n);Ie(n,r,i);Wt(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Mt=e;oe(K().body,"htmx:historyRestore",{path:e,item:t})}else{if(G.config.refreshOnHistoryMiss){window.location.reload(true)}else{_t(e)}}}function $t(e){var t=ce(e,"hx-indicator");if(t==null){t=[e]}te(t,function(e){var t=ee(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,G.config.requestClass)});return t}function Gt(e){te(e,function(e){var t=ee(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,G.config.requestClass)}})}function Jt(e,t){for(var r=0;r=0}function sr(e,t){var r=t?t:Y(e,"hx-swap");var n={swapStyle:ee(e).boosted?"innerHTML":G.config.defaultSwapStyle,swapDelay:G.config.defaultSwapDelay,settleDelay:G.config.defaultSettleDelay};if(ee(e).boosted&&!or(e)){n["show"]="top"}if(r){var i=P(r);if(i.length>0){n["swapStyle"]=i[0];for(var a=1;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}if(o.indexOf("focus-scroll:")===0){var d=o.substr("focus-scroll:".length);n["focusScroll"]=d=="true"}}}}return n}function lr(e){return Y(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&J(e,"enctype")==="multipart/form-data"}function ur(t,r,n){var i=null;w(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(lr(r)){return nr(n)}else{return rr(n)}}}function S(e){return{tasks:[],elts:[e]}}function fr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ie(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ie(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:G.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:G.config.scrollBehavior})}}}function cr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=Z(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=hr(e,function(){return Function("return ("+a+")")()},{})}else{s=y(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return cr(u(e),t,r,n)}function hr(e,t,r){if(G.config.allowEval){return t()}else{ae(e,"htmx:evalDisallowedError");return r}}function dr(e,t){return cr(e,"hx-vars",true,t)}function vr(e,t){return cr(e,"hx-vals",false,t)}function gr(e){return ne(dr(e),vr(e))}function pr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function mr(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ae(K().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function E(e,t){return e.getAllResponseHeaders().match(t)}function xr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||L(r,"String")){return se(e,t,null,null,{targetOverride:s(r),returnPromise:true})}else{return se(e,t,s(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:s(r.target),swapOverride:r.swap,returnPromise:true})}}else{return se(e,t,null,null,{returnPromise:true})}}function yr(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function se(e,t,n,r,i,M){var a=null;var o=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var s=new Promise(function(e,t){a=e;o=t})}if(n==null){n=K().body}var D=i.handler||wr;if(!re(n)){return}var l=i.targetOverride||de(n);if(l==null||l==fe){ae(n,"htmx:targetError",{target:Z(n,"hx-target")});return}if(!M){var X=function(){return se(e,t,n,r,i,true)};var F={target:l,elt:n,path:t,verb:e,triggeringEvent:r,etc:i,issueRequest:X};if(oe(n,"htmx:confirm",F)===false){return}}var u=n;var f=ee(n);var c=Y(n,"hx-sync");var h=null;var d=false;if(c){var v=c.split(":");var g=v[0].trim();if(g==="this"){u=he(n,"hx-sync")}else{u=ie(n,g)}c=(v[1]||"drop").trim();f=ee(u);if(c==="drop"&&f.xhr&&f.abortable!==true){return}else if(c==="abort"){if(f.xhr){return}else{d=true}}else if(c==="replace"){oe(u,"htmx:abort")}else if(c.indexOf("queue")===0){var U=c.split(" ");h=(U[1]||"last").trim()}}if(f.xhr){if(f.abortable){oe(u,"htmx:abort")}else{if(h==null){if(r){var p=ee(r);if(p&&p.triggerSpec&&p.triggerSpec.queue){h=p.triggerSpec.queue}}if(h==null){h="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(h==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){se(e,t,n,r,i)})}else if(h==="all"){f.queuedRequests.push(function(){se(e,t,n,r,i)})}else if(h==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){se(e,t,n,r,i)})}return}}var m=new XMLHttpRequest;f.xhr=m;f.abortable=d;var x=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var y=Y(n,"hx-prompt");if(y){var b=prompt(y);if(b===null||!oe(n,"htmx:prompt",{prompt:b,target:l})){Q(a);x();return s}}var w=Y(n,"hx-confirm");if(w){if(!confirm(w)){Q(a);x();return s}}var S=ir(n,l,b);if(i.headers){S=ne(S,i.headers)}var E=er(n,e);var C=E.errors;var R=E.values;if(i.values){R=ne(R,i.values)}var B=gr(n);var O=ne(R,B);var T=ar(O,n);if(e!=="get"&&!lr(n)){S["Content-Type"]="application/x-www-form-urlencoded"}if(G.config.getCacheBusterParam&&e==="get"){T["org.htmx.cache-buster"]=J(l,"id")||"true"}if(t==null||t===""){t=K().location.href}var q=cr(n,"hx-request");var V=ee(n).boosted;var H=G.config.methodsThatUseUrlParams.indexOf(e)>=0;var L={boosted:V,useUrlParams:H,parameters:T,unfilteredParameters:O,headers:S,target:l,verb:e,errors:C,withCredentials:i.credentials||q.credentials||G.config.withCredentials,timeout:i.timeout||q.timeout||G.config.timeout,path:t,triggeringEvent:r};if(!oe(n,"htmx:configRequest",L)){Q(a);x();return s}t=L.path;e=L.verb;S=L.headers;T=L.parameters;C=L.errors;H=L.useUrlParams;if(C&&C.length>0){oe(n,"htmx:validation:halted",L);Q(a);x();return s}var j=t.split("#");var W=j[0];var A=j[1];var N=t;if(H){N=W;var _=Object.keys(T).length!==0;if(_){if(N.indexOf("?")<0){N+="?"}else{N+="&"}N+=rr(T);if(A){N+="#"+A}}}m.open(e.toUpperCase(),N,true);m.overrideMimeType("text/html");m.withCredentials=L.withCredentials;m.timeout=L.timeout;if(q.noHeaders){}else{for(var I in S){if(S.hasOwnProperty(I)){var z=S[I];pr(m,I,z)}}}var k={xhr:m,target:l,requestConfig:L,etc:i,boosted:V,pathInfo:{requestPath:t,finalRequestPath:N,anchor:A}};m.onload=function(){try{var e=yr(n);k.pathInfo.responsePath=mr(m);D(n,k);Gt(P);oe(n,"htmx:afterRequest",k);oe(n,"htmx:afterOnLoad",k);if(!re(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(re(r)){t=r}}if(t){oe(t,"htmx:afterRequest",k);oe(t,"htmx:afterOnLoad",k)}}Q(a);x()}catch(e){ae(n,"htmx:onLoadError",ne({error:e},k));throw e}};m.onerror=function(){Gt(P);ae(n,"htmx:afterRequest",k);ae(n,"htmx:sendError",k);Q(o);x()};m.onabort=function(){Gt(P);ae(n,"htmx:afterRequest",k);ae(n,"htmx:sendAbort",k);Q(o);x()};m.ontimeout=function(){Gt(P);ae(n,"htmx:afterRequest",k);ae(n,"htmx:timeout",k);Q(o);x()};if(!oe(n,"htmx:beforeRequest",k)){Q(a);x();return s}var P=$t(n);te(["loadstart","loadend","progress","abort"],function(t){te([m,m.upload],function(e){e.addEventListener(t,function(e){oe(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});oe(n,"htmx:beforeSend",k);var $=H?null:ur(m,n,T);m.send($);return s}function br(e,t){var r=t.xhr;var n=null;var i=null;if(E(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(E(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(E(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=Y(e,"hx-push-url");var l=Y(e,"hx-replace-url");var u=ee(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function wr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;if(!oe(l,"htmx:beforeOnLoad",u))return;if(E(f,/HX-Trigger:/i)){Xe(f,"HX-Trigger",l)}if(E(f,/HX-Location:/i)){Bt();var t=f.getResponseHeader("HX-Location");var h;if(t.indexOf("{")===0){h=y(t);t=h["path"];delete h["path"]}xr("GET",t,h).then(function(){Vt(t)});return}if(E(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");return}if(E(f,/HX-Refresh:/i)){if("true"===f.getResponseHeader("HX-Refresh")){location.reload();return}}if(E(f,/HX-Retarget:/i)){u.target=K().querySelector(f.getResponseHeader("HX-Retarget"))}var d=br(l,u);var r=f.status>=200&&f.status<400&&f.status!==204;var v=f.response;var n=f.status>=400;var i=ne({shouldSwap:r,serverResponse:v,isError:n},u);if(!oe(c,"htmx:beforeSwap",i))return;c=i.target;v=i.serverResponse;n=i.isError;u.target=c;u.failed=n;u.successful=!n;if(i.shouldSwap){if(f.status===286){Je(l)}w(l,function(e){v=e.transformResponse(v,f,l)});if(d.type){Bt()}var a=e.swapOverride;if(E(f,/HX-Reswap:/i)){a=f.getResponseHeader("HX-Reswap")}var h=sr(l,a);c.classList.add(G.config.swappingClass);var g=null;var p=null;var o=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(E(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}var n=S(c);De(h.swapStyle,c,l,v,n,r);if(t.elt&&!re(t.elt)&&J(t.elt,"id")){var i=document.getElementById(J(t.elt,"id"));var a={preventScroll:h.focusScroll!==undefined?!h.focusScroll:!G.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(G.config.swappingClass);te(n.elts,function(e){if(e.classList){e.classList.add(G.config.settlingClass)}oe(e,"htmx:afterSwap",u)});if(E(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!re(l)){o=K().body}Xe(f,"HX-Trigger-After-Swap",o)}var s=function(){te(n.tasks,function(e){e.call()});te(n.elts,function(e){if(e.classList){e.classList.remove(G.config.settlingClass)}oe(e,"htmx:afterSettle",u)});if(d.type){if(d.type==="push"){Vt(d.path);oe(K().body,"htmx:pushedIntoHistory",{path:d.path})}else{jt(d.path);oe(K().body,"htmx:replacedInHistory",{path:d.path})}}if(u.pathInfo.anchor){var e=b("#"+u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title){var t=b("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}fr(n.elts,h);if(E(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!re(l)){r=K().body}Xe(f,"HX-Trigger-After-Settle",r)}Q(g)};if(h.settleDelay>0){setTimeout(s,h.settleDelay)}else{s()}}catch(e){ae(l,"htmx:swapError",u);Q(p);throw e}};var s=G.config.globalViewTransitions;if(h.hasOwnProperty("transition")){s=h.transition}if(s&&oe(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var m=new Promise(function(e,t){g=e;p=t});var x=o;o=function(){document.startViewTransition(function(){x();return m})}}if(h.swapDelay>0){setTimeout(o,h.swapDelay)}else{o()}}if(n){ae(l,"htmx:responseError",ne({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Sr={};function Er(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Cr(e,t){if(t.init){t.init(C)}Sr[e]=ne(Er(),t)}function Rr(e){delete Sr[e]}function Or(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=Z(e,"hx-ext");if(t){te(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Sr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Or(u(e),r,n)}function Tr(e){if(K().readyState!=="loading"){e()}else{K().addEventListener("DOMContentLoaded",e)}}function qr(){if(G.config.includeIndicatorStyles!==false){K().head.insertAdjacentHTML("beforeend","")}}function Hr(){var e=K().querySelector('meta[name="htmx-config"]');if(e){return y(e.content)}else{return null}}function Lr(){var e=Hr();if(e){G.config=ne(G.config,e)}}Tr(function(){Lr();qr();var e=K().body;Nt(e);var t=K().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ee(t);if(r&&r.xhr){r.xhr.abort()}});var r=window.onpopstate;window.onpopstate=function(e){if(e.state&&e.state.htmx){zt();te(t,function(e){oe(e,"htmx:restored",{document:K(),triggerEvent:oe})})}else{if(r){r(e)}}};setTimeout(function(){oe(e,"htmx:load",{});e=null},0)});return G}()}); -------------------------------------------------------------------------------- /static/js/htmx.1.9.4_dist_ext_sse.js: -------------------------------------------------------------------------------- 1 | /* 2 | Server Sent Events Extension 3 | ============================ 4 | This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. 5 | 6 | */ 7 | 8 | (function(){ 9 | 10 | /** @type {import("../htmx").HtmxInternalApi} */ 11 | var api; 12 | 13 | htmx.defineExtension("sse", { 14 | 15 | /** 16 | * Init saves the provided reference to the internal HTMX API. 17 | * 18 | * @param {import("../htmx").HtmxInternalApi} api 19 | * @returns void 20 | */ 21 | init: function(apiRef) { 22 | // store a reference to the internal API. 23 | api = apiRef; 24 | 25 | // set a function in the public API for creating new EventSource objects 26 | if (htmx.createEventSource == undefined) { 27 | htmx.createEventSource = createEventSource; 28 | } 29 | }, 30 | 31 | /** 32 | * onEvent handles all events passed to this extension. 33 | * 34 | * @param {string} name 35 | * @param {Event} evt 36 | * @returns void 37 | */ 38 | onEvent: function(name, evt) { 39 | 40 | switch (name) { 41 | 42 | // Try to remove remove an EventSource when elements are removed 43 | case "htmx:beforeCleanupElement": 44 | var internalData = api.getInternalData(evt.target) 45 | if (internalData.sseEventSource) { 46 | internalData.sseEventSource.close(); 47 | } 48 | return; 49 | 50 | // Try to create EventSources when elements are processed 51 | case "htmx:afterProcessNode": 52 | createEventSourceOnElement(evt.target); 53 | } 54 | } 55 | }); 56 | 57 | /////////////////////////////////////////////// 58 | // HELPER FUNCTIONS 59 | /////////////////////////////////////////////// 60 | 61 | 62 | /** 63 | * createEventSource is the default method for creating new EventSource objects. 64 | * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. 65 | * 66 | * @param {string} url 67 | * @returns EventSource 68 | */ 69 | function createEventSource(url) { 70 | return new EventSource(url, {withCredentials:true}); 71 | } 72 | 73 | function splitOnWhitespace(trigger) { 74 | return trigger.trim().split(/\s+/); 75 | } 76 | 77 | function getLegacySSEURL(elt) { 78 | var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); 79 | if (legacySSEValue) { 80 | var values = splitOnWhitespace(legacySSEValue); 81 | for (var i = 0; i < values.length; i++) { 82 | var value = values[i].split(/:(.+)/); 83 | if (value[0] === "connect") { 84 | return value[1]; 85 | } 86 | } 87 | } 88 | } 89 | 90 | function getLegacySSESwaps(elt) { 91 | var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); 92 | var returnArr = []; 93 | if (legacySSEValue) { 94 | var values = splitOnWhitespace(legacySSEValue); 95 | for (var i = 0; i < values.length; i++) { 96 | var value = values[i].split(/:(.+)/); 97 | if (value[0] === "swap") { 98 | returnArr.push(value[1]); 99 | } 100 | } 101 | } 102 | return returnArr; 103 | } 104 | 105 | /** 106 | * createEventSourceOnElement creates a new EventSource connection on the provided element. 107 | * If a usable EventSource already exists, then it is returned. If not, then a new EventSource 108 | * is created and stored in the element's internalData. 109 | * @param {HTMLElement} elt 110 | * @param {number} retryCount 111 | * @returns {EventSource | null} 112 | */ 113 | function createEventSourceOnElement(elt, retryCount) { 114 | 115 | if (elt == null) { 116 | return null; 117 | } 118 | 119 | var internalData = api.getInternalData(elt); 120 | 121 | // get URL from element's attribute 122 | var sseURL = api.getAttributeValue(elt, "sse-connect"); 123 | 124 | 125 | if (sseURL == undefined) { 126 | var legacyURL = getLegacySSEURL(elt) 127 | if (legacyURL) { 128 | sseURL = legacyURL; 129 | } else { 130 | return null; 131 | } 132 | } 133 | 134 | // Connect to the EventSource 135 | var source = htmx.createEventSource(sseURL); 136 | internalData.sseEventSource = source; 137 | 138 | // Create event handlers 139 | source.onerror = function (err) { 140 | 141 | // Log an error event 142 | api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source}); 143 | 144 | // If parent no longer exists in the document, then clean up this EventSource 145 | if (maybeCloseSSESource(elt)) { 146 | return; 147 | } 148 | 149 | // Otherwise, try to reconnect the EventSource 150 | if (source.readyState === EventSource.CLOSED) { 151 | retryCount = retryCount || 0; 152 | var timeout = Math.random() * (2 ^ retryCount) * 500; 153 | window.setTimeout(function() { 154 | createEventSourceOnElement(elt, Math.min(7, retryCount+1)); 155 | }, timeout); 156 | } 157 | }; 158 | 159 | source.onopen = function (evt) { 160 | api.triggerEvent(elt, "htmx:sseOpen", {source: source}); 161 | } 162 | 163 | // Add message handlers for every `sse-swap` attribute 164 | queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { 165 | 166 | var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); 167 | if (sseSwapAttr) { 168 | var sseEventNames = sseSwapAttr.split(","); 169 | } else { 170 | var sseEventNames = getLegacySSESwaps(child); 171 | } 172 | 173 | for (var i = 0 ; i < sseEventNames.length ; i++) { 174 | var sseEventName = sseEventNames[i].trim(); 175 | var listener = function(event) { 176 | 177 | // If the parent is missing then close SSE and remove listener 178 | if (maybeCloseSSESource(elt)) { 179 | source.removeEventListener(sseEventName, listener); 180 | return; 181 | } 182 | 183 | // swap the response into the DOM and trigger a notification 184 | swap(child, event.data); 185 | api.triggerEvent(elt, "htmx:sseMessage", event); 186 | }; 187 | 188 | // Register the new listener 189 | api.getInternalData(elt).sseEventListener = listener; 190 | source.addEventListener(sseEventName, listener); 191 | } 192 | }); 193 | 194 | // Add message handlers for every `hx-trigger="sse:*"` attribute 195 | queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { 196 | 197 | var sseEventName = api.getAttributeValue(child, "hx-trigger"); 198 | if (sseEventName == null) { 199 | return; 200 | } 201 | 202 | // Only process hx-triggers for events with the "sse:" prefix 203 | if (sseEventName.slice(0, 4) != "sse:") { 204 | return; 205 | } 206 | 207 | var listener = function(event) { 208 | 209 | // If parent is missing, then close SSE and remove listener 210 | if (maybeCloseSSESource(elt)) { 211 | source.removeEventListener(sseEventName, listener); 212 | return; 213 | } 214 | 215 | // Trigger events to be handled by the rest of htmx 216 | htmx.trigger(child, sseEventName, event); 217 | htmx.trigger(child, "htmx:sseMessage", event); 218 | } 219 | 220 | // Register the new listener 221 | api.getInternalData(elt).sseEventListener = listener; 222 | source.addEventListener(sseEventName.slice(4), listener); 223 | }); 224 | } 225 | 226 | /** 227 | * maybeCloseSSESource confirms that the parent element still exists. 228 | * If not, then any associated SSE source is closed and the function returns true. 229 | * 230 | * @param {HTMLElement} elt 231 | * @returns boolean 232 | */ 233 | function maybeCloseSSESource(elt) { 234 | if (!api.bodyContains(elt)) { 235 | var source = api.getInternalData(elt).sseEventSource; 236 | if (source != undefined) { 237 | source.close(); 238 | // source = null 239 | return true; 240 | } 241 | } 242 | return false; 243 | } 244 | 245 | /** 246 | * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. 247 | * 248 | * @param {HTMLElement} elt 249 | * @param {string} attributeName 250 | */ 251 | function queryAttributeOnThisOrChildren(elt, attributeName) { 252 | 253 | var result = []; 254 | 255 | // If the parent element also contains the requested attribute, then add it to the results too. 256 | if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) { 257 | result.push(elt); 258 | } 259 | 260 | // Search all child nodes that match the requested attribute 261 | elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) { 262 | result.push(node); 263 | }); 264 | 265 | return result; 266 | } 267 | 268 | /** 269 | * @param {HTMLElement} elt 270 | * @param {string} content 271 | */ 272 | function swap(elt, content) { 273 | 274 | api.withExtensions(elt, function(extension) { 275 | content = extension.transformResponse(content, null, elt); 276 | }); 277 | 278 | var swapSpec = api.getSwapSpecification(elt); 279 | var target = api.getTarget(elt); 280 | var settleInfo = api.makeSettleInfo(elt); 281 | 282 | api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); 283 | 284 | settleInfo.elts.forEach(function (elt) { 285 | if (elt.classList) { 286 | elt.classList.add(htmx.config.settlingClass); 287 | } 288 | api.triggerEvent(elt, 'htmx:beforeSettle'); 289 | }); 290 | 291 | // Handle settle tasks (with delay if requested) 292 | if (swapSpec.settleDelay > 0) { 293 | setTimeout(doSettle(settleInfo), swapSpec.settleDelay); 294 | } else { 295 | doSettle(settleInfo)(); 296 | } 297 | } 298 | 299 | /** 300 | * doSettle mirrors much of the functionality in htmx that 301 | * settles elements after their content has been swapped. 302 | * TODO: this should be published by htmx, and not duplicated here 303 | * @param {import("../htmx").HtmxSettleInfo} settleInfo 304 | * @returns () => void 305 | */ 306 | function doSettle(settleInfo) { 307 | 308 | return function() { 309 | settleInfo.tasks.forEach(function (task) { 310 | task.call(); 311 | }); 312 | 313 | settleInfo.elts.forEach(function (elt) { 314 | if (elt.classList) { 315 | elt.classList.remove(htmx.config.settlingClass); 316 | } 317 | api.triggerEvent(elt, 'htmx:afterSettle'); 318 | }); 319 | } 320 | } 321 | 322 | })(); -------------------------------------------------------------------------------- /stream/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyHAT-stack/web-async-patterns/2d2b356945d3b5a23f39b42832d2acdd2bb7b45a/stream/__init__.py -------------------------------------------------------------------------------- /stream/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for stream project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stream.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /stream/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for stream project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | from jinja2 import FileSystemLoader 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-v2xyf4$&45iz^1-ltvlw66hs0n)chyqd!4wxv%d06g8773elh(" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | 'home', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "stream.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | { 70 | "BACKEND": "django.template.backends.jinja2.Jinja2", 71 | "DIRS": [ 72 | BASE_DIR / 'jinja', 73 | ], 74 | 'OPTIONS': { 75 | 'enable_async': True, 76 | 'loader': FileSystemLoader(BASE_DIR / 'jinja') 77 | } 78 | }, 79 | ] 80 | 81 | WSGI_APPLICATION = "stream.wsgi.application" 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 88 | 89 | # Internationalization 90 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 91 | 92 | LANGUAGE_CODE = "en-us" 93 | 94 | TIME_ZONE = "UTC" 95 | 96 | USE_I18N = True 97 | 98 | USE_TZ = True 99 | 100 | # Static files (CSS, JavaScript, Images) 101 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 102 | 103 | STATIC_URL = "static/" 104 | 105 | STATIC_ROOT = BASE_DIR / "static" 106 | 107 | # Default primary key field type 108 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 109 | 110 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 111 | -------------------------------------------------------------------------------- /stream/urls.py: -------------------------------------------------------------------------------- 1 | """stream URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import path 20 | 21 | import home.views 22 | import home.views.stream 23 | import home.views.sse 24 | import home.views.jinja 25 | 26 | urlpatterns = [ 27 | path('', home.views.chooser), 28 | path('stream', home.views.stream.index, name='index'), 29 | path('via-sse', home.views.sse.index, name='index_sse'), 30 | path('recommend-sse', home.views.sse.handle_sse, name='index_sse'), 31 | path('jinja', home.views.jinja.index, name='index_jinja'), 32 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 33 | -------------------------------------------------------------------------------- /stream/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for stream project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stream.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------