tina4-python 3.13.26__tar.gz → 3.13.28__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 (157) hide show
  1. {tina4_python-3.13.26 → tina4_python-3.13.28}/PKG-INFO +1 -1
  2. {tina4_python-3.13.26 → tina4_python-3.13.28}/pyproject.toml +1 -1
  3. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/frond/engine.py +69 -11
  5. {tina4_python-3.13.26 → tina4_python-3.13.28}/.gitignore +0 -0
  6. {tina4_python-3.13.26 → tina4_python-3.13.28}/README.md +0 -0
  7. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/CLAUDE.md +0 -0
  8. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/HtmlElement.py +0 -0
  9. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/Testing.py +0 -0
  10. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/ai/__init__.py +0 -0
  11. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/api/__init__.py +0 -0
  12. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/auth/__init__.py +0 -0
  13. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/cache/__init__.py +0 -0
  14. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/cli/__init__.py +0 -0
  15. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/container/__init__.py +0 -0
  16. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/__init__.py +0 -0
  17. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/cache.py +0 -0
  18. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/constants.py +0 -0
  19. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/events.py +0 -0
  20. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/middleware.py +0 -0
  21. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/rate_limiter.py +0 -0
  22. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/request.py +0 -0
  23. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/response.py +0 -0
  24. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/router.py +0 -0
  25. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/core/server.py +0 -0
  26. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/crud/__init__.py +0 -0
  27. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/__init__.py +0 -0
  28. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/adapter.py +0 -0
  29. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/connection.py +0 -0
  30. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/firebird.py +0 -0
  31. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/mongodb.py +0 -0
  32. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/postgres.py +0 -0
  36. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/dev_admin/__init__.py +0 -0
  40. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/dev_admin/metrics.py +0 -0
  41. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/dev_admin/plan.py +0 -0
  42. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/dev_admin/project_index.py +0 -0
  43. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/docs.py +0 -0
  44. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/dotenv/__init__.py +0 -0
  45. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/env.py +0 -0
  46. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/frond/FROND.md +0 -0
  47. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/frond/__init__.py +0 -0
  48. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  50. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/database/meta.json +0 -0
  51. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  52. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/error-overlay/meta.json +0 -0
  53. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  54. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/orm/meta.json +0 -0
  55. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  56. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  57. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/queue/meta.json +0 -0
  58. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  59. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/rest-api/meta.json +0 -0
  60. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  61. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/templates/meta.json +0 -0
  62. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  63. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  64. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/graphql/__init__.py +0 -0
  65. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/i18n/__init__.py +0 -0
  66. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/mcp/__init__.py +0 -0
  67. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/mcp/protocol.py +0 -0
  68. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/mcp/tools.py +0 -0
  69. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/messenger/__init__.py +0 -0
  70. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/migration/__init__.py +0 -0
  71. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/migration/runner.py +0 -0
  72. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/orm/__init__.py +0 -0
  73. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/orm/fields.py +0 -0
  74. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/orm/model.py +0 -0
  75. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.26 → tina4_python-3.13.28}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.26
3
+ Version: 3.13.28
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.26"
3
+ version = "3.13.28"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.26"
11
+ __version__ = "3.13.28"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -851,6 +851,13 @@ def _eval_test(value_expr: str, test_name: str, args: str, context: dict, eval_f
851
851
  "boolean": lambda v: isinstance(v, bool),
852
852
  }
853
853
 
854
+ # Merge in custom tests registered via add_test(). They live on the Frond
855
+ # instance, reachable through the bound eval_fn (eval_fn.__self__._tests).
856
+ # Custom registrations override built-ins, matching PHP/Ruby/Node.
857
+ custom = getattr(getattr(eval_fn, "__self__", None), "_tests", None)
858
+ if custom:
859
+ tests = {**tests, **custom}
860
+
854
861
  # 'divisible by(n)'
855
862
  if test_name == "divisible":
856
863
  m = _DIVISIBLE_BY_RE.match(args)
@@ -989,9 +996,26 @@ def _coerce_arg(s: str):
989
996
  return s
990
997
 
991
998
 
999
+ class _Ref:
1000
+ """A filter argument that was an UNQUOTED bareword — i.e. a variable
1001
+ reference (or dotted/bracket path), not a literal. Resolved against the
1002
+ render context at apply-time so e.g. `{{ '%.2f' | format(price) }}` binds
1003
+ `price` to its value. Quoted literals (`default('fb')`) are kept as plain
1004
+ strings and never resolved."""
1005
+
1006
+ __slots__ = ("name",)
1007
+
1008
+ def __init__(self, name: str):
1009
+ self.name = name
1010
+
1011
+
992
1012
  def _parse_args(raw: str) -> list:
993
- """Parse filter arguments, respecting quoted strings, braces, and backslash escapes."""
994
- args = []
1013
+ """Parse filter arguments, respecting quoted strings, braces, and backslash escapes.
1014
+
1015
+ Quoted args become literal strings; numbers/bools/null/JSON are coerced to
1016
+ their Python types; an unquoted bareword becomes a `_Ref` (a variable
1017
+ reference resolved against the context when the filter runs)."""
1018
+ tokens = []
995
1019
  current = ""
996
1020
  in_quote = None
997
1021
  depth = 0
@@ -1006,15 +1030,27 @@ def _parse_args(raw: str) -> list:
1006
1030
  elif ch in (")", "}", "]") and not in_quote:
1007
1031
  depth -= 1
1008
1032
  elif ch == "," and not in_quote and depth == 0:
1009
- args.append(_strip_outer_quotes(current.strip()))
1033
+ tokens.append(current.strip())
1010
1034
  current = ""
1011
1035
  continue
1012
1036
  current += ch
1013
1037
 
1014
1038
  if current.strip():
1015
- args.append(_strip_outer_quotes(current.strip()))
1039
+ tokens.append(current.strip())
1016
1040
 
1017
- return [_coerce_arg(a) for a in args]
1041
+ out = []
1042
+ for tok in tokens:
1043
+ if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in ('"', "'"):
1044
+ out.append(_strip_outer_quotes(tok)) # quoted literal → string
1045
+ else:
1046
+ coerced = _coerce_arg(tok)
1047
+ # A bareword that stayed a string is a variable/expression
1048
+ # reference (e.g. `n`, `user.age`) — resolve it at apply-time.
1049
+ if isinstance(coerced, str) and tok:
1050
+ out.append(_Ref(tok))
1051
+ else:
1052
+ out.append(coerced)
1053
+ return out
1018
1054
 
1019
1055
 
1020
1056
  def _strip_outer_quotes(s: str) -> str:
@@ -1071,16 +1107,16 @@ _BUILTIN_FILTERS = {
1071
1107
  "default": lambda v, *a: v if v is not None and v != "" else (a[0] if a else ""),
1072
1108
  "raw": lambda v, *a: v, # Mark as safe (no escaping)
1073
1109
  "safe": lambda v, *a: v,
1074
- "escape": lambda v, *a: html.escape(str(v)),
1075
- "e": lambda v, *a: html.escape(str(v)),
1110
+ "escape": lambda v, *a: SafeString(html.escape(str(v))),
1111
+ "e": lambda v, *a: SafeString(html.escape(str(v))),
1076
1112
  "striptags": lambda v, *a: _STRIPTAGS_RE.sub("", str(v)),
1077
- "nl2br": lambda v, *a: str(v).replace("\n", "<br>\n"),
1113
+ "nl2br": lambda v, *a: SafeString(html.escape(str(v)).replace("\n", "<br />\n")),
1078
1114
  "abs": lambda v, *a: abs(v) if isinstance(v, (int, float)) else v,
1079
- "round": lambda v, *a: round(float(v), int(a[0]) if a else 0),
1115
+ "round": lambda v, *a: round(float(v), int(a[0])) if a else int(round(float(v))),
1080
1116
  "int": lambda v, *a: int(v) if v else 0,
1081
1117
  "float": lambda v, *a: float(v) if v else 0.0,
1082
1118
  "string": lambda v, *a: str(v),
1083
- "json_encode": lambda v, *a: json.dumps(v),
1119
+ "json_encode": lambda v, *a: json.dumps(v, separators=(",", ":")),
1084
1120
  "to_json": lambda v, *a: SafeString(json.dumps(v, default=str, separators=(",", ":")).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")),
1085
1121
  "tojson": lambda v, *a: SafeString(json.dumps(v, default=str, separators=(",", ":")).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")),
1086
1122
  "js_escape": lambda v, *a: SafeString(str(v).replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")),
@@ -1633,6 +1669,21 @@ class Frond:
1633
1669
 
1634
1670
  def _render_tokens(self, tokens: list, context: dict) -> str:
1635
1671
  """Render a list of tokens to string."""
1672
+ # Whitespace control pre-pass: every {{- / -}} and {%- / -%} marker
1673
+ # trims its neighbouring TEXT token. Doing this up-front over the whole
1674
+ # token list (not per-handler) means the markers on closing tags
1675
+ # (endif/endfor) and the body boundaries are honoured too — a block tag
1676
+ # consumes its own end tag during dispatch, so the inline pass below
1677
+ # never sees it. Idempotent, so recursion into block bodies is safe.
1678
+ for _idx in range(len(tokens)):
1679
+ _tt, _raw = tokens[_idx]
1680
+ if _tt in (VAR, BLOCK):
1681
+ _, _sb, _sa = _strip_tag(_raw)
1682
+ if _sb and _idx > 0 and tokens[_idx - 1][0] == TEXT:
1683
+ tokens[_idx - 1] = (TEXT, tokens[_idx - 1][1].rstrip())
1684
+ if _sa and _idx + 1 < len(tokens) and tokens[_idx + 1][0] == TEXT:
1685
+ tokens[_idx + 1] = (TEXT, tokens[_idx + 1][1].lstrip())
1686
+
1636
1687
  output = []
1637
1688
  i = 0
1638
1689
 
@@ -1835,6 +1886,13 @@ class Frond:
1835
1886
  is_safe = True
1836
1887
  continue
1837
1888
 
1889
+ # Resolve any unquoted-bareword args against the context (a quoted
1890
+ # literal stays a plain string). Lets `{{ '%.2f' | format(n) }}`
1891
+ # bind `n` to its value instead of the literal text "n".
1892
+ if args and any(isinstance(a, _Ref) for a in args):
1893
+ args = [_eval_expr(a.name, context) if isinstance(a, _Ref) else a
1894
+ for a in args]
1895
+
1838
1896
  # Sandbox: check filter access
1839
1897
  if self._sandbox and self._allowed_filters is not None:
1840
1898
  if fname not in self._allowed_filters:
@@ -1885,7 +1943,7 @@ class Frond:
1885
1943
  value = int(value) if value else 0
1886
1944
  continue
1887
1945
  if fname in ("e", "escape"):
1888
- value = html.escape(str(value))
1946
+ value = SafeString(html.escape(str(value)))
1889
1947
  continue
1890
1948
 
1891
1949
  fn = self._filters.get(fname)
File without changes