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
@@ -7,6 +7,8 @@ require_relative "../constants"
7
7
 
8
8
  module Hanami
9
9
  module Providers
10
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
11
+
10
12
  # @api private
11
13
  # @since 2.2.0
12
14
  class DB < Hanami::Provider::Source
@@ -250,7 +252,7 @@ module Hanami
250
252
  end
251
253
 
252
254
  # Set the default gateway from ENV var without suffix
253
- if !database_urls.key?(:default)
255
+ unless database_urls.key?(:default)
254
256
  fallback_vars = ["#{env_var_prefix}DATABASE_URL", "DATABASE_URL"].uniq
255
257
 
256
258
  fallback_vars.each do |var|
@@ -290,7 +292,9 @@ module Hanami
290
292
 
291
293
  return if Hanami.bundled?(database_gem)
292
294
 
293
- raise Hanami::ComponentLoadError, %(The "#{database_gem}" gem is required to connect to #{database_url}. Please add it to your Gemfile.)
295
+ raise Hanami::ComponentLoadError, <<~STR
296
+ The "#{database_gem}" gem is required to connect to #{database_url}. Please add it to your Gemfile.
297
+ STR
294
298
  end
295
299
 
296
300
  def register_rom_components(component_type, path)
@@ -309,6 +313,7 @@ module Hanami
309
313
  end
310
314
  end
311
315
  end
316
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
312
317
 
313
318
  Dry::System.register_provider_source(
314
319
  :db,
@@ -3,19 +3,16 @@
3
3
  module Hanami
4
4
  module Providers
5
5
  # @api private
6
- # @since 2.2.0
7
6
  class DBLogging < Hanami::Provider::Source
8
- # @api private
9
- # @since 2.2.0
10
7
  def prepare
11
- require "dry/monitor/sql/logger"
12
8
  slice["notifications"].register_event :sql
13
9
  end
14
10
 
15
- # @api private
16
- # @since 2.2.0
17
11
  def start
18
- Dry::Monitor::SQL::Logger.new(slice["logger"]).subscribe(slice["notifications"])
12
+ require "hanami/logger/sql_logger"
13
+ Hanami::Logger::SQLLogger
14
+ .new(slice["logger"], level: slice.config.db.log_level)
15
+ .subscribe(slice["notifications"])
19
16
  end
20
17
  end
21
18
  end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Providers
5
+ class I18n < Hanami::Provider::Source
6
+ # A wrapper that provides a full `I18n`-like interface for an individual I18n backend. This
7
+ # allows each Hanami slice to have its own isolated I18n instance.
8
+ #
9
+ # Unlike the global I18n module which uses class variables for configuration,
10
+ # this wrapper maintains per-instance state for true isolation between slices.
11
+ #
12
+ # @api public
13
+ # @since 3.0.0
14
+ class Backend
15
+ attr_reader :backend
16
+
17
+ # Returns the default locale.
18
+ #
19
+ # @return [Symbol] the default locale
20
+ #
21
+ # @api public
22
+ # @since 3.0.0
23
+ attr_reader :default_locale
24
+
25
+ # Creates a new Backend instance.
26
+ #
27
+ # @param backend [I18n::Backend::Base] the underlying I18n backend
28
+ # @param locale [Symbol, String, nil] initial locale to set in thread-local storage
29
+ # @param default_locale [Symbol, String] the default locale to use when no locale is set
30
+ # @param available_locales [Array<Symbol, String>] list of available locales
31
+ # @param fallbacks [I18n::Locale::Fallbacks, nil] fallbacks configuration for missing translations
32
+ #
33
+ # @api private
34
+ # @since 3.0.0
35
+ def initialize(backend, locale: nil, default_locale: :en, available_locales: [], fallbacks: nil)
36
+ @backend = backend
37
+ @default_locale = default_locale.to_sym
38
+ @available_locales = Array(available_locales).map(&:to_sym)
39
+ @fallbacks = fallbacks
40
+
41
+ # Set initial locale (if provided) in thread-local storage.
42
+ @storage_key = :"hanami_i18n_#{object_id}"
43
+ self.locale = locale if locale
44
+ end
45
+
46
+ # rubocop:disable Metrics/PerceivedComplexity
47
+
48
+ # Translates the given key.
49
+ #
50
+ # @param key [String, Symbol] the translation key to look up
51
+ # @param options [Hash] translation options
52
+ # @option options [Symbol, String] :locale the locale to use (defaults to current locale)
53
+ # @option options [String, Proc, Array] :default default value if translation is missing
54
+ # @option options [Hash] :scope additional scope for the key
55
+ # @option options [Integer] :count for pluralization
56
+ # @option options [Boolean] :raise whether to raise an exception for missing translations
57
+ #
58
+ # @return [String, Object] the translated string or default value
59
+ #
60
+ # @raise [I18n::MissingTranslationData] if translation is missing and :raise option is true
61
+ #
62
+ # @example Basic translation
63
+ # translate("hello") # => "Hello"
64
+ #
65
+ # @example Translation with interpolation
66
+ # translate("greeting", name: "Alice") # => "Hello, Alice"
67
+ #
68
+ # @example With explicit locale
69
+ # translate("hello", locale: :fr) # => "Bonjour"
70
+ #
71
+ # @api public
72
+ # @since 3.0.0
73
+ def translate(key, **options)
74
+ locale = options[:locale] || self.locale
75
+
76
+ result = catch(:exception) do
77
+ @backend.translate(locale, key, options)
78
+ end
79
+
80
+ # If translation is missing and fallbacks are configured, try fallback locales
81
+ if result.is_a?(::I18n::MissingTranslation) && @fallbacks && !options[:fallback]
82
+ fallback_locales = @fallbacks[locale] - [locale]
83
+ fallback_locales.each do |fallback_locale|
84
+ fallback_result = catch(:exception) do
85
+ @backend.translate(fallback_locale, key, options.merge(fallback: true))
86
+ end
87
+
88
+ unless fallback_result.is_a?(::I18n::MissingTranslation)
89
+ return fallback_result
90
+ end
91
+ end
92
+ end
93
+
94
+ if result.is_a?(::I18n::MissingTranslation)
95
+ if options[:raise]
96
+ raise ::I18n::MissingTranslationData.new(locale, key, options)
97
+ else
98
+ handle_missing_translation(result, options)
99
+ end
100
+ else
101
+ result
102
+ end
103
+ end
104
+
105
+ # rubocop:enable Metrics/PerceivedComplexity
106
+
107
+ # @api public
108
+ # @since 3.0.0
109
+ alias_method :t, :translate
110
+
111
+ # Translates the given key, raising an exception if translation is missing.
112
+ #
113
+ # @param key [String, Symbol] the translation key to look up
114
+ # @param options [Hash] translation options (see {#translate})
115
+ #
116
+ # @return [String, Object] the translated string
117
+ #
118
+ # @raise [I18n::MissingTranslationData] if translation is missing
119
+ #
120
+ # @example
121
+ # translate!("hello") # => "Hello"
122
+ # translate!("missing.key") # raises I18n::MissingTranslationData
123
+ #
124
+ # @api public
125
+ # @since 3.0.0
126
+ def translate!(key, **options)
127
+ translate(key, **options.merge(raise: true))
128
+ end
129
+
130
+ # @api public
131
+ # @since 3.0.0
132
+ alias_method :t!, :translate!
133
+
134
+ # Localizes the given date or time.
135
+ #
136
+ # Resolves symbol formats through this instance's translations (e.g. `:short` becomes
137
+ # `date.formats.short` or `time.formats.short`). Also resolves locale-dependent strftime
138
+ # codes (e.g. `%a`, `%A`, `%b`, `%B`, `%p`, `%P`).
139
+ #
140
+ # @param object [Date, Time, DateTime] the object to localize
141
+ # @param locale [Symbol, String, nil] the locale to use (defaults to current locale)
142
+ # @param format [Symbol, String] the format to use for localization
143
+ # @param options [Hash] additional localization options
144
+ #
145
+ # @return [String] the localized string representation
146
+ #
147
+ # @example Localize a date
148
+ # localize(Date.today, format: :long) # => "January 19, 2026"
149
+ #
150
+ # @example Localize with specific locale
151
+ # localize(Date.today, locale: :fr, format: :long) # => "19 janvier 2026"
152
+ #
153
+ # @api public
154
+ # @since 3.0.0
155
+ def localize(object, locale: nil, format: :default, **options)
156
+ locale ||= self.locale
157
+
158
+ return options[:default] if object.nil? && options.key?(:default)
159
+
160
+ unless object.respond_to?(:strftime)
161
+ raise ArgumentError, <<~MSG
162
+ Object must be a Date, DateTime or Time object. #{object.inspect} given.
163
+ MSG
164
+ end
165
+
166
+ if format.is_a?(Symbol)
167
+ type = object.respond_to?(:sec) ? "time" : "date"
168
+ format = translate(
169
+ :"#{type}.formats.#{format}",
170
+ **options, locale:, object:, raise: true
171
+ )
172
+ end
173
+
174
+ format = expand_localization_format(locale, object, format)
175
+ object.strftime(format)
176
+ end
177
+
178
+ # @api public
179
+ # @since 3.0.0
180
+ alias_method :l, :localize
181
+
182
+ # Returns true if a translation exists for the given key.
183
+ #
184
+ # @param key [String, Symbol] the translation key to check
185
+ # @param locale [Symbol, String, nil] the locale to check (defaults to current locale)
186
+ # @param options [Hash] additional options
187
+ #
188
+ # @return [Boolean] true if the translation exists, false otherwise
189
+ #
190
+ # @example
191
+ # exists?("hello") # => true
192
+ # exists?("missing.key") # => false
193
+ #
194
+ # @api public
195
+ # @since 3.0.0
196
+ def exists?(key, locale: nil, **options)
197
+ locale ||= self.locale
198
+ @backend.exists?(locale, key, options)
199
+ end
200
+
201
+ # Transliterates the given string.
202
+ #
203
+ # @param key [String] the string to transliterate
204
+ # @param locale [Symbol, String, nil] the locale to use (defaults to current locale)
205
+ # @param replacement [String, nil] replacement string for non-transliteratable characters
206
+ # @param options [Hash] additional transliteration options
207
+ #
208
+ # @return [String] the transliterated string
209
+ #
210
+ # @example
211
+ # transliterate("Ærøskøbing") # => "AEroskobing"
212
+ #
213
+ # @api public
214
+ # @since 3.0.0
215
+ def transliterate(key, locale: nil, replacement: nil, **options)
216
+ locale ||= self.locale
217
+ @backend.transliterate(locale, key, replacement)
218
+ end
219
+
220
+ # Returns available locales.
221
+ #
222
+ # If configured via `config.i18n.available_locales`, returns the configured locales.
223
+ # Otherwise, returns all locales detected from loaded translation files.
224
+ #
225
+ # @api public
226
+ # @since 3.0.0
227
+ def available_locales
228
+ if @available_locales.any?
229
+ @available_locales
230
+ else
231
+ @backend.available_locales
232
+ end
233
+ end
234
+
235
+ # Reloads translations from translation files.
236
+ #
237
+ # @return [void]
238
+ #
239
+ # @api public
240
+ # @since 3.0.0
241
+ def reload!
242
+ @backend.reload!
243
+ end
244
+
245
+ # Eager loads translations if the backend supports it.
246
+ #
247
+ # @return [void]
248
+ #
249
+ # @api public
250
+ # @since 3.0.0
251
+ def eager_load!
252
+ @backend.eager_load! if @backend.respond_to?(:eager_load!)
253
+ end
254
+
255
+ # Returns the current locale from fiber/thread-local storage, or default_locale.
256
+ #
257
+ # This is thread-safe and works correctly with concurrent requests.
258
+ # Each backend instance maintains its own locale in thread storage.
259
+ #
260
+ # @return [Symbol] the current locale or default locale if none is set
261
+ #
262
+ # @example
263
+ # locale # => :en
264
+ # self.locale = :fr
265
+ # locale # => :fr
266
+ #
267
+ # @api public
268
+ # @since 3.0.0
269
+ def locale
270
+ Thread.current[@storage_key] || default_locale
271
+ end
272
+
273
+ # Sets the current locale in fiber/thread-local storage.
274
+ #
275
+ # This is thread-safe and works correctly with concurrent requests.
276
+ # Each backend instance maintains its own locale in thread storage.
277
+ #
278
+ # @param value [Symbol, String, nil] the locale to set (converted to symbol)
279
+ #
280
+ # @return [Symbol, nil] the locale value that was set
281
+ #
282
+ # @example
283
+ # self.locale = :fr
284
+ # self.locale = "de"
285
+ # self.locale = nil # resets to default_locale
286
+ #
287
+ # @api public
288
+ # @since 3.0.0
289
+ def locale=(value)
290
+ Thread.current[@storage_key] = value&.to_sym
291
+ end
292
+
293
+ # Executes a block with a temporary locale.
294
+ #
295
+ # This is useful for executing code with a specific locale without affecting
296
+ # other concurrent requests. The previous locale is restored even if the block raises.
297
+ #
298
+ # @param tmp_locale [Symbol, String, nil] the temporary locale to use
299
+ #
300
+ # @yieldreturn [Object] the return value of the block
301
+ #
302
+ # @return [Object] the return value of the block
303
+ #
304
+ # @example
305
+ # with_locale(:fr) do
306
+ # t("greeting") # Uses French locale
307
+ # end
308
+ # # locale is restored to previous value
309
+ #
310
+ # @example Nested usage
311
+ # with_locale(:fr) do
312
+ # with_locale(:de) do
313
+ # t("hello") # Uses German
314
+ # end
315
+ # t("hello") # Uses French
316
+ # end
317
+ #
318
+ # @api public
319
+ # @since 3.0.0
320
+ def with_locale(tmp_locale)
321
+ previous_locale = Thread.current[@storage_key]
322
+ Thread.current[@storage_key] = tmp_locale&.to_sym
323
+ yield
324
+ ensure
325
+ Thread.current[@storage_key] = previous_locale
326
+ end
327
+
328
+ private
329
+
330
+ def handle_missing_translation(missing_translation, options)
331
+ default = options[:default]
332
+ return default if default.is_a?(String)
333
+
334
+ key = missing_translation.key
335
+ key.to_s
336
+ end
337
+
338
+ # rubocop:disable Layout/LineLength, Style/NestedTernaryOperator, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
339
+
340
+ # Expands locale-dependent strftime codes (`%a`, `%A`, `%b`, `%B`, `%p`, `%P` and their
341
+ # uppercase `^` variants) by looking up day/month names and meridiem markers through this
342
+ # backend.
343
+ #
344
+ # This is a deliberate port of `I18n::Backend::Base#translate_localization_format`. We can't
345
+ # call this upstream method directly because it's private and also hardcodes `I18n.t!` (the
346
+ # global module) for every lookup, which defeats our per-slice isolated backends.
347
+ #
348
+ # This set of locale-dependent strftime codes is fixed by the C `strftime` spec and has not
349
+ # changed in the upstream gem since at least 2010, so this table is effectively frozen.
350
+ def expand_localization_format(locale, object, format)
351
+ format.to_s.gsub(/%(|\^)[aAbBpP]/) do |match|
352
+ case match
353
+ when "%a" then t!(:"date.abbr_day_names", locale:, format:)[object.wday]
354
+ when "%^a" then t!(:"date.abbr_day_names", locale:, format:)[object.wday].upcase
355
+ when "%A" then t!(:"date.day_names", locale:, format:)[object.wday]
356
+ when "%^A" then t!(:"date.day_names", locale:, format:)[object.wday].upcase
357
+ when "%b" then t!(:"date.abbr_month_names", locale:, format:)[object.mon]
358
+ when "%^b" then t!(:"date.abbr_month_names", locale:, format:)[object.mon].upcase
359
+ when "%B" then t!(:"date.month_names", locale:, format:)[object.mon]
360
+ when "%^B" then t!(:"date.month_names", locale:, format:)[object.mon].upcase
361
+ when "%p" then t!(:"time.#{(object.respond_to?(:hour) ? object.hour : 0) < 12 ? :am : :pm}", locale:, format:).upcase
362
+ when "%P" then t!(:"time.#{(object.respond_to?(:hour) ? object.hour : 0) < 12 ? :am : :pm}", locale:, format:).downcase
363
+ end
364
+ end
365
+ rescue ::I18n::MissingTranslationData => exception
366
+ exception.message
367
+ end
368
+
369
+ # rubocop:enable Layout/LineLength, Style/NestedTernaryOperator, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,57 @@
1
+ en:
2
+ date:
3
+ formats:
4
+ default: "%a, %-d %b %Y"
5
+ short: "%-d %b"
6
+ long: "%-d %B %Y"
7
+ day_names:
8
+ - Sunday
9
+ - Monday
10
+ - Tuesday
11
+ - Wednesday
12
+ - Thursday
13
+ - Friday
14
+ - Saturday
15
+ abbr_day_names:
16
+ - Sun
17
+ - Mon
18
+ - Tue
19
+ - Wed
20
+ - Thu
21
+ - Fri
22
+ - Sat
23
+ month_names:
24
+ - ~
25
+ - January
26
+ - February
27
+ - March
28
+ - April
29
+ - May
30
+ - June
31
+ - July
32
+ - August
33
+ - September
34
+ - October
35
+ - November
36
+ - December
37
+ abbr_month_names:
38
+ - ~
39
+ - Jan
40
+ - Feb
41
+ - Mar
42
+ - Apr
43
+ - May
44
+ - Jun
45
+ - Jul
46
+ - Aug
47
+ - Sep
48
+ - Oct
49
+ - Nov
50
+ - Dec
51
+ time:
52
+ formats:
53
+ default: "%a, %-d %b %Y %H:%M:%S %:z"
54
+ short: "%-d %b %-I:%M %P"
55
+ long: "%-d %B %Y %-I:%M %P"
56
+ am: "am"
57
+ pm: "pm"
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Providers
5
+ # @api private
6
+ class I18n < Hanami::Provider::Source
7
+ SLICE_CONFIGURED_SETTINGS = %i[
8
+ default_locale available_locales load_path shared_load_path fallbacks
9
+ ].freeze
10
+
11
+ DEFAULT_LOCALE = :en
12
+ DEFAULT_AVAILABLE_LOCALES = [].freeze
13
+ DEFAULT_LOAD_PATH = ["config/i18n/**/*.{yml,yaml,json,rb}"].freeze
14
+ DEFAULT_SHARED_LOAD_PATH = ["config/i18n/shared/**/*.{yml,yaml,json,rb}"].freeze
15
+
16
+ # Built-in English baseline for date/time localization. Loaded into every slice before any
17
+ # user translation files, so users can supply overrides if desired.
18
+ BUNDLED_DEFAULTS_PATH = File.expand_path("i18n/locale/en.yml", __dir__).freeze
19
+
20
+ setting :default_locale, default: DEFAULT_LOCALE
21
+ setting :available_locales, default: DEFAULT_AVAILABLE_LOCALES
22
+ setting :backend
23
+ setting :load_path, default: DEFAULT_LOAD_PATH
24
+ setting :shared_load_path, default: DEFAULT_SHARED_LOAD_PATH
25
+ setting :fallbacks
26
+
27
+ def prepare
28
+ require "i18n"
29
+
30
+ SLICE_CONFIGURED_SETTINGS.each do |setting|
31
+ next if config.configured?(setting)
32
+
33
+ config.public_send(:"#{setting}=", slice.config.i18n.public_send(setting))
34
+ end
35
+ end
36
+
37
+ # rubocop:disable Metrics/AbcSize
38
+ def start
39
+ backend = config.backend || ::I18n::Backend::Simple.new
40
+
41
+ # Configure fallbacks if enabled (only for default backend, not custom backends)
42
+ fallbacks_config = nil
43
+ if config.fallbacks && !config.backend
44
+ fallbacks_config =
45
+ case config.fallbacks
46
+ when true
47
+ # Use default_locale as the only fallback
48
+ fallbacks = ::I18n::Locale::Fallbacks.new
49
+ fallbacks.defaults = [config.default_locale]
50
+ fallbacks
51
+ when Hash
52
+ # Use explicit per-locale fallback mapping
53
+ ::I18n::Locale::Fallbacks.new(config.fallbacks)
54
+ when Array
55
+ # Use the given fallbacks for all locales
56
+ fallbacks = ::I18n::Locale::Fallbacks.new
57
+ fallbacks.defaults = config.fallbacks
58
+ fallbacks
59
+ else
60
+ ::I18n::Locale::Fallbacks.new(config.fallbacks)
61
+ end
62
+ end
63
+
64
+ # Only load translation files if using the default backend. Custom backends are expected to
65
+ # handle their own initialization.
66
+ unless config.backend
67
+ translation_files = [
68
+ BUNDLED_DEFAULTS_PATH,
69
+ *resolve_load_paths(Array(config.shared_load_path), root: slice.app.root),
70
+ *resolve_load_paths(Array(config.load_path), root: slice.root)
71
+ ].uniq
72
+
73
+ backend.load_translations(*translation_files)
74
+ end
75
+
76
+ register "i18n", Backend.new(
77
+ backend,
78
+ locale: config.default_locale,
79
+ default_locale: config.default_locale,
80
+ available_locales: config.available_locales,
81
+ fallbacks: fallbacks_config
82
+ )
83
+ end
84
+ # rubocop:enable Metrics/AbcSize
85
+
86
+ private
87
+
88
+ # Resolves load path patterns to actual file paths. Relative patterns are resolved against
89
+ # the given `root`. Absolute paths are used as-is.
90
+ def resolve_load_paths(patterns, root:)
91
+ patterns.flat_map do |pattern|
92
+ if absolute_path?(pattern)
93
+ # Absolute path or already-globbed absolute paths
94
+ File.exist?(pattern) ? [pattern] : Dir.glob(pattern)
95
+ else
96
+ Dir.glob(root.join(pattern))
97
+ end
98
+ end
99
+ end
100
+
101
+ def absolute_path?(path)
102
+ Pathname.new(path).absolute?
103
+ end
104
+ end
105
+
106
+ Dry::System.register_provider_source(
107
+ :i18n,
108
+ source: I18n,
109
+ group: :hanami
110
+ )
111
+ end
112
+ end
113
+
114
+ require_relative "i18n/backend"
@@ -12,8 +12,30 @@ module Hanami
12
12
  class Logger < Hanami::Provider::Source
13
13
  # @api private
14
14
  def start
15
- register :logger, Hanami.app.config.logger_instance
15
+ register :logger, logger
16
+ end
17
+
18
+ # Returns the logger instance that will be registered as the slice's `"logger"` component.
19
+ #
20
+ # This is memoized, so lifecycle callbacks added when extending the provider via
21
+ # {Hanami::Slice::ClassMethods#configure_provider} (such as a `before :start` or `after :start`
22
+ # hook) can access and customize the very same logger that `start` registers — for example, to
23
+ # add a logging backend.
24
+ #
25
+ # @return [Dry::Logger::Dispatcher] the default logger, or the logger instance configured via
26
+ # {Hanami::Config#logger=}
27
+ #
28
+ # @api public
29
+ # @since 3.0.0
30
+ def logger
31
+ @logger ||= Hanami.app.config.logger_instance
16
32
  end
17
33
  end
34
+
35
+ Dry::System.register_provider_source(
36
+ :logger,
37
+ source: Logger,
38
+ group: :hanami
39
+ )
18
40
  end
19
41
  end