hyperflask 0.6.0__tar.gz → 0.7.1__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.
Files changed (127) hide show
  1. {hyperflask-0.6.0 → hyperflask-0.7.1}/PKG-INFO +3 -3
  2. {hyperflask-0.6.0 → hyperflask-0.7.1}/README.md +1 -1
  3. {hyperflask-0.6.0 → hyperflask-0.7.1}/pyproject.toml +2 -5
  4. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/app.py +9 -6
  5. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/components/core.py +20 -2
  6. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/layouts/web.html +2 -2
  7. hyperflask-0.7.1/src/hyperflask/static/app.js +11 -0
  8. hyperflask-0.7.1/src/hyperflask/static/htmx-exts.js +186 -0
  9. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/forms.html +3 -2
  10. hyperflask-0.7.1/src/hyperflask/ui/sse.html +24 -0
  11. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/htmx.py +9 -9
  12. hyperflask-0.6.0/src/hyperflask/static/app.js +0 -14
  13. hyperflask-0.6.0/src/hyperflask/static/htmx-exts.js +0 -128
  14. hyperflask-0.6.0/src/hyperflask/ui/sse.html +0 -22
  15. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/__init__.py +0 -0
  16. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/__main__.py +0 -0
  17. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/actors.py +0 -0
  18. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/cli/__init__.py +0 -0
  19. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/cli/reloader.py +0 -0
  20. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/cli/runner.py +0 -0
  21. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/cli/scaffold.py +0 -0
  22. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/cli/worker.py +0 -0
  23. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/components/__init__.py +0 -0
  24. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/components/alpine.py +0 -0
  25. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/components/jsx.py +0 -0
  26. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/components/webcomponent.py +0 -0
  27. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/factory.py +0 -0
  28. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/forms/__init__.py +0 -0
  29. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/forms/fields.py +0 -0
  30. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/forms/widgets.py +0 -0
  31. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/layouts/email.mjml +0 -0
  32. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/models.py +0 -0
  33. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/page_helpers.py +0 -0
  34. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/security.py +0 -0
  35. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/alpine-csp.js +0 -0
  36. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/alpine.js +0 -0
  37. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/app.css +0 -0
  38. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/react.js +0 -0
  39. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/stimulus.js +0 -0
  40. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/static/translations.js +0 -0
  41. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Accordion.html +0 -0
  42. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/AccordionPanel.html +0 -0
  43. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Alert.html +0 -0
  44. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Avatar.html +0 -0
  45. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/AvatarGroup.html +0 -0
  46. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Badge.html +0 -0
  47. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Breadcrumbs.html +0 -0
  48. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/BreadcrumbsItem.html +0 -0
  49. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Button.html +0 -0
  50. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Card.html +0 -0
  51. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/CardActions.html +0 -0
  52. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/CardTitle.html +0 -0
  53. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/ChatBubble.html +0 -0
  54. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Checkbox.html +0 -0
  55. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/CollapsibleSubMenu.html +0 -0
  56. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Countdown.html +0 -0
  57. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Divider.html +0 -0
  58. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Drawer.html +0 -0
  59. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/DrawerContent.html +0 -0
  60. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/DrawerSide.html +0 -0
  61. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Dropdown.html +0 -0
  62. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/DropdownItem.html +0 -0
  63. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Fieldset.html +0 -0
  64. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/FieldsetLegend.html +0 -0
  65. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/FileInput.html +0 -0
  66. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Filter.html +0 -0
  67. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/FilterItem.html +0 -0
  68. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Footer.html +0 -0
  69. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/FooterLink.html +0 -0
  70. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/FooterNav.html +0 -0
  71. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Indicator.html +0 -0
  72. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Input.html +0 -0
  73. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/InputLabel.html +0 -0
  74. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Join.html +0 -0
  75. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Kbd.html +0 -0
  76. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Label.html +0 -0
  77. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/LabeledCheckbox.html +0 -0
  78. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/LabeledToggle.html +0 -0
  79. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Link.html +0 -0
  80. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/List.html +0 -0
  81. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/ListItem.html +0 -0
  82. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Loading.html +0 -0
  83. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Menu.html +0 -0
  84. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/MenuItem.html +0 -0
  85. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/MenuTitle.html +0 -0
  86. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Modal.html +0 -0
  87. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/ModalAction.html +0 -0
  88. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/ModalTitle.html +0 -0
  89. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Navbar.html +0 -0
  90. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Pagination.html +0 -0
  91. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/PaginationNext.html +0 -0
  92. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/PaginationPage.html +0 -0
  93. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/PaginationPrev.html +0 -0
  94. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Progress.html +0 -0
  95. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/README.md +0 -0
  96. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Radio.html +0 -0
  97. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Range.html +0 -0
  98. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Rating.html +0 -0
  99. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Select.html +0 -0
  100. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Stack.html +0 -0
  101. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Stat.html +0 -0
  102. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Stats.html +0 -0
  103. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Step.html +0 -0
  104. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Steps.html +0 -0
  105. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/SubMenu.html +0 -0
  106. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Swap.html +0 -0
  107. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Tab.html +0 -0
  108. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Table.html +0 -0
  109. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Tabs.html +0 -0
  110. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Textarea.html +0 -0
  111. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Toast.html +0 -0
  112. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Toggle.html +0 -0
  113. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/daisyui/Tooltip.html +0 -0
  114. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/flash.html +0 -0
  115. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/icons.html +0 -0
  116. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/layout.html +0 -0
  117. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/modal.html +0 -0
  118. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/ui/skeleton.html +0 -0
  119. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/__init__.py +0 -0
  120. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/collections.py +0 -0
  121. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/freezer.py +0 -0
  122. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/html.py +0 -0
  123. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/image.py +0 -0
  124. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/markdown.py +0 -0
  125. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/metadata.py +0 -0
  126. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/page_actions.py +0 -0
  127. {hyperflask-0.6.0 → hyperflask-0.7.1}/src/hyperflask/utils/tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperflask
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Full stack web framework
5
5
  Author: Maxime Bouroumeau-Fuseau
6
6
  Author-email: Maxime Bouroumeau-Fuseau <maxime.bouroumeau@gmail.com>
@@ -28,7 +28,7 @@ Requires-Dist: html-sanitizer~=2.6
28
28
  Requires-Dist: htmx-flask~=0.1
29
29
  Requires-Dist: jinja-layout~=0.5
30
30
  Requires-Dist: jinja-wtforms~=0.2
31
- Requires-Dist: periodiq~=0.13
31
+ Requires-Dist: periodiq~=0.14
32
32
  Requires-Dist: python-slugify~=8.0.4
33
33
  Requires-Dist: watchdog>=6.0.0
34
34
  Requires-Dist: pillow>=11.3.0
@@ -114,6 +114,6 @@ A good part of these extensions is developed as part of the Hyperflask project.
114
114
  1. Create your project directory: `mkdir example-project && cd example-project`
115
115
  2. Create and activate a virtualenv: `python -m venv .venv && source .venv/bin/activate`
116
116
  3. `pip install hyperflask`
117
- 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org htmx-ext-sse bootstrap-icons daisyui`
117
+ 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org@4 bootstrap-icons daisyui`
118
118
  4. Run `mkdir pages && echo "Hello world" > pages/index.jpy`
119
119
  6. Start a development server using `hyperflask dev`
@@ -76,6 +76,6 @@ A good part of these extensions is developed as part of the Hyperflask project.
76
76
  1. Create your project directory: `mkdir example-project && cd example-project`
77
77
  2. Create and activate a virtualenv: `python -m venv .venv && source .venv/bin/activate`
78
78
  3. `pip install hyperflask`
79
- 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org htmx-ext-sse bootstrap-icons daisyui`
79
+ 5. Run `npm install esbuild tailwindcss @tailwindcss/cli htmx.org@4 bootstrap-icons daisyui`
80
80
  4. Run `mkdir pages && echo "Hello world" > pages/index.jpy`
81
81
  6. Start a development server using `hyperflask dev`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperflask"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "Full stack web framework"
5
5
  authors = [
6
6
  {"name" = "Maxime Bouroumeau-Fuseau", email = "maxime.bouroumeau@gmail.com"}
@@ -33,15 +33,12 @@ dependencies = [
33
33
  "htmx-flask ~=0.1",
34
34
  "jinja-layout ~=0.5",
35
35
  "jinja-wtforms ~=0.2",
36
- "periodiq ~=0.13",
36
+ "periodiq ~=0.14",
37
37
  "python-slugify ~=8.0.4",
38
38
  "watchdog>=6.0.0",
39
39
  "pillow>=11.3.0",
40
40
  ]
41
41
 
42
- [tool.uv.sources]
43
- periodiq = { git = "https://gitlab.com/bersace/periodiq" }
44
-
45
42
  [project.urls]
46
43
  Repository = "https://github.com/hyperflask/hyperflask"
47
44
 
@@ -16,7 +16,7 @@ from flask_files import Files
16
16
  from flask_sqlorm import FlaskSQLORM
17
17
  from flask_mercure_sse import MercureSSE
18
18
  from flask_mailman_templates import MailTemplates
19
- from flask_suspense import Suspense, render_template, suspense_before_render_template, suspense_before_render_macros
19
+ from flask_suspense import Suspense, render_template, suspense_before_render_template, suspense_after_render_macros
20
20
  #from flask_observability import Observability
21
21
  # 3rd party extensions
22
22
  from flask_wtf.csrf import CSRFProtect
@@ -82,7 +82,7 @@ class Hyperflask(Flask):
82
82
  "ALPINE": False,
83
83
  "STATIC_MODE": static_mode,
84
84
  "ASSETS_INCLUDE_ALPINE": False,
85
- "HTMX_EXT": [],
85
+ "HTMX_CONFIG": {},
86
86
  "HTMX_BOOST_SITE": False,
87
87
  "MARKDOWN_OPTIONS": {"extensions": ["fenced_code", "nl2br", "attr_list", "admonition", "codehilite"]},
88
88
  "MARKDOWN_SANITIZER_CONFIG": {},
@@ -111,6 +111,8 @@ class Hyperflask(Flask):
111
111
  self.config["PAGES_MARKDOWN_OPTIONS"] = self.config["MARKDOWN_OPTIONS"]
112
112
  self.config["COLLECTIONS_MARKDOWN_OPTIONS"] = self.config["MARKDOWN_OPTIONS"]
113
113
  self.config["MAIL_TEMPLATES_MARKDOWN_OPTIONS"] = self.config["MARKDOWN_OPTIONS"]
114
+ # error responses are not swapped (only out of band swaps are processed)
115
+ self.config["HTMX_CONFIG"] = {"noSwap": [204, 304, "4xx", "5xx"], **self.config["HTMX_CONFIG"]}
114
116
 
115
117
  if self.config['SERVER_SECURED']:
116
118
  self.config.update({
@@ -244,10 +246,11 @@ class Hyperflask(Flask):
244
246
  def on_suspense_before_render_template(sender, **kwargs):
245
247
  csp_nonce() # we will need a nonce but the first call will be after the template started rendering
246
248
 
247
- @suspense_before_render_macros.connect_via(self, weak=False)
248
- def on_suspense_before_render_macros(sender, **kwargs):
249
- return ""
250
- return f'<script nonce="{csp_nonce()}">htmx.bootstrap()</script>' # waiting on PR 3441
249
+ @suspense_after_render_macros.connect_via(self, weak=False)
250
+ def on_suspense_after_render_macros(sender, **kwargs):
251
+ # process the content inserted by suspense macros during htmx requests
252
+ # (during full page loads, htmx is not loaded yet and processes the whole page once loaded)
253
+ return f'<script nonce="{csp_nonce()}">window.htmx?.process(document.body)</script>'
251
254
 
252
255
  @self.errorhandler(404)
253
256
  def not_found(error):
@@ -100,13 +100,31 @@ class ComponentAdapter(BaseComponentAdapter):
100
100
 
101
101
 
102
102
  class PropsWrapper:
103
+ # Not a Mapping subclass on purpose: methods like items() or values() would shadow props
104
+ # with the same name since jinja tries getattr() first. keys() and __getitem__() are enough for **props.
103
105
  def __init__(self, props):
104
106
  self._props = props
105
107
 
106
108
  def __getattr__(self, name):
107
- if name in self._props:
109
+ if not name.startswith("_") and name in self._props:
108
110
  return self._props[name]
109
- raise Exception(f"No such prop: {name}")
111
+ # AttributeError lets jinja fall back to __getitem__ which makes missing props undefined
112
+ raise AttributeError(f"No such prop: {name}")
113
+
114
+ def __getitem__(self, name):
115
+ return self._props[name]
116
+
117
+ def __contains__(self, name):
118
+ return name in self._props
119
+
120
+ def __iter__(self):
121
+ return iter(self._props)
122
+
123
+ def __len__(self):
124
+ return len(self._props)
125
+
126
+ def keys(self):
127
+ return self._props.keys()
110
128
 
111
129
  def get(self, name, default=None):
112
130
  return self._props.get(name, default)
@@ -5,14 +5,14 @@
5
5
  {% block head_metas %}
6
6
  {{ metadata_tags(page.get('metadata', {})) }}
7
7
  {% endblock %}
8
+ <meta name="htmx-config" content='{{ config["HTMX_CONFIG"]|tojson }}'>
8
9
  {% asset_tags(nonce=csp_nonce) %}
9
10
  {% block head_assets %}{% endblock %}
10
11
  {%- endblock %}
11
12
  </head>
12
13
  {% block body_tag -%}
13
14
  <body
14
- {% if config["HTMX_EXT"] %}hx-ext="{{ ','.join(config['HTMX_EXT']) }}"{% endif %}
15
- {% if config["HTMX_BOOST_SITE"] %}hx-boost="true"{% endif %}
15
+ {% if config["HTMX_BOOST_SITE"] %}hx-boost:inherited="true"{% endif %}
16
16
  {% block body_tag_attrs %}{% endblock %}
17
17
  >
18
18
  {%- endblock %}
@@ -0,0 +1,11 @@
1
+ import htmx from "htmx.org";
2
+ import "htmx.org/dist/ext/hx-sse.js";
3
+ import "./htmx-exts";
4
+ import "./app.css";
5
+
6
+ window.htmx = htmx;
7
+ export { htmx };
8
+
9
+ if (window.Alpine) {
10
+ window.Alpine.start()
11
+ }
@@ -0,0 +1,186 @@
1
+ import htmx from "htmx.org";
2
+
3
+ function queryAll(root, selector) {
4
+ const elts = [...root.querySelectorAll(selector)];
5
+ if (root.matches(selector)) {
6
+ elts.unshift(root);
7
+ }
8
+ return elts;
9
+ }
10
+
11
+ htmx.registerExtension("hf-modal", {
12
+ htmx_before_process(root) {
13
+ for (const elt of queryAll(root, "[hf-modal]")) {
14
+ elt.setAttribute("hx-get", elt.getAttribute("hf-modal"));
15
+ elt.setAttribute("hx-target", "body");
16
+ elt.setAttribute("hx-swap", "beforeend");
17
+ if (!elt.hasAttribute("hx-disable")) {
18
+ elt.setAttribute("hx-disable", "this");
19
+ }
20
+ }
21
+ },
22
+ htmx_after_settle(target, detail) {
23
+ const triggerElement = detail.task.sourceElement;
24
+ if (detail.task.type !== "main" || !triggerElement?.hasAttribute("hf-modal")) return;
25
+ for (const dialog of detail.newContent) {
26
+ if (dialog.tagName !== "DIALOG") continue;
27
+ dialog.showModal();
28
+ const form = dialog.querySelector("form[hf-modal-form]");
29
+ if (form) {
30
+ form.setAttribute("hx-target", "closest dialog");
31
+ form.setAttribute("hx-swap", "delete");
32
+ form.setAttribute("hx-disable", "this");
33
+ if (!form.hasAttribute("action")) {
34
+ form.setAttribute("action", triggerElement.getAttribute("hf-modal"));
35
+ }
36
+ setTimeout(() => {
37
+ form.querySelector("input:not([type=hidden])")?.focus();
38
+ }, 100);
39
+ }
40
+ }
41
+ }
42
+ });
43
+
44
+ htmx.registerExtension("hx-bottom-scroll-on-trigger", {
45
+ htmx_after_process(root) {
46
+ for (const elt of queryAll(root, "[hx-bottom-scroll-on-trigger]")) {
47
+ if (elt._hfBottomScroll) continue;
48
+ elt._hfBottomScroll = true;
49
+ elt.scrollTop = elt.scrollHeight - elt.clientHeight;
50
+ elt.classList.add("at-bottom");
51
+ elt.addEventListener("scroll", () => {
52
+ if (elt.scrollTop < elt.scrollHeight - elt.clientHeight - 20) {
53
+ elt.classList.remove("at-bottom");
54
+ } else {
55
+ elt.classList.add("at-bottom");
56
+ }
57
+ });
58
+ elt.addEventListener(elt.getAttribute("hx-bottom-scroll-on-trigger"), () => {
59
+ if (elt.classList.contains("at-bottom")) {
60
+ elt.scrollTop = elt.scrollHeight - elt.clientHeight;
61
+ }
62
+ });
63
+ }
64
+ }
65
+ });
66
+
67
+ // Port of the htmx 1 remove-me extension: removes elements after a delay (eg. remove-me="5s")
68
+ htmx.registerExtension("remove-me", {
69
+ htmx_after_process(root) {
70
+ for (const elt of queryAll(root, "[remove-me],[data-remove-me]")) {
71
+ if (elt._hfRemoveMe) continue;
72
+ elt._hfRemoveMe = true;
73
+ const timing = elt.getAttribute("remove-me") || elt.getAttribute("data-remove-me");
74
+ if (timing) {
75
+ setTimeout(() => elt.remove(), htmx.parseInterval(timing));
76
+ }
77
+ }
78
+ }
79
+ });
80
+
81
+ (function () {
82
+ function ensureLinksAreActive() {
83
+ const nodes = document.querySelectorAll('a[hx-active-url], [hx-active-url] a');
84
+ nodes.forEach((a) => {
85
+ let url;
86
+ for (const attr of ['hx-push-url', 'hx-replace-url', 'href', 'hx-get']) {
87
+ if (a.hasAttribute(attr)) {
88
+ if (attr !== "href" && a.getAttribute(attr) === "true") {
89
+ url = new URL(a.getAttribute('hx-get'), window.location.origin);
90
+ } else {
91
+ url = new URL(a.getAttribute(attr), window.location.origin);
92
+ }
93
+ break;
94
+ }
95
+ }
96
+ if (!url) return;
97
+ const activeClass = a.closest("[hx-active-url]")?.getAttribute("hx-active-url");
98
+ if (!activeClass) return;
99
+ if (url.pathname === window.location.pathname && url.search === window.location.search) {
100
+ a.classList.add(activeClass);
101
+ } else {
102
+ a.classList.remove(activeClass);
103
+ }
104
+ });
105
+ }
106
+
107
+ htmx.registerExtension("hx-active-url", {
108
+ init() {
109
+ document.addEventListener('DOMContentLoaded', ensureLinksAreActive);
110
+ },
111
+ htmx_before_process(root) {
112
+ for (const elt of queryAll(root, "[hx-href]")) {
113
+ elt.setAttribute('href', elt.getAttribute('hx-href'));
114
+ elt.setAttribute('hx-push-url', elt.getAttribute('hx-href'));
115
+ }
116
+ },
117
+ htmx_after_history_push: ensureLinksAreActive,
118
+ htmx_after_history_replace: ensureLinksAreActive
119
+ });
120
+ })();
121
+
122
+ (function () {
123
+ // Swaps named SSE events into elements, like the htmx 2 sse-swap attribute.
124
+ // The htmx 4 sse extension only swaps unnamed messages into the connection element
125
+ // and dispatches named events as DOM events.
126
+ const SSE_CONNECT_SELECTOR = "[hx-sse\\:connect],[data-hx-sse\\:connect]";
127
+ const SSE_SWAP_ATTR = "hx-sse:swap";
128
+ let api;
129
+
130
+ htmx.registerExtension("hx-sse-swap", {
131
+ init(internalAPI) {
132
+ api = internalAPI;
133
+ },
134
+ htmx_sse_before_message(elt, detail) {
135
+ const swapElts = queryAll(elt, "[hx-sse\\:swap]").filter((e) => e.closest(SSE_CONNECT_SELECTOR) === elt);
136
+ if (!swapElts.length) return;
137
+ // unnamed messages are treated as "message" events so that the sse extension does not swap them
138
+ const event = detail.message.event ||= "message";
139
+ for (const swapElt of swapElts) {
140
+ if (!swapElt.getAttribute(SSE_SWAP_ATTR).split(/\s*,\s*/).includes(event)) continue;
141
+ const ctx = api.createRequestContext(swapElt, new CustomEvent(event));
142
+ ctx.text = detail.message.data;
143
+ ctx.swap += " swapEmpty:false";
144
+ detail.waitUntil(htmx.swap(ctx).catch((error) => api.triggerHtmxEvent(swapElt, "htmx:error", { error })));
145
+ }
146
+ }
147
+ });
148
+ })();
149
+
150
+ (function () {
151
+ async function streamInto(ctx, stream) {
152
+ const reader = stream.getReader();
153
+ const decoder = new TextDecoder();
154
+ let text = "";
155
+ while (true) {
156
+ const { done, value } = await reader.read();
157
+ if (done || ctx.hfStreamDone) break;
158
+ text += decoder.decode(value, { stream: true });
159
+ ctx.target.innerHTML = text;
160
+ }
161
+ reader.cancel();
162
+ }
163
+
164
+ // Progressively renders suspense responses (streamed with the x-suspense header)
165
+ // while htmx waits for the full response to perform the actual swap
166
+ htmx.registerExtension("hx-stream", {
167
+ htmx_before_request(elt, detail) {
168
+ const ctx = detail.ctx;
169
+ const fetch = ctx.fetch;
170
+ ctx.fetch = async (url, init) => {
171
+ const response = await fetch(url, init);
172
+ if (response.headers.get("x-suspense") !== "1" || !response.body || !(ctx.target instanceof Element)) {
173
+ return response;
174
+ }
175
+ const [body, stream] = response.body.tee();
176
+ streamInto(ctx, stream);
177
+ const teed = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
178
+ Object.defineProperty(teed, "url", { value: response.url });
179
+ return teed;
180
+ };
181
+ },
182
+ htmx_before_swap(elt, detail) {
183
+ detail.ctx.hfStreamDone = true;
184
+ }
185
+ });
186
+ })();
@@ -11,8 +11,9 @@
11
11
 
12
12
  {% macro HxForm(form=None, action=None) -%}
13
13
  {% set inner = caller() %}
14
- <{Form form hx_boost="false" hx_post=(action or request.url)
15
- hx_on__after_request=("event.detail.successful && event.target.reset();" + kwargs.pop("hx_on__after_request", "")) **kwargs}>
14
+ {% set on_after_request = "ctx.response.status < 400 && event.target.reset();" + kwargs.pop("hx_on__after_request", "") + kwargs.pop("hx-on::after:request", "") %}
15
+ {% set _ = kwargs.update({"hx-on::after:request": on_after_request}) %}
16
+ <{Form form hx_boost="false" hx_post=(action or request.url) **kwargs}>
16
17
  {{inner}}
17
18
  </{Form}>
18
19
  {%- endmacro %}
@@ -0,0 +1,24 @@
1
+
2
+ {% macro MercureStream(topic=None, private=True, hx_swap="beforeend", type=None, auto_scroll=False, with_subscriptions=False) -%}
3
+ {% set sse_url = mercure_authentified_hub_url(topic, with_subscriptions=with_subscriptions) if private else mercure_hub_url(topic, with_subscriptions=with_subscriptions) %}
4
+ <div {% if topic %}hx-sse:connect="{{sse_url}}"{% endif %}
5
+ hx-sse:swap="{{mercure_topic(topic if topic and not type and app.extensions['mercure_sse'].type_is_topic else (type or 'message'))}}"
6
+ {% if auto_scroll %}hx-bottom-scroll-on-trigger="htmx:after:swap"{% endif %} {{html_attrs(hx_swap=hx_swap, class_="mercure-stream", **kwargs)}}>
7
+ {{ caller() }}
8
+ </div>
9
+ {%- endmacro %}
10
+
11
+ {% macro MercureStreamContext(topics, private=True, with_subscriptions=False) -%}
12
+ {% set sse_url = mercure_authentified_hub_url(topics, with_subscriptions=with_subscriptions) if private else mercure_hub_url(topics, with_subscriptions=with_subscriptions) %}
13
+ <div hx-sse:connect="{{sse_url}}" {{html_attrs(class_="mercure-stream-context", **kwargs)}}>
14
+ {{ caller() }}
15
+ </div>
16
+ {%- endmacro %}
17
+
18
+ {% macro MercurePresence(hx_get, topic=None) -%}
19
+ {#- the sse extension uses hx-trigger to open the connection so it needs its own element -#}
20
+ {% if topic %}<div hx-sse:connect="{{mercure_authentified_hub_url(mercure_subscription_topic(topic))}}" class="mercure-stream-context">{% endif %}
21
+ <div hx-trigger="Subscription from:'closest .mercure-stream-context'" {{html_attrs(hx_get=hx_get, class_="mercure-presence", **kwargs)}}>
22
+ </div>
23
+ {% if topic %}</div>{% endif %}
24
+ {%- endmacro %}
@@ -14,15 +14,12 @@ def htmx_oob(html, swap="outerHTML", target=None, wrap=True):
14
14
  html = html()
15
15
  swap_oob = f"{swap}:{target}" if target else swap
16
16
  html = str(html).strip()
17
- m = re.match(r'<([a-z\-0-9]+)', html, re.I)
18
17
  if swap != "outerHTML" and wrap:
19
- wrap_tag = wrap if wrap is not True else {"tr": "table", "li": "ul"}.get(m.group(1).lower() if m else None, "div")
18
+ # htmx inserts the content of the wrapper (template content can hold any element, eg. table rows)
19
+ wrap_tag = wrap if wrap is not True else "template"
20
20
  html = f"<{wrap_tag} hx-swap-oob=\"{swap_oob}\">{html}</{wrap_tag}>"
21
- elif m:
22
- tag, html = html.split(" " if " " in html else ">", 1)
23
- html = f"{tag} hx-swap-oob=\"{swap_oob}\" {html}"
24
- if wrap and tag[1:].lower() in ("tr", "td", "th", "thead", "tbody", "tfoot", "colgroup", "caption", "col"):
25
- html = f"<template>{html}</template>"
21
+ else:
22
+ html = re.sub(r'^<([a-z\-0-9]+)', lambda m: f'<{m.group(1)} hx-swap-oob="{swap_oob}"', html, count=1, flags=re.I)
26
23
  return Markup(html)
27
24
 
28
25
 
@@ -43,20 +40,23 @@ def add_hx_trigger(response, event, data=None):
43
40
  else:
44
41
  trigger = {trigger: None}
45
42
  trigger[event] = data
46
- response.headers["HX-Trigger"] = trigger
43
+ response.headers["HX-Trigger"] = json.dumps(trigger)
47
44
  return response
48
45
 
49
46
 
50
47
  def respond_with_flash_messages(response, only_if_htmx=True, target="#flash-messages-toast"):
51
48
  if only_if_htmx and not request.htmx:
52
49
  return response
50
+ if response.is_streamed:
51
+ # do not buffer streams (eg. sse connections made by htmx)
52
+ return response
53
53
 
54
54
  messages = []
55
55
  for category, message in get_flashed_messages(with_categories=True):
56
56
  props = {"category": category, "caller": lambda: message}
57
57
  if current_app.config["FLASH_TOAST_REMOVE_AFTER"]:
58
58
  props["remove-me"] = current_app.config["FLASH_TOAST_REMOVE_AFTER"]
59
- messages.append(htmx_oob(current_app.macros.FlashMessage(**props), swap="beforestart", target=target))
59
+ messages.append(htmx_oob(current_app.macros.FlashMessage(**props), swap="afterbegin", target=target))
60
60
 
61
61
  response.set_data("\n".join(messages) + response.get_data(as_text=True))
62
62
  return response
@@ -1,14 +0,0 @@
1
- import htmx from "htmx.org";
2
- import "htmx-ext-sse";
3
- import "./htmx-exts";
4
- import "./app.css";
5
-
6
- const DEFAULT_HTMX_EXTS = ['hf-modal', 'hx-bottom-scroll-on-trigger', 'hx-active-url', 'hx-stream'];
7
- document.body.setAttribute('hx-ext', [document.body.getAttribute('hx-ext'), ...DEFAULT_HTMX_EXTS].filter(Boolean).join(","))
8
-
9
- window.htmx = htmx;
10
- export { htmx };
11
-
12
- if (window.Alpine) {
13
- window.Alpine.start()
14
- }
@@ -1,128 +0,0 @@
1
- import htmx from "htmx.org";
2
-
3
- (function () {
4
- let triggerElement = false, enableTriggerElement;
5
- htmx.defineExtension("hf-modal", {
6
- getSelectors() {
7
- return ['[hf-modal]'];
8
- },
9
- onEvent(name, evt) {
10
- if (name === "htmx:beforeProcessNode" && evt.target.hasAttribute("hf-modal")) {
11
- evt.target.setAttribute("hx-get", evt.target.getAttribute("hf-modal"));
12
- evt.target.setAttribute("hx-target", "body");
13
- evt.target.setAttribute("hx-swap", "beforeend");
14
- } else if (name === "htmx:beforeRequest" && evt.target.hasAttribute("hf-modal")) {
15
- triggerElement = evt.target;
16
- enableTriggerElement = !evt.target.disabled;
17
- evt.target.disabled = true;
18
- } else if (name === "htmx:load" && triggerElement) {
19
- if (evt.target.tagName === "DIALOG") {
20
- evt.target.showModal();
21
- const form = evt.target.querySelector("form[hf-modal-form]");
22
- if (form) {
23
- form.setAttribute("hx-target", "closest dialog");
24
- form.setAttribute("hx-swap", "delete");
25
- form.setAttribute("hx-disabled-elt", "this");
26
- if (!form.hasAttribute("action")) {
27
- form.setAttribute("action", triggerElement.getAttribute("hf-modal"));
28
- }
29
- setTimeout(() => {
30
- form.querySelector("input:not([type=hidden])")?.focus();
31
- }, 100);
32
- }
33
- }
34
- if (enableTriggerElement) {
35
- triggerElement.disabled = false;
36
- }
37
- triggerElement = null;
38
- }
39
- }
40
- });
41
- })();
42
-
43
- htmx.defineExtension("hx-bottom-scroll-on-trigger", {
44
- getSelectors() {
45
- return ['[hx-bottom-scroll-on-trigger]'];
46
- },
47
- onEvent(name, evt) {
48
- if (name === "htmx:afterProcessNode" && evt.target.hasAttribute("hx-bottom-scroll-on-trigger")) {
49
- const elt = evt.target;
50
- elt.scrollTop = elt.scrollHeight - elt.clientHeight;
51
- elt.classList.add("at-bottom");
52
- elt.addEventListener("scroll", (e) => {
53
- if (e.target.scrollTop < e.target.scrollHeight - e.target.clientHeight - 20) {
54
- e.target.classList.remove("at-bottom");
55
- } else {
56
- e.target.classList.add("at-bottom");
57
- }
58
- });
59
- return;
60
- }
61
-
62
- const eventName = evt.target.getAttribute("hx-bottom-scroll-on-trigger");
63
- if (name === eventName && evt.target.classList.contains("at-bottom")) {
64
- evt.target.scrollTop = evt.target.scrollHeight - evt.target.clientHeight;
65
- }
66
- }
67
- });
68
-
69
- htmx.defineExtension("hx-active-url", {
70
- init() {
71
- function ensureLinksAreActive() {
72
- const nodes = document.querySelectorAll('a[hx-active-url], [hx-active-url] a');
73
- nodes.forEach((a) => {
74
- let url;
75
- for (const attr of ['hx-push-url', 'hx-replace-url', 'href', 'hx-get']) {
76
- if (a.hasAttribute(attr)) {
77
- if (attr !== "href" && a.getAttribute(attr) === "true") {
78
- url = new URL(a.getAttribute('hx-get'), window.location.origin);
79
- } else {
80
- url = new URL(a.getAttribute(attr), window.location.origin);
81
- }
82
- break;
83
- }
84
- }
85
- if (!url) return;
86
- const activeClass = a.closest("[hx-active-url]")?.getAttribute("hx-active-url");
87
- if (!activeClass) return;
88
- if (url.pathname === window.location.pathname && url.search === window.location.search) {
89
- a.classList.add(activeClass);
90
- } else {
91
- a.classList.remove(activeClass);
92
- }
93
- });
94
- }
95
- document.addEventListener('DOMContentLoaded', ensureLinksAreActive);
96
- document.addEventListener("htmx:pushedIntoHistory", ensureLinksAreActive);
97
- },
98
- getSelectors() {
99
- return ['[hx-href]'];
100
- },
101
- onEvent(name, evt) {
102
- if (name === "htmx:beforeProcessNode" && evt.target.hasAttribute("hx-href")) {
103
- evt.target.setAttribute('href', evt.target.getAttribute('hx-href'));
104
- evt.target.setAttribute('hx-push-url', evt.target.getAttribute('hx-href'));
105
- }
106
- }
107
- });
108
-
109
- htmx.defineExtension("hx-stream", {
110
- onEvent: function (name, evt) {
111
- if (name === "htmx:beforeRequest") {
112
- const target = evt.detail.target;
113
- const xhr = evt.detail.xhr;
114
- let stream = false;
115
- xhr.addEventListener('readystatechange', function () {
116
- if (xhr.readyState == xhr.HEADERS_RECEIVED && xhr.getResponseHeader('x-suspense') === '1') {
117
- stream = true;
118
- target.innerHTML = xhr.responseText;
119
- } else if (xhr.readyState === xhr.LOADING && stream) {
120
- target.innerHTML = xhr.responseText;
121
- } else if (xhr.readyState === xhr.DONE) {
122
- stream = false;
123
- }
124
- });
125
- }
126
- return true;
127
- }
128
- });
@@ -1,22 +0,0 @@
1
-
2
- {% macro MercureStream(topic=None, private=True, hx_swap="beforeend", type=None, auto_scroll=False, with_subscriptions=False) -%}
3
- {% set sse_url = mercure_authentified_hub_url(topic, with_subscriptions=with_subscriptions) if private else mercure_hub_url(topic, with_subscriptions=with_subscriptions) %}
4
- <div {% if topic %}hx-ext="sse" sse-connect="{{sse_url}}"{% endif %}
5
- sse-swap="{{mercure_topic(topic if topic and not type and app.extensions['mercure_sse'].type_is_topic else (type or 'message'))}}"
6
- {% if auto_scroll %}hx-bottom-scroll-on-trigger="htmx:sseMessage"{% endif %} {{html_attrs(hx_swap=hx_swap, class_="mercure-stream", **kwargs)}}>
7
- {{ caller() }}
8
- </div>
9
- {%- endmacro %}
10
-
11
- {% macro MercureStreamContext(topics, private=True, with_subscriptions=False) -%}
12
- {% set sse_url = mercure_authentified_hub_url(topics, with_subscriptions=with_subscriptions) if private else mercure_hub_url(topics, with_subscriptions=with_subscriptions) %}
13
- <div hx-ext="sse" sse-connect="{{sse_url}}" {{html_attrs(class_="mercure-stream-context", **kwargs)}}>
14
- {{ caller() }}
15
- </div>
16
- {%- endmacro %}
17
-
18
- {% macro MercurePresence(hx_get, topic=None) -%}
19
- <div {% if topic %}hx-ext="sse" sse-connect="{{mercure_authentified_hub_url(mercure_subscription_topic(topic))}}"{% endif %}
20
- hx-trigger="sse:Subscription" {{html_attrs(hx_get=hx_get, class_="mercure-presence", **kwargs)}}>
21
- </div>
22
- {%- endmacro %}