hanami 2.3.2 → 3.0.0

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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -19
  3. data/LICENSE +20 -0
  4. data/README.md +18 -35
  5. data/hanami.gemspec +36 -37
  6. data/lib/hanami/app.rb +12 -2
  7. data/lib/hanami/config/db.rb +2 -0
  8. data/lib/hanami/config/i18n.rb +138 -0
  9. data/lib/hanami/config/logger.rb +15 -7
  10. data/lib/hanami/config/null_config.rb +1 -1
  11. data/lib/hanami/config/views.rb +17 -0
  12. data/lib/hanami/config.rb +66 -22
  13. data/lib/hanami/errors.rb +6 -0
  14. data/lib/hanami/extensions/action/i18n_helper.rb +64 -0
  15. data/lib/hanami/extensions/action/name_inferrer.rb +34 -0
  16. data/lib/hanami/extensions/action/slice_configured_action.rb +31 -2
  17. data/lib/hanami/extensions/action.rb +20 -7
  18. data/lib/hanami/extensions/mailer/slice_configured_mailer.rb +134 -0
  19. data/lib/hanami/extensions/mailer.rb +28 -0
  20. data/lib/hanami/extensions/operation/slice_configured_db_operation.rb +2 -0
  21. data/lib/hanami/extensions/view/context.rb +26 -4
  22. data/lib/hanami/extensions/view/part.rb +2 -0
  23. data/lib/hanami/extensions/view/slice_configured_context.rb +7 -0
  24. data/lib/hanami/extensions/view/slice_configured_part.rb +2 -0
  25. data/lib/hanami/extensions/view/slice_configured_view.rb +8 -8
  26. data/lib/hanami/extensions/view/standard_helpers.rb +4 -0
  27. data/lib/hanami/extensions.rb +6 -1
  28. data/lib/hanami/helpers/assets_helper.rb +0 -4
  29. data/lib/hanami/helpers/form_helper.rb +1 -1
  30. data/lib/hanami/helpers/i18n_helper.rb +241 -0
  31. data/lib/hanami/logger/rack_formatter.rb +73 -0
  32. data/lib/hanami/logger/sql_formatter.rb +80 -0
  33. data/lib/hanami/logger/sql_logger.rb +48 -0
  34. data/lib/hanami/middleware/render_errors.rb +2 -2
  35. data/lib/hanami/providers/db.rb +7 -2
  36. data/lib/hanami/providers/db_logging.rb +4 -7
  37. data/lib/hanami/providers/i18n/backend.rb +373 -0
  38. data/lib/hanami/providers/i18n/locale/en.yml +57 -0
  39. data/lib/hanami/providers/i18n.rb +114 -0
  40. data/lib/hanami/providers/logger.rb +23 -1
  41. data/lib/hanami/providers/mailers.rb +101 -0
  42. data/lib/hanami/routes.rb +1 -0
  43. data/lib/hanami/settings/composite_store.rb +53 -0
  44. data/lib/hanami/settings.rb +4 -4
  45. data/lib/hanami/slice/router.rb +15 -10
  46. data/lib/hanami/slice.rb +102 -11
  47. data/lib/hanami/slice_configurable.rb +1 -3
  48. data/lib/hanami/slice_registrar.rb +2 -2
  49. data/lib/hanami/universal_logger.rb +250 -0
  50. data/lib/hanami/version.rb +1 -1
  51. data/lib/hanami/web/rack_logger.rb +2 -80
  52. data/lib/hanami/web/welcome.html.erb +443 -58
  53. data/lib/hanami.rb +4 -2
  54. metadata +34 -280
  55. data/CODE_OF_CONDUCT.md +0 -74
  56. data/FEATURES.md +0 -269
  57. data/LICENSE.md +0 -22
  58. data/spec/integration/action/cookies_spec.rb +0 -58
  59. data/spec/integration/action/csrf_protection_spec.rb +0 -54
  60. data/spec/integration/action/format_config_spec.rb +0 -129
  61. data/spec/integration/action/routes_spec.rb +0 -71
  62. data/spec/integration/action/sessions_spec.rb +0 -50
  63. data/spec/integration/action/slice_configuration_spec.rb +0 -284
  64. data/spec/integration/action/view_rendering/automatic_rendering_spec.rb +0 -247
  65. data/spec/integration/action/view_rendering/paired_view_inference_spec.rb +0 -115
  66. data/spec/integration/action/view_rendering/view_context_spec.rb +0 -221
  67. data/spec/integration/action/view_rendering_spec.rb +0 -89
  68. data/spec/integration/assets/assets_spec.rb +0 -155
  69. data/spec/integration/assets/cross_slice_assets_helpers_spec.rb +0 -129
  70. data/spec/integration/assets/serve_static_assets_spec.rb +0 -152
  71. data/spec/integration/code_loading/loading_from_app_spec.rb +0 -152
  72. data/spec/integration/code_loading/loading_from_lib_spec.rb +0 -242
  73. data/spec/integration/code_loading/loading_from_slice_spec.rb +0 -165
  74. data/spec/integration/container/application_routes_helper_spec.rb +0 -48
  75. data/spec/integration/container/auto_injection_spec.rb +0 -53
  76. data/spec/integration/container/auto_registration_spec.rb +0 -86
  77. data/spec/integration/container/autoloader_spec.rb +0 -82
  78. data/spec/integration/container/imports_spec.rb +0 -253
  79. data/spec/integration/container/prepare_container_spec.rb +0 -125
  80. data/spec/integration/container/provider_environment_spec.rb +0 -52
  81. data/spec/integration/container/provider_lifecycle_spec.rb +0 -61
  82. data/spec/integration/container/shutdown_spec.rb +0 -91
  83. data/spec/integration/container/standard_providers/rack_provider_spec.rb +0 -44
  84. data/spec/integration/container/standard_providers_spec.rb +0 -124
  85. data/spec/integration/db/auto_registration_spec.rb +0 -39
  86. data/spec/integration/db/commands_spec.rb +0 -80
  87. data/spec/integration/db/db_inflector_spec.rb +0 -57
  88. data/spec/integration/db/db_slices_spec.rb +0 -398
  89. data/spec/integration/db/db_spec.rb +0 -245
  90. data/spec/integration/db/gateways_spec.rb +0 -361
  91. data/spec/integration/db/logging_spec.rb +0 -301
  92. data/spec/integration/db/mappers_spec.rb +0 -84
  93. data/spec/integration/db/provider_config_spec.rb +0 -88
  94. data/spec/integration/db/provider_spec.rb +0 -35
  95. data/spec/integration/db/relations_spec.rb +0 -60
  96. data/spec/integration/db/repo_spec.rb +0 -300
  97. data/spec/integration/db/slices_importing_from_parent.rb +0 -130
  98. data/spec/integration/dotenv_loading_spec.rb +0 -138
  99. data/spec/integration/logging/exception_logging_spec.rb +0 -120
  100. data/spec/integration/logging/notifications_spec.rb +0 -68
  101. data/spec/integration/logging/request_logging_spec.rb +0 -202
  102. data/spec/integration/operations/extension_spec.rb +0 -122
  103. data/spec/integration/rack_app/body_parser_spec.rb +0 -108
  104. data/spec/integration/rack_app/method_override_spec.rb +0 -97
  105. data/spec/integration/rack_app/middleware_spec.rb +0 -720
  106. data/spec/integration/rack_app/non_booted_rack_app_spec.rb +0 -104
  107. data/spec/integration/rack_app/rack_app_spec.rb +0 -442
  108. data/spec/integration/rake_tasks_spec.rb +0 -107
  109. data/spec/integration/router/resource_routes_spec.rb +0 -281
  110. data/spec/integration/settings/access_in_slice_class_body_spec.rb +0 -83
  111. data/spec/integration/settings/access_to_constants_spec.rb +0 -46
  112. data/spec/integration/settings/loading_from_env_spec.rb +0 -188
  113. data/spec/integration/settings/settings_component_loading_spec.rb +0 -113
  114. data/spec/integration/settings/slice_registration_spec.rb +0 -145
  115. data/spec/integration/settings/using_types_spec.rb +0 -80
  116. data/spec/integration/setup_spec.rb +0 -165
  117. data/spec/integration/slices/external_slice_spec.rb +0 -91
  118. data/spec/integration/slices/slice_configuration_spec.rb +0 -42
  119. data/spec/integration/slices/slice_loading_spec.rb +0 -171
  120. data/spec/integration/slices/slice_registrations_spec.rb +0 -80
  121. data/spec/integration/slices/slice_routing_spec.rb +0 -219
  122. data/spec/integration/slices_spec.rb +0 -471
  123. data/spec/integration/view/config/default_context_spec.rb +0 -149
  124. data/spec/integration/view/config/inflector_spec.rb +0 -57
  125. data/spec/integration/view/config/part_class_spec.rb +0 -147
  126. data/spec/integration/view/config/part_namespace_spec.rb +0 -103
  127. data/spec/integration/view/config/paths_spec.rb +0 -119
  128. data/spec/integration/view/config/scope_class_spec.rb +0 -147
  129. data/spec/integration/view/config/scope_namespace_spec.rb +0 -103
  130. data/spec/integration/view/config/template_spec.rb +0 -38
  131. data/spec/integration/view/context/assets_spec.rb +0 -79
  132. data/spec/integration/view/context/inflector_spec.rb +0 -40
  133. data/spec/integration/view/context/request_spec.rb +0 -57
  134. data/spec/integration/view/context/routes_spec.rb +0 -84
  135. data/spec/integration/view/helpers/form_helper_spec.rb +0 -174
  136. data/spec/integration/view/helpers/part_helpers_spec.rb +0 -124
  137. data/spec/integration/view/helpers/scope_helpers_spec.rb +0 -84
  138. data/spec/integration/view/helpers/user_defined_helpers/part_helpers_spec.rb +0 -162
  139. data/spec/integration/view/helpers/user_defined_helpers/scope_helpers_spec.rb +0 -119
  140. data/spec/integration/view/parts/default_rendering_spec.rb +0 -138
  141. data/spec/integration/view/slice_configuration_spec.rb +0 -289
  142. data/spec/integration/view/views_spec.rb +0 -103
  143. data/spec/integration/web/content_security_policy_nonce_spec.rb +0 -251
  144. data/spec/integration/web/render_detailed_errors_spec.rb +0 -107
  145. data/spec/integration/web/render_errors_spec.rb +0 -242
  146. data/spec/integration/web/welcome_view_spec.rb +0 -84
  147. data/spec/spec_helper.rb +0 -28
  148. data/spec/support/app_integration.rb +0 -157
  149. data/spec/support/coverage.rb +0 -1
  150. data/spec/support/matchers.rb +0 -32
  151. data/spec/support/rspec.rb +0 -27
  152. data/spec/unit/hanami/config/actions/content_security_policy_spec.rb +0 -96
  153. data/spec/unit/hanami/config/actions/cookies_spec.rb +0 -46
  154. data/spec/unit/hanami/config/actions/csrf_protection_spec.rb +0 -58
  155. data/spec/unit/hanami/config/actions/default_values_spec.rb +0 -43
  156. data/spec/unit/hanami/config/actions/sessions_spec.rb +0 -48
  157. data/spec/unit/hanami/config/actions_spec.rb +0 -52
  158. data/spec/unit/hanami/config/base_url_spec.rb +0 -25
  159. data/spec/unit/hanami/config/console_spec.rb +0 -22
  160. data/spec/unit/hanami/config/db_spec.rb +0 -38
  161. data/spec/unit/hanami/config/inflector_spec.rb +0 -35
  162. data/spec/unit/hanami/config/logger_spec.rb +0 -195
  163. data/spec/unit/hanami/config/render_detailed_errors_spec.rb +0 -25
  164. data/spec/unit/hanami/config/render_errors_spec.rb +0 -25
  165. data/spec/unit/hanami/config/router_spec.rb +0 -44
  166. data/spec/unit/hanami/config/slices_spec.rb +0 -34
  167. data/spec/unit/hanami/config/views_spec.rb +0 -80
  168. data/spec/unit/hanami/env_spec.rb +0 -37
  169. data/spec/unit/hanami/extensions/view/context_spec.rb +0 -59
  170. data/spec/unit/hanami/helpers/assets_helper/asset_url_spec.rb +0 -120
  171. data/spec/unit/hanami/helpers/assets_helper/audio_tag_spec.rb +0 -132
  172. data/spec/unit/hanami/helpers/assets_helper/favicon_tag_spec.rb +0 -87
  173. data/spec/unit/hanami/helpers/assets_helper/image_tag_spec.rb +0 -92
  174. data/spec/unit/hanami/helpers/assets_helper/javascript_tag_spec.rb +0 -143
  175. data/spec/unit/hanami/helpers/assets_helper/stylesheet_tag_spec.rb +0 -126
  176. data/spec/unit/hanami/helpers/assets_helper/video_tag_spec.rb +0 -136
  177. data/spec/unit/hanami/helpers/form_helper_spec.rb +0 -2857
  178. data/spec/unit/hanami/port_spec.rb +0 -117
  179. data/spec/unit/hanami/providers/db/config/default_config_spec.rb +0 -100
  180. data/spec/unit/hanami/providers/db/config/gateway_spec.rb +0 -73
  181. data/spec/unit/hanami/providers/db/config_spec.rb +0 -143
  182. data/spec/unit/hanami/router/errors/not_allowed_error_spec.rb +0 -27
  183. data/spec/unit/hanami/router/errors/not_found_error_spec.rb +0 -22
  184. data/spec/unit/hanami/settings/env_store_spec.rb +0 -52
  185. data/spec/unit/hanami/settings_spec.rb +0 -111
  186. data/spec/unit/hanami/slice_configurable_spec.rb +0 -141
  187. data/spec/unit/hanami/slice_name_spec.rb +0 -47
  188. data/spec/unit/hanami/slice_spec.rb +0 -99
  189. data/spec/unit/hanami/web/rack_logger_spec.rb +0 -99
@@ -25,6 +25,8 @@ module Hanami
25
25
  end
26
26
 
27
27
  views_namespace.const_set(:Context, Class.new(context_superclass(slice)).tap { |klass|
28
+ # Call configure_for_slice explicitly, since this is an anonymous class at this point,
29
+ # so the slice cannot be inferred from its name.
28
30
  klass.configure_for_slice(slice)
29
31
  })
30
32
  end
@@ -40,8 +42,8 @@ module Hanami
40
42
  slice.inflector.constantize(
41
43
  slice.inflector.camelize("#{slice.app.slice_name.name}/views/context")
42
44
  )
43
- rescue NameError => e
44
- raise e unless %i[Views Context].include?(e.name)
45
+ rescue NameError => exception
46
+ raise exception unless %i[Views Context].include?(exception.name)
45
47
 
46
48
  Hanami::View::Context
47
49
  end
@@ -95,17 +97,19 @@ module Hanami
95
97
  #
96
98
  # @api private
97
99
  # @since 2.1.0
98
- def initialize( # rubocop:disable Metrics/ParameterLists
100
+ def initialize(
99
101
  inflector: nil,
100
102
  routes: nil,
101
103
  assets: nil,
102
104
  request: nil,
105
+ i18n: nil,
103
106
  **args
104
107
  )
105
108
  @inflector = inflector
106
109
  @routes = routes
107
110
  @assets = assets
108
111
  @request = request
112
+ @i18n = i18n
109
113
 
110
114
  @content_for = {}
111
115
 
@@ -157,7 +161,9 @@ module Hanami
157
161
  # @since 2.1.0
158
162
  def request
159
163
  unless @request
160
- raise Hanami::ComponentLoadError, "Request not available. Only views rendered from Hanami::Action instances have a request."
164
+ raise Hanami::ComponentLoadError, <<~STR
165
+ Request not available. Only views rendered from Hanami::Action instances have a request.
166
+ STR
161
167
  end
162
168
 
163
169
  @request
@@ -190,6 +196,22 @@ module Hanami
190
196
  @routes
191
197
  end
192
198
 
199
+ # Returns the slice's i18n backend.
200
+ #
201
+ # @return [Hanami::Providers::I18n::Backend] the i18n backend
202
+ #
203
+ # @raise [Hanami::ComponentLoadError] if the i18n gem is not bundled
204
+ #
205
+ # @api public
206
+ # @since 3.0.0
207
+ def i18n
208
+ unless @i18n
209
+ raise Hanami::ComponentLoadError, "the i18n gem is required to access translations"
210
+ end
211
+
212
+ @i18n
213
+ end
214
+
193
215
  # @overload content_for(key, value = nil, &block)
194
216
  # Stores a string or block of template markup for later use.
195
217
  #
@@ -27,6 +27,8 @@ module Hanami
27
27
  extend SliceConfiguredPart.new(slice)
28
28
 
29
29
  const_set :PartHelpers, Class.new(PartHelpers) { |klass|
30
+ # Call configure_for_slice explicitly, since this is an anonymous class at this point,
31
+ # so the slice cannot be inferred from its name.
30
32
  klass.configure_for_slice(slice)
31
33
  }
32
34
  end
@@ -39,15 +39,18 @@ module Hanami
39
39
  # - the configured inflector as `inflector`
40
40
  # - "routes" from the app container as `routes`
41
41
  # - "assets" from the app container as `assets`
42
+ # - "i18n" from the slice container as `i18n`
42
43
  def define_new
43
44
  inflector = slice.inflector
44
45
  resolve_routes = method(:resolve_routes)
45
46
  resolve_assets = method(:resolve_assets)
47
+ resolve_i18n = method(:resolve_i18n)
46
48
 
47
49
  define_method :new do |**kwargs|
48
50
  kwargs[:inflector] ||= inflector
49
51
  kwargs[:routes] ||= resolve_routes.()
50
52
  kwargs[:assets] ||= resolve_assets.()
53
+ kwargs[:i18n] ||= resolve_i18n.()
51
54
 
52
55
  super(**kwargs)
53
56
  end
@@ -60,6 +63,10 @@ module Hanami
60
63
  def resolve_assets
61
64
  slice["assets"] if slice.key?("assets")
62
65
  end
66
+
67
+ def resolve_i18n
68
+ slice["i18n"] if slice.key?("i18n")
69
+ end
63
70
  end
64
71
  end
65
72
  end
@@ -57,6 +57,8 @@ module Hanami
57
57
  define_method(:new) do |**args|
58
58
  return super(**args) if args.key?(:rendering)
59
59
 
60
+ # Call configure_for_slice explicitly, since this is an anonymous class at this point,
61
+ # so the slice cannot be inferred from its name.
60
62
  slice_rendering = Class.new(Hanami::View)
61
63
  .configure_for_slice(slice)
62
64
  .new
@@ -49,12 +49,12 @@ module Hanami
49
49
 
50
50
  begin
51
51
  slice.app.namespace.const_get(:View, false)
52
- rescue NameError => e
53
- raise unless e.name == :View
52
+ rescue NameError => exception
53
+ raise unless exception.name == :View
54
54
  end
55
55
  end
56
56
 
57
- # rubocop:disable Metrics/AbcSize
57
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
58
  def configure_view(view_class)
59
59
  view_class.settings.each do |setting|
60
60
  next unless slice.config.views.respond_to?(setting.name)
@@ -134,7 +134,7 @@ module Hanami
134
134
  view_class.config.scope_namespace = scope_namespace
135
135
  end
136
136
  end
137
- # rubocop:enable Metrics/AbcSize
137
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
138
138
 
139
139
  def define_inherited
140
140
  template_name = method(:template_name)
@@ -185,8 +185,8 @@ module Hanami
185
185
  views_namespace.const_get(:Part)
186
186
  else
187
187
  views_namespace.const_set(:Part, Class.new(part_superclass).tap { |klass|
188
- # Give the slice to `configure_for_slice`, since it cannot be inferred when it is
189
- # called via `.inherited`, because the class is anonymous at this point
188
+ # Call configure_for_slice explicitly, since this is an anonymous class at this point,
189
+ # so the slice cannot be inferred from its name.
190
190
  klass.configure_for_slice(slice)
191
191
  })
192
192
  end
@@ -208,8 +208,8 @@ module Hanami
208
208
  views_namespace.const_get(:Scope)
209
209
  else
210
210
  views_namespace.const_set(:Scope, Class.new(scope_superclass).tap { |klass|
211
- # Give the slice to `configure_for_slice`, since it cannot be inferred when it is
212
- # called via `.inherited`, since the class is anonymous at this point
211
+ # Call configure_for_slice explicitly, since this is an anonymous class at this point,
212
+ # so the slice cannot be inferred from its name.
213
213
  klass.configure_for_slice(slice)
214
214
  })
215
215
  end
@@ -16,6 +16,10 @@ module Hanami
16
16
  if Hanami.bundled?("hanami-assets")
17
17
  include Helpers::AssetsHelper
18
18
  end
19
+
20
+ if Hanami.bundled?("i18n")
21
+ include Helpers::I18nHelper
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -4,7 +4,7 @@ if Hanami.bundled?("hanami-db")
4
4
  require_relative "extensions/db/repo"
5
5
  end
6
6
 
7
- if Hanami.bundled?("hanami-controller")
7
+ if Hanami.bundled?("hanami-action")
8
8
  require_relative "extensions/action"
9
9
  end
10
10
 
@@ -20,6 +20,11 @@ if Hanami.bundled?("hanami-router")
20
20
  require_relative "extensions/router/errors"
21
21
  end
22
22
 
23
+ if Hanami.bundled?("hanami-mailer")
24
+ require "hanami/mailer"
25
+ require_relative "extensions/mailer"
26
+ end
27
+
23
28
  if Hanami.bundled?("dry-operation")
24
29
  require_relative "extensions/operation"
25
30
  end
@@ -4,8 +4,6 @@ require "uri"
4
4
  require "hanami/view"
5
5
  require_relative "../constants"
6
6
 
7
- # rubocop:disable Metrics/ModuleLength
8
-
9
7
  module Hanami
10
8
  module Helpers
11
9
  # HTML assets helpers
@@ -804,5 +802,3 @@ module Hanami
804
802
  end
805
803
  end
806
804
  end
807
-
808
- # rubocop:enable Metrics/ModuleLength
@@ -32,7 +32,7 @@ module Hanami
32
32
 
33
33
  # CSRF Token session key
34
34
  #
35
- # This name of this key is shared with the hanami and hanami-controller gems.
35
+ # This name of this key is shared with the hanami and hanami-action gems.
36
36
  #
37
37
  # @since 2.1.0
38
38
  # @api private
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Hanami
6
+ module Helpers
7
+ # View-layer translation and localization helpers.
8
+ #
9
+ # These helpers are automatically available in your view templates, part classes, and scope
10
+ # classes when the `i18n` gem is bundled. They provide `translate`/`t`, `translate!`/`t!`, and
11
+ # `localize`/`l`, sourcing the i18n backend from the view context and expanding relative
12
+ # (leading-dot) translation keys against the currently-rendering template name.
13
+ #
14
+ # The shared `translate`/`localize` logic lives in {Methods}, which is also included by the
15
+ # action-layer i18n helper ({Hanami::Extensions::Action::I18nHelper}). This module supplies
16
+ # the two view-specific concrete implementations of {Methods}'s abstract hooks: {#_i18n}
17
+ # returns the i18n backend from the view context, and {#_resolve_i18n_key} expands relative
18
+ # keys against the template name.
19
+ #
20
+ # @example Basic translation
21
+ # <%= translate("messages.welcome") %>
22
+ # # => "Welcome"
23
+ #
24
+ # @example HTML-safe translation
25
+ # # en.yml
26
+ # # greeting_html: "Hello, <strong>%{name}</strong>!"
27
+ # <%= translate("greeting_html", name: "<script>") %>
28
+ # # => "Hello, <strong>&lt;script&gt;</strong>!" (marked HTML-safe)
29
+ #
30
+ # @example Missing translation
31
+ # <%= translate("missing.key") %>
32
+ # # => '<span class="translation_missing" title="...">missing.key</span>'
33
+ #
34
+ # @example Relative key lookup
35
+ # # In app/templates/users/index.html.erb:
36
+ # <%= translate(".title") %>
37
+ # # Looks up "users.index.title"
38
+ #
39
+ # # In app/templates/users/_form.html.erb (a partial):
40
+ # <%= translate(".label") %>
41
+ # # Looks up "users._form.label"
42
+ #
43
+ # @api public
44
+ # @since 3.0.0
45
+ module I18nHelper
46
+ # Shared `translate` / `localize` (and shorthand) helper methods used by both view-layer
47
+ # consumers and action-layer consumers.
48
+ #
49
+ # **This module is abstract.** Including modules must override two private hooks:
50
+ #
51
+ # - {#_i18n} — returns the i18n backend to delegate to.
52
+ # - {#_resolve_i18n_key} — expands relative (leading-dot) keys against the consumer's
53
+ # context, and is a no-op for absolute keys.
54
+ #
55
+ # @api public
56
+ # @since 3.0.0
57
+ module Methods
58
+ # Matches keys whose final segment is `html` or whose final segment ends in `_html`. These
59
+ # translated values are treated as HTML-safe and any string interpolation values are
60
+ # HTML-escaped before substitution.
61
+ #
62
+ # @api private
63
+ HTML_SAFE_TRANSLATION_KEY = /(\b|_)html\z/
64
+
65
+ # Translates the given key using the slice's i18n backend.
66
+ #
67
+ # When the key's final segment is `html` or ends in `_html`, the result is marked HTML-safe
68
+ # and any string interpolation values are HTML-escaped first.
69
+ #
70
+ # When a translation is missing and neither `:default` nor `:raise` was supplied, returns a
71
+ # `<span class="translation_missing">` element containing the missing key, useful for
72
+ # spotting missing translations during development.
73
+ #
74
+ # When the key begins with a `.`, it is treated as relative to the consumer's context and
75
+ # expanded by {#_resolve_i18n_key}.
76
+ #
77
+ # @param key [String, Symbol] the translation key to look up
78
+ # @param options [Hash] translation options forwarded to the backend (`:locale`, `:scope`,
79
+ # `:default`, `:count`, `:raise`, etc.), plus any interpolation values
80
+ #
81
+ # @return [String] the translated string (marked HTML-safe when hanami-view is bundled and
82
+ # the key ends in `_html` or `html`)
83
+ #
84
+ # @api public
85
+ # @since 3.0.0
86
+ def translate(key, **options)
87
+ key = _resolve_i18n_key(key)
88
+
89
+ html_safe = _html_safe_translation_key?(key)
90
+
91
+ options = _escape_translation_options(options) if html_safe
92
+
93
+ result =
94
+ if options.key?(:default) || options[:raise]
95
+ _i18n.translate(key, **options)
96
+ else
97
+ begin
98
+ _i18n.translate(key, **options, raise: true)
99
+ rescue ::I18n::MissingTranslationData => exception
100
+ return _missing_translation_markup(key, exception)
101
+ end
102
+ end
103
+
104
+ html_safe ? _i18n_mark_html_safe(result.to_s) : result
105
+ end
106
+
107
+ # @api public
108
+ # @since 3.0.0
109
+ alias_method :t, :translate
110
+
111
+ # Translates the given key, raising an exception if the translation is missing.
112
+ #
113
+ # @param (see #translate)
114
+ #
115
+ # @return [String] the translated string (marked HTML-safe when hanami-view is bundled and
116
+ # the key ends in `_html` or `html`)
117
+ #
118
+ # @raise [I18n::MissingTranslationData] if the translation is missing
119
+ #
120
+ # @api public
121
+ # @since 3.0.0
122
+ def translate!(key, **options)
123
+ translate(key, **options, raise: true)
124
+ end
125
+
126
+ # @api public
127
+ # @since 3.0.0
128
+ alias_method :t!, :translate!
129
+
130
+ # Localizes the given object (e.g. a date, time, or number) using the slice's i18n backend.
131
+ #
132
+ # @param object [Date, Time, DateTime, Numeric] the object to localize
133
+ # @param options [Hash] localization options forwarded to the backend (`:locale`, `:format`,
134
+ # etc.)
135
+ #
136
+ # @return [String] the localized string
137
+ #
138
+ # @api public
139
+ # @since 3.0.0
140
+ def localize(object, **options)
141
+ _i18n.localize(object, **options)
142
+ end
143
+
144
+ # @api public
145
+ # @since 3.0.0
146
+ alias_method :l, :localize
147
+
148
+ private
149
+
150
+ # Returns the i18n backend the helper methods delegate to.
151
+ #
152
+ # Including modules must override this method to return an I18n backend instance.
153
+ #
154
+ # @return [Hanami::Providers::I18n::Backend]
155
+ def _i18n
156
+ raise NoMethodError, "#{self.class} must implement #_i18n to return an i18n backend"
157
+ end
158
+
159
+ # Resolves the given key, returning it unchanged by default.
160
+ #
161
+ # Override this hook to expand relative (leading-dot) keys against a context.
162
+ #
163
+ # @param key [String, Symbol]
164
+ #
165
+ # @return [String, Symbol] the resolved key
166
+ def _resolve_i18n_key(key)
167
+ key
168
+ end
169
+
170
+ def _html_safe_translation_key?(key)
171
+ HTML_SAFE_TRANSLATION_KEY.match?(key.to_s)
172
+ end
173
+
174
+ def _escape_translation_options(options)
175
+ options.each_with_object({}) do |(key, value), result|
176
+ result[key] =
177
+ if ::I18n::RESERVED_KEYS.include?(key) || !value.is_a?(String) || _i18n_html_safe?(value)
178
+ value
179
+ else
180
+ _i18n_html_escape(value)
181
+ end
182
+ end
183
+ end
184
+
185
+ def _missing_translation_markup(key, error)
186
+ title = _i18n_html_escape(error.message)
187
+ body = _i18n_html_escape(key.to_s)
188
+ _i18n_mark_html_safe(%(<span class="translation_missing" title="#{title}">#{body}</span>))
189
+ end
190
+
191
+ # Escapes `value` for HTML. Prefers `Hanami::View::Helpers::EscapeHelper#escape_html` when
192
+ # Hanami View's EscapeHelper is included, falling back to stdlib `CGI.escapeHTML` so the
193
+ # helper works in API-only apps that don't bundle hanami-view.
194
+ def _i18n_html_escape(value)
195
+ if respond_to?(:escape_html)
196
+ escape_html(value)
197
+ else
198
+ CGI.escapeHTML(value.to_s)
199
+ end
200
+ end
201
+
202
+ # Marks the given string as HTML-safe when Hanami View is loaded, otherwise returns the
203
+ # string unchanged.
204
+ #
205
+ # The HTML-safe marker is only meaningful to Hanami View's template rendering, so there's
206
+ # nothing to do in its absence.
207
+ def _i18n_mark_html_safe(str)
208
+ str.respond_to?(:html_safe) ? str.html_safe : str
209
+ end
210
+
211
+ def _i18n_html_safe?(value)
212
+ value.respond_to?(:html_safe?) && value.html_safe?
213
+ end
214
+ end
215
+
216
+ include Methods
217
+
218
+ private
219
+
220
+ def _i18n
221
+ _context.i18n
222
+ end
223
+
224
+ def _resolve_i18n_key(key)
225
+ return key unless key.to_s.start_with?(".")
226
+
227
+ template_name = _context.current_template_name
228
+
229
+ unless template_name
230
+ raise(
231
+ ::I18n::ArgumentError,
232
+ "Cannot use relative translation key #{key.inspect} outside of a template render. " \
233
+ "Use an absolute key (without a leading dot) instead."
234
+ )
235
+ end
236
+
237
+ "#{template_name.tr("/", ".")}#{key}"
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/logger"
4
+
5
+ module Hanami
6
+ module Logger
7
+ # Rack request log formatter for Dry Logger.
8
+ #
9
+ # Formats rack request log entries with colorized output for HTTP verbs, status codes, and
10
+ # request paths, making it easier to visually scan logs in development.
11
+ #
12
+ # HTTP verbs each have a distinct color. Status codes follow traffic-light coloring (2xx green,
13
+ # 3xx cyan, 4xx yellow, 5xx red), and the request path echoes the status color so both signals
14
+ # reinforce each other at a glance.
15
+ #
16
+ # Colorization is only active when `colorize: true` is set in the logger options (the default
17
+ # in development).
18
+ #
19
+ # @api private
20
+ class RackFormatter < Dry::Logger::Formatters::String
21
+ RACK_TEMPLATE = <<~TEXT
22
+ [%<progname>s] [%<severity>s] [%<time>s] \
23
+ %<verb>s %<status>s %<elapsed>s %<ip>s %<path>s %<length>s %<payload>s
24
+ %<params>s
25
+ TEXT
26
+
27
+ VERB_COLORS = {
28
+ "GET" => :green,
29
+ "POST" => :yellow,
30
+ "PUT" => :blue,
31
+ "PATCH" => :blue,
32
+ "DELETE" => :red,
33
+ "HEAD" => :cyan
34
+ }.freeze
35
+
36
+ Colors = Dry::Logger::Formatters::Colors
37
+ private_constant :Colors
38
+
39
+ def initialize(**options)
40
+ super
41
+ @template = Dry::Logger::Formatters::Template[RACK_TEMPLATE]
42
+ end
43
+
44
+ private
45
+
46
+ def format_values(entry)
47
+ return super unless colorize?
48
+
49
+ status_color = status_color(entry.to_h[:status])
50
+
51
+ super.merge(
52
+ verb: Colors.call(VERB_COLORS.fetch(entry.to_h[:verb].to_s.upcase, :gray), entry.to_h[:verb]),
53
+ status: Colors.call(status_color, entry.to_h[:status]),
54
+ path: Colors.call(status_color, entry.to_h[:path])
55
+ )
56
+ end
57
+
58
+ def format_params(value)
59
+ value unless value.empty?
60
+ end
61
+
62
+ def status_color(status)
63
+ case status.to_i
64
+ when 200..299 then :green
65
+ when 300..399 then :cyan
66
+ when 400..499 then :yellow
67
+ when 500..599 then :red
68
+ else :gray
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/logger"
4
+
5
+ module Hanami
6
+ module Logger
7
+ # SQL query log formatter for Dry Logger.
8
+ #
9
+ # Formats SQL query log entries with a template that mirrors the structure of the built-in rack
10
+ # log formatter, providing consistent visual formatting across both HTTP request and database
11
+ # query logs.
12
+ #
13
+ # For example, in development, SQL logs alongside Rack logs:
14
+ #
15
+ # [my_app] [INFO] [2026-03-04 10:15:32] SQL sqlite 1.234ms SELECT * FROM users
16
+ # [my_app] [INFO] [2026-03-04 10:15:32] GET 200 1ms 127.0.0.1 /users -
17
+ #
18
+ # In production, the default JSON formatter handles SQL entries automatically via the structured
19
+ # payload.
20
+ #
21
+ # Supports colorization via Dry Logger's template color tags. When `colorize: true` is set in
22
+ # the logger options (the default in development), the "SQL" label is colorized, and severity
23
+ # is colorized per-level by the parent formatter (e.g. INFO => magenta, ERROR => red).
24
+ #
25
+ # When colorization is enabled and the "rouge" gem is available, SQL queries are syntax
26
+ # highlighted using Rouge's SQL lexer. This is a soft dependency; if Rouge is not installed,
27
+ # queries output as plain, unhighlighted text.
28
+ #
29
+ # The Rouge theme defaults to "pastie" and can be customized by setting the
30
+ # `HANAMI_LOG_SYNTAX_THEME` environment variable to any Rouge theme name. See
31
+ # `Rouge::Theme.registry` for available themes.
32
+ #
33
+ # @see Hanami::Logger::SQLLogger
34
+ #
35
+ # @api private
36
+ class SQLFormatter < Dry::Logger::Formatters::String
37
+ SQL_TEMPLATE = <<~TEXT
38
+ [%<progname>s] [%<severity>s] [%<time>s] SQL %<db>s %<elapsed>s%<elapsed_unit>s %<query>s
39
+ TEXT
40
+
41
+ SQL_TEMPLATE_COLORIZED = <<~TEXT
42
+ [%<progname>s] [%<severity>s] [%<time>s] <blue>SQL</blue> %<db>s %<elapsed>s%<elapsed_unit>s %<query>s
43
+ TEXT
44
+
45
+ def initialize(**options)
46
+ super
47
+ @template = Dry::Logger::Formatters::Template[
48
+ colorize? ? SQL_TEMPLATE_COLORIZED : SQL_TEMPLATE
49
+ ]
50
+ @sql_colorizer = build_sql_colorizer if colorize?
51
+ end
52
+
53
+ private
54
+
55
+ def format_query(value)
56
+ if @sql_colorizer
57
+ @sql_colorizer.call(value)
58
+ else
59
+ value
60
+ end
61
+ end
62
+
63
+ def build_sql_colorizer
64
+ begin
65
+ require "rouge"
66
+ rescue LoadError
67
+ return nil
68
+ end
69
+
70
+ theme_name = ENV.fetch("HANAMI_LOG_SYNTAX_THEME", "pastie")
71
+ theme_class = Rouge::Theme.find(theme_name) || Rouge::Themes::Pastie
72
+ formatter = Rouge::Formatters::Terminal256.new(theme_class.new)
73
+
74
+ lexer = Rouge::Lexers::SQL.new
75
+
76
+ ->(sql) { formatter.format(lexer.lex(sql)) }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Logger
5
+ # SQL query logger that integrates with the Hanami logger using structured, tagged logging.
6
+ #
7
+ # Subscribes to `:sql` notification events (emitted by ROM via the Hanami app's
8
+ # `"notifications"` component) and logs each query as a structured payload. The log entries are
9
+ # tagged as `:sql`, which allows Dry Logger backends to route them to a dedicated formatter (see
10
+ # {SQLFormatter}), in the same way that rack log entries are routed using the `:rack` tag.
11
+ #
12
+ # @see SQLFormatter
13
+ # @see Hanami::Providers::DBLogging
14
+ #
15
+ # @api private
16
+ class SQLLogger
17
+ attr_reader :logger, :level
18
+
19
+ # @param logger [#tagged, #info] a Hanami-compatible logger (typically a
20
+ # Dry::Logger::Dispatcher or a {Hanami::UniversalLogger}-wrapped logger)
21
+ def initialize(logger, level: :debug)
22
+ @logger = logger
23
+ @level = level
24
+ end
25
+
26
+ # Subscribes to `:sql` notification events.
27
+ #
28
+ # @param notifications [Dry::Monitor::Notifications] the notifications bus
29
+ # @return [void]
30
+ def subscribe(notifications)
31
+ notifications.subscribe(:sql) { |params| log_query(**params) }
32
+ end
33
+
34
+ # Log a SQL query with structured data.
35
+ #
36
+ # @param time [Numeric] elapsed time in milliseconds
37
+ # @param name [Symbol] database adapter name (e.g. `:sqlite`, `:postgres`)
38
+ # @param query [String] the SQL query string
39
+ def log_query(time:, name:, query:)
40
+ logger.tagged(:sql) do
41
+ logger.public_send(@level) do
42
+ {query:, db: name, elapsed: time.round(3), elapsed_unit: "ms"}
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -72,9 +72,9 @@ module Hanami
72
72
  request.set_header(Rack::REQUEST_METHOD, "GET")
73
73
 
74
74
  @errors_app.call(request.env)
75
- rescue Exception => failsafe_error
75
+ rescue Exception => exception
76
76
  # rubocop:disable Style/StderrPuts
77
- $stderr.puts "Error during exception rendering: #{failsafe_error}\n #{failsafe_error.backtrace * "\n "}"
77
+ $stderr.puts "Error during exception rendering: #{exception}\n #{exception.backtrace * "\n "}"
78
78
  # rubocop:enable Style/StderrPuts
79
79
 
80
80
  [