hanami 2.3.2 → 3.0.0.rc1

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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -19
  3. data/LICENSE +20 -0
  4. data/README.md +18 -35
  5. data/hanami.gemspec +36 -37
  6. data/lib/hanami/config/db.rb +2 -0
  7. data/lib/hanami/config/i18n.rb +138 -0
  8. data/lib/hanami/config/logger.rb +15 -7
  9. data/lib/hanami/config/null_config.rb +1 -1
  10. data/lib/hanami/config/views.rb +17 -0
  11. data/lib/hanami/config.rb +66 -22
  12. data/lib/hanami/errors.rb +6 -0
  13. data/lib/hanami/extensions/action/slice_configured_action.rb +1 -1
  14. data/lib/hanami/extensions/action.rb +2 -2
  15. data/lib/hanami/extensions/mailer/slice_configured_mailer.rb +120 -0
  16. data/lib/hanami/extensions/mailer.rb +28 -0
  17. data/lib/hanami/extensions/operation/slice_configured_db_operation.rb +2 -0
  18. data/lib/hanami/extensions/view/context.rb +26 -4
  19. data/lib/hanami/extensions/view/part.rb +2 -0
  20. data/lib/hanami/extensions/view/slice_configured_context.rb +7 -0
  21. data/lib/hanami/extensions/view/slice_configured_part.rb +2 -0
  22. data/lib/hanami/extensions/view/slice_configured_view.rb +8 -8
  23. data/lib/hanami/extensions/view/standard_helpers.rb +4 -0
  24. data/lib/hanami/extensions.rb +6 -1
  25. data/lib/hanami/helpers/assets_helper.rb +0 -4
  26. data/lib/hanami/helpers/form_helper.rb +1 -1
  27. data/lib/hanami/helpers/i18n_helper.rb +176 -0
  28. data/lib/hanami/logger/rack_formatter.rb +73 -0
  29. data/lib/hanami/logger/sql_formatter.rb +80 -0
  30. data/lib/hanami/logger/sql_logger.rb +48 -0
  31. data/lib/hanami/middleware/render_errors.rb +2 -2
  32. data/lib/hanami/providers/db.rb +7 -2
  33. data/lib/hanami/providers/db_logging.rb +4 -7
  34. data/lib/hanami/providers/i18n/backend.rb +369 -0
  35. data/lib/hanami/providers/i18n/locale/en.yml +57 -0
  36. data/lib/hanami/providers/i18n.rb +114 -0
  37. data/lib/hanami/providers/mailers.rb +101 -0
  38. data/lib/hanami/routes.rb +1 -0
  39. data/lib/hanami/settings/composite_store.rb +53 -0
  40. data/lib/hanami/settings.rb +4 -4
  41. data/lib/hanami/slice/router.rb +15 -10
  42. data/lib/hanami/slice.rb +71 -11
  43. data/lib/hanami/slice_registrar.rb +2 -2
  44. data/lib/hanami/universal_logger.rb +250 -0
  45. data/lib/hanami/version.rb +1 -1
  46. data/lib/hanami/web/rack_logger.rb +2 -80
  47. data/lib/hanami/web/welcome.html.erb +443 -58
  48. data/lib/hanami.rb +4 -2
  49. metadata +28 -276
  50. data/CODE_OF_CONDUCT.md +0 -74
  51. data/FEATURES.md +0 -269
  52. data/LICENSE.md +0 -22
  53. data/spec/integration/action/cookies_spec.rb +0 -58
  54. data/spec/integration/action/csrf_protection_spec.rb +0 -54
  55. data/spec/integration/action/format_config_spec.rb +0 -129
  56. data/spec/integration/action/routes_spec.rb +0 -71
  57. data/spec/integration/action/sessions_spec.rb +0 -50
  58. data/spec/integration/action/slice_configuration_spec.rb +0 -284
  59. data/spec/integration/action/view_rendering/automatic_rendering_spec.rb +0 -247
  60. data/spec/integration/action/view_rendering/paired_view_inference_spec.rb +0 -115
  61. data/spec/integration/action/view_rendering/view_context_spec.rb +0 -221
  62. data/spec/integration/action/view_rendering_spec.rb +0 -89
  63. data/spec/integration/assets/assets_spec.rb +0 -155
  64. data/spec/integration/assets/cross_slice_assets_helpers_spec.rb +0 -129
  65. data/spec/integration/assets/serve_static_assets_spec.rb +0 -152
  66. data/spec/integration/code_loading/loading_from_app_spec.rb +0 -152
  67. data/spec/integration/code_loading/loading_from_lib_spec.rb +0 -242
  68. data/spec/integration/code_loading/loading_from_slice_spec.rb +0 -165
  69. data/spec/integration/container/application_routes_helper_spec.rb +0 -48
  70. data/spec/integration/container/auto_injection_spec.rb +0 -53
  71. data/spec/integration/container/auto_registration_spec.rb +0 -86
  72. data/spec/integration/container/autoloader_spec.rb +0 -82
  73. data/spec/integration/container/imports_spec.rb +0 -253
  74. data/spec/integration/container/prepare_container_spec.rb +0 -125
  75. data/spec/integration/container/provider_environment_spec.rb +0 -52
  76. data/spec/integration/container/provider_lifecycle_spec.rb +0 -61
  77. data/spec/integration/container/shutdown_spec.rb +0 -91
  78. data/spec/integration/container/standard_providers/rack_provider_spec.rb +0 -44
  79. data/spec/integration/container/standard_providers_spec.rb +0 -124
  80. data/spec/integration/db/auto_registration_spec.rb +0 -39
  81. data/spec/integration/db/commands_spec.rb +0 -80
  82. data/spec/integration/db/db_inflector_spec.rb +0 -57
  83. data/spec/integration/db/db_slices_spec.rb +0 -398
  84. data/spec/integration/db/db_spec.rb +0 -245
  85. data/spec/integration/db/gateways_spec.rb +0 -361
  86. data/spec/integration/db/logging_spec.rb +0 -301
  87. data/spec/integration/db/mappers_spec.rb +0 -84
  88. data/spec/integration/db/provider_config_spec.rb +0 -88
  89. data/spec/integration/db/provider_spec.rb +0 -35
  90. data/spec/integration/db/relations_spec.rb +0 -60
  91. data/spec/integration/db/repo_spec.rb +0 -300
  92. data/spec/integration/db/slices_importing_from_parent.rb +0 -130
  93. data/spec/integration/dotenv_loading_spec.rb +0 -138
  94. data/spec/integration/logging/exception_logging_spec.rb +0 -120
  95. data/spec/integration/logging/notifications_spec.rb +0 -68
  96. data/spec/integration/logging/request_logging_spec.rb +0 -202
  97. data/spec/integration/operations/extension_spec.rb +0 -122
  98. data/spec/integration/rack_app/body_parser_spec.rb +0 -108
  99. data/spec/integration/rack_app/method_override_spec.rb +0 -97
  100. data/spec/integration/rack_app/middleware_spec.rb +0 -720
  101. data/spec/integration/rack_app/non_booted_rack_app_spec.rb +0 -104
  102. data/spec/integration/rack_app/rack_app_spec.rb +0 -442
  103. data/spec/integration/rake_tasks_spec.rb +0 -107
  104. data/spec/integration/router/resource_routes_spec.rb +0 -281
  105. data/spec/integration/settings/access_in_slice_class_body_spec.rb +0 -83
  106. data/spec/integration/settings/access_to_constants_spec.rb +0 -46
  107. data/spec/integration/settings/loading_from_env_spec.rb +0 -188
  108. data/spec/integration/settings/settings_component_loading_spec.rb +0 -113
  109. data/spec/integration/settings/slice_registration_spec.rb +0 -145
  110. data/spec/integration/settings/using_types_spec.rb +0 -80
  111. data/spec/integration/setup_spec.rb +0 -165
  112. data/spec/integration/slices/external_slice_spec.rb +0 -91
  113. data/spec/integration/slices/slice_configuration_spec.rb +0 -42
  114. data/spec/integration/slices/slice_loading_spec.rb +0 -171
  115. data/spec/integration/slices/slice_registrations_spec.rb +0 -80
  116. data/spec/integration/slices/slice_routing_spec.rb +0 -219
  117. data/spec/integration/slices_spec.rb +0 -471
  118. data/spec/integration/view/config/default_context_spec.rb +0 -149
  119. data/spec/integration/view/config/inflector_spec.rb +0 -57
  120. data/spec/integration/view/config/part_class_spec.rb +0 -147
  121. data/spec/integration/view/config/part_namespace_spec.rb +0 -103
  122. data/spec/integration/view/config/paths_spec.rb +0 -119
  123. data/spec/integration/view/config/scope_class_spec.rb +0 -147
  124. data/spec/integration/view/config/scope_namespace_spec.rb +0 -103
  125. data/spec/integration/view/config/template_spec.rb +0 -38
  126. data/spec/integration/view/context/assets_spec.rb +0 -79
  127. data/spec/integration/view/context/inflector_spec.rb +0 -40
  128. data/spec/integration/view/context/request_spec.rb +0 -57
  129. data/spec/integration/view/context/routes_spec.rb +0 -84
  130. data/spec/integration/view/helpers/form_helper_spec.rb +0 -174
  131. data/spec/integration/view/helpers/part_helpers_spec.rb +0 -124
  132. data/spec/integration/view/helpers/scope_helpers_spec.rb +0 -84
  133. data/spec/integration/view/helpers/user_defined_helpers/part_helpers_spec.rb +0 -162
  134. data/spec/integration/view/helpers/user_defined_helpers/scope_helpers_spec.rb +0 -119
  135. data/spec/integration/view/parts/default_rendering_spec.rb +0 -138
  136. data/spec/integration/view/slice_configuration_spec.rb +0 -289
  137. data/spec/integration/view/views_spec.rb +0 -103
  138. data/spec/integration/web/content_security_policy_nonce_spec.rb +0 -251
  139. data/spec/integration/web/render_detailed_errors_spec.rb +0 -107
  140. data/spec/integration/web/render_errors_spec.rb +0 -242
  141. data/spec/integration/web/welcome_view_spec.rb +0 -84
  142. data/spec/spec_helper.rb +0 -28
  143. data/spec/support/app_integration.rb +0 -157
  144. data/spec/support/coverage.rb +0 -1
  145. data/spec/support/matchers.rb +0 -32
  146. data/spec/support/rspec.rb +0 -27
  147. data/spec/unit/hanami/config/actions/content_security_policy_spec.rb +0 -96
  148. data/spec/unit/hanami/config/actions/cookies_spec.rb +0 -46
  149. data/spec/unit/hanami/config/actions/csrf_protection_spec.rb +0 -58
  150. data/spec/unit/hanami/config/actions/default_values_spec.rb +0 -43
  151. data/spec/unit/hanami/config/actions/sessions_spec.rb +0 -48
  152. data/spec/unit/hanami/config/actions_spec.rb +0 -52
  153. data/spec/unit/hanami/config/base_url_spec.rb +0 -25
  154. data/spec/unit/hanami/config/console_spec.rb +0 -22
  155. data/spec/unit/hanami/config/db_spec.rb +0 -38
  156. data/spec/unit/hanami/config/inflector_spec.rb +0 -35
  157. data/spec/unit/hanami/config/logger_spec.rb +0 -195
  158. data/spec/unit/hanami/config/render_detailed_errors_spec.rb +0 -25
  159. data/spec/unit/hanami/config/render_errors_spec.rb +0 -25
  160. data/spec/unit/hanami/config/router_spec.rb +0 -44
  161. data/spec/unit/hanami/config/slices_spec.rb +0 -34
  162. data/spec/unit/hanami/config/views_spec.rb +0 -80
  163. data/spec/unit/hanami/env_spec.rb +0 -37
  164. data/spec/unit/hanami/extensions/view/context_spec.rb +0 -59
  165. data/spec/unit/hanami/helpers/assets_helper/asset_url_spec.rb +0 -120
  166. data/spec/unit/hanami/helpers/assets_helper/audio_tag_spec.rb +0 -132
  167. data/spec/unit/hanami/helpers/assets_helper/favicon_tag_spec.rb +0 -87
  168. data/spec/unit/hanami/helpers/assets_helper/image_tag_spec.rb +0 -92
  169. data/spec/unit/hanami/helpers/assets_helper/javascript_tag_spec.rb +0 -143
  170. data/spec/unit/hanami/helpers/assets_helper/stylesheet_tag_spec.rb +0 -126
  171. data/spec/unit/hanami/helpers/assets_helper/video_tag_spec.rb +0 -136
  172. data/spec/unit/hanami/helpers/form_helper_spec.rb +0 -2857
  173. data/spec/unit/hanami/port_spec.rb +0 -117
  174. data/spec/unit/hanami/providers/db/config/default_config_spec.rb +0 -100
  175. data/spec/unit/hanami/providers/db/config/gateway_spec.rb +0 -73
  176. data/spec/unit/hanami/providers/db/config_spec.rb +0 -143
  177. data/spec/unit/hanami/router/errors/not_allowed_error_spec.rb +0 -27
  178. data/spec/unit/hanami/router/errors/not_found_error_spec.rb +0 -22
  179. data/spec/unit/hanami/settings/env_store_spec.rb +0 -52
  180. data/spec/unit/hanami/settings_spec.rb +0 -111
  181. data/spec/unit/hanami/slice_configurable_spec.rb +0 -141
  182. data/spec/unit/hanami/slice_name_spec.rb +0 -47
  183. data/spec/unit/hanami/slice_spec.rb +0 -99
  184. data/spec/unit/hanami/web/rack_logger_spec.rb +0 -99
@@ -0,0 +1,369 @@
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 x.x.x
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 x.x.x
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 x.x.x
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 x.x.x
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 x.x.x
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
+ # t!("hello") # => "Hello"
122
+ # t!("missing.key") # raises I18n::MissingTranslationData
123
+ #
124
+ # @api public
125
+ # @since x.x.x
126
+ def t!(key, **options)
127
+ translate(key, **options.merge(raise: true))
128
+ end
129
+
130
+ # Localizes the given date or time.
131
+ #
132
+ # Resolves symbol formats through this instance's translations (e.g. `:short` becomes
133
+ # `date.formats.short` or `time.formats.short`). Also resolves locale-dependent strftime
134
+ # codes (e.g. `%a`, `%A`, `%b`, `%B`, `%p`, `%P`).
135
+ #
136
+ # @param object [Date, Time, DateTime] the object to localize
137
+ # @param locale [Symbol, String, nil] the locale to use (defaults to current locale)
138
+ # @param format [Symbol, String] the format to use for localization
139
+ # @param options [Hash] additional localization options
140
+ #
141
+ # @return [String] the localized string representation
142
+ #
143
+ # @example Localize a date
144
+ # localize(Date.today, format: :long) # => "January 19, 2026"
145
+ #
146
+ # @example Localize with specific locale
147
+ # localize(Date.today, locale: :fr, format: :long) # => "19 janvier 2026"
148
+ #
149
+ # @api public
150
+ # @since x.x.x
151
+ def localize(object, locale: nil, format: :default, **options)
152
+ locale ||= self.locale
153
+
154
+ return options[:default] if object.nil? && options.key?(:default)
155
+
156
+ unless object.respond_to?(:strftime)
157
+ raise ArgumentError, <<~MSG
158
+ Object must be a Date, DateTime or Time object. #{object.inspect} given.
159
+ MSG
160
+ end
161
+
162
+ if format.is_a?(Symbol)
163
+ type = object.respond_to?(:sec) ? "time" : "date"
164
+ format = translate(
165
+ :"#{type}.formats.#{format}",
166
+ **options, locale:, object:, raise: true
167
+ )
168
+ end
169
+
170
+ format = expand_localization_format(locale, object, format)
171
+ object.strftime(format)
172
+ end
173
+
174
+ # @api public
175
+ # @since x.x.x
176
+ alias_method :l, :localize
177
+
178
+ # Returns true if a translation exists for the given key.
179
+ #
180
+ # @param key [String, Symbol] the translation key to check
181
+ # @param locale [Symbol, String, nil] the locale to check (defaults to current locale)
182
+ # @param options [Hash] additional options
183
+ #
184
+ # @return [Boolean] true if the translation exists, false otherwise
185
+ #
186
+ # @example
187
+ # exists?("hello") # => true
188
+ # exists?("missing.key") # => false
189
+ #
190
+ # @api public
191
+ # @since x.x.x
192
+ def exists?(key, locale: nil, **options)
193
+ locale ||= self.locale
194
+ @backend.exists?(locale, key, options)
195
+ end
196
+
197
+ # Transliterates the given string.
198
+ #
199
+ # @param key [String] the string to transliterate
200
+ # @param locale [Symbol, String, nil] the locale to use (defaults to current locale)
201
+ # @param replacement [String, nil] replacement string for non-transliteratable characters
202
+ # @param options [Hash] additional transliteration options
203
+ #
204
+ # @return [String] the transliterated string
205
+ #
206
+ # @example
207
+ # transliterate("Ærøskøbing") # => "AEroskobing"
208
+ #
209
+ # @api public
210
+ # @since x.x.x
211
+ def transliterate(key, locale: nil, replacement: nil, **options)
212
+ locale ||= self.locale
213
+ @backend.transliterate(locale, key, replacement)
214
+ end
215
+
216
+ # Returns available locales.
217
+ #
218
+ # If configured via `config.i18n.available_locales`, returns the configured locales.
219
+ # Otherwise, returns all locales detected from loaded translation files.
220
+ #
221
+ # @api public
222
+ # @since x.x.x
223
+ def available_locales
224
+ if @available_locales.any?
225
+ @available_locales
226
+ else
227
+ @backend.available_locales
228
+ end
229
+ end
230
+
231
+ # Reloads translations from translation files.
232
+ #
233
+ # @return [void]
234
+ #
235
+ # @api public
236
+ # @since x.x.x
237
+ def reload!
238
+ @backend.reload!
239
+ end
240
+
241
+ # Eager loads translations if the backend supports it.
242
+ #
243
+ # @return [void]
244
+ #
245
+ # @api public
246
+ # @since x.x.x
247
+ def eager_load!
248
+ @backend.eager_load! if @backend.respond_to?(:eager_load!)
249
+ end
250
+
251
+ # Returns the current locale from fiber/thread-local storage, or default_locale.
252
+ #
253
+ # This is thread-safe and works correctly with concurrent requests.
254
+ # Each backend instance maintains its own locale in thread storage.
255
+ #
256
+ # @return [Symbol] the current locale or default locale if none is set
257
+ #
258
+ # @example
259
+ # locale # => :en
260
+ # self.locale = :fr
261
+ # locale # => :fr
262
+ #
263
+ # @api public
264
+ # @since x.x.x
265
+ def locale
266
+ Thread.current[@storage_key] || default_locale
267
+ end
268
+
269
+ # Sets the current locale in fiber/thread-local storage.
270
+ #
271
+ # This is thread-safe and works correctly with concurrent requests.
272
+ # Each backend instance maintains its own locale in thread storage.
273
+ #
274
+ # @param value [Symbol, String, nil] the locale to set (converted to symbol)
275
+ #
276
+ # @return [Symbol, nil] the locale value that was set
277
+ #
278
+ # @example
279
+ # self.locale = :fr
280
+ # self.locale = "de"
281
+ # self.locale = nil # resets to default_locale
282
+ #
283
+ # @api public
284
+ # @since x.x.x
285
+ def locale=(value)
286
+ Thread.current[@storage_key] = value&.to_sym
287
+ end
288
+
289
+ # Executes a block with a temporary locale.
290
+ #
291
+ # This is useful for executing code with a specific locale without affecting
292
+ # other concurrent requests. The previous locale is restored even if the block raises.
293
+ #
294
+ # @param tmp_locale [Symbol, String, nil] the temporary locale to use
295
+ #
296
+ # @yieldreturn [Object] the return value of the block
297
+ #
298
+ # @return [Object] the return value of the block
299
+ #
300
+ # @example
301
+ # with_locale(:fr) do
302
+ # t("greeting") # Uses French locale
303
+ # end
304
+ # # locale is restored to previous value
305
+ #
306
+ # @example Nested usage
307
+ # with_locale(:fr) do
308
+ # with_locale(:de) do
309
+ # t("hello") # Uses German
310
+ # end
311
+ # t("hello") # Uses French
312
+ # end
313
+ #
314
+ # @api public
315
+ # @since x.x.x
316
+ def with_locale(tmp_locale)
317
+ previous_locale = Thread.current[@storage_key]
318
+ Thread.current[@storage_key] = tmp_locale&.to_sym
319
+ yield
320
+ ensure
321
+ Thread.current[@storage_key] = previous_locale
322
+ end
323
+
324
+ private
325
+
326
+ def handle_missing_translation(missing_translation, options)
327
+ default = options[:default]
328
+ return default if default.is_a?(String)
329
+
330
+ key = missing_translation.key
331
+ key.to_s
332
+ end
333
+
334
+ # rubocop:disable Layout/LineLength, Style/NestedTernaryOperator, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
335
+
336
+ # Expands locale-dependent strftime codes (`%a`, `%A`, `%b`, `%B`, `%p`, `%P` and their
337
+ # uppercase `^` variants) by looking up day/month names and meridiem markers through this
338
+ # backend.
339
+ #
340
+ # This is a deliberate port of `I18n::Backend::Base#translate_localization_format`. We can't
341
+ # call this upstream method directly because it's private and also hardcodes `I18n.t!` (the
342
+ # global module) for every lookup, which defeats our per-slice isolated backends.
343
+ #
344
+ # This set of locale-dependent strftime codes is fixed by the C `strftime` spec and has not
345
+ # changed in the upstream gem since at least 2010, so this table is effectively frozen.
346
+ def expand_localization_format(locale, object, format)
347
+ format.to_s.gsub(/%(|\^)[aAbBpP]/) do |match|
348
+ case match
349
+ when "%a" then t!(:"date.abbr_day_names", locale:, format:)[object.wday]
350
+ when "%^a" then t!(:"date.abbr_day_names", locale:, format:)[object.wday].upcase
351
+ when "%A" then t!(:"date.day_names", locale:, format:)[object.wday]
352
+ when "%^A" then t!(:"date.day_names", locale:, format:)[object.wday].upcase
353
+ when "%b" then t!(:"date.abbr_month_names", locale:, format:)[object.mon]
354
+ when "%^b" then t!(:"date.abbr_month_names", locale:, format:)[object.mon].upcase
355
+ when "%B" then t!(:"date.month_names", locale:, format:)[object.mon]
356
+ when "%^B" then t!(:"date.month_names", locale:, format:)[object.mon].upcase
357
+ when "%p" then t!(:"time.#{(object.respond_to?(:hour) ? object.hour : 0) < 12 ? :am : :pm}", locale:, format:).upcase
358
+ when "%P" then t!(:"time.#{(object.respond_to?(:hour) ? object.hour : 0) < 12 ? :am : :pm}", locale:, format:).downcase
359
+ end
360
+ end
361
+ rescue ::I18n::MissingTranslationData => exception
362
+ exception.message
363
+ end
364
+
365
+ # rubocop:enable Layout/LineLength, Style/NestedTernaryOperator, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
366
+ end
367
+ end
368
+ end
369
+ 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"
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Providers
5
+ # Registers the `"mailers.delivery_method"` component. This is an SMTP delivery method built
6
+ # from SMTP environment variables when present, otherwise the test delivery method.
7
+ #
8
+ # SMTP env vars may take a per-slice prefix derived from the slice name (e.g. an "admin" slice
9
+ # reads `ADMIN__SMTP_ADDRESS`, falling back to `SMTP_ADDRESS`). Register your own `:mailers`
10
+ # provider if you need to use another delivery method or different setup logic.
11
+ #
12
+ # In the test env, environment variables are ignored, and the test delivery method is always
13
+ # used, so the test suite can never send real email.
14
+ #
15
+ # In the production env, warns noisily when there is no SMTP configuration, before falling back
16
+ # to the test delivery method. This ensures an app whose mail setup is a work in progress can
17
+ # still boot.
18
+ #
19
+ # @api private
20
+ class Mailers < Hanami::Provider::Source
21
+ # Maps SMTP environment variable names to their compatible delivery option keys.
22
+ #
23
+ # Example values:
24
+ #
25
+ # SMTP_ADDRESS=smtp.example.com
26
+ # SMTP_PORT=587
27
+ # SMTP_USERNAME=postmaster@example.com
28
+ # SMTP_PASSWORD=s3cr3t
29
+ # SMTP_AUTHENTICATION=plain
30
+ SMTP_ENV_VARS = {
31
+ "SMTP_ADDRESS" => :address,
32
+ "SMTP_PORT" => :port,
33
+ "SMTP_USERNAME" => :user_name,
34
+ "SMTP_PASSWORD" => :password,
35
+ "SMTP_AUTHENTICATION" => :authentication
36
+ }.freeze
37
+
38
+ # Coercions applied to SMTP env var values, keyed by option. Options not listed here are
39
+ # passed through unchanged (as strings).
40
+ SMTP_COERCIONS = Hash.new(:itself.to_proc).update(
41
+ port: ->(value) { Integer(value) },
42
+ authentication: ->(value) { value.to_sym }
43
+ ).freeze
44
+
45
+ def start
46
+ require "hanami/mailer"
47
+
48
+ register "delivery_method", build_delivery_method
49
+ end
50
+
51
+ private
52
+
53
+ def build_delivery_method
54
+ return Hanami::Mailer::Delivery::Test.new if Hanami.env?(:test)
55
+
56
+ smtp_options = smtp_options_from_env
57
+
58
+ return Hanami::Mailer::Delivery::SMTP.new(**smtp_options) if smtp_options.key?(:address)
59
+
60
+ warn_missing_smtp if Hanami.env?(:production)
61
+
62
+ Hanami::Mailer::Delivery::Test.new
63
+ end
64
+
65
+ def smtp_options_from_env
66
+ SMTP_ENV_VARS.each_with_object({}) do |(var, option), options|
67
+ value = env_value(var)
68
+ next if value.nil?
69
+
70
+ coercion = SMTP_COERCIONS[option]
71
+ options[option] = coercion ? coercion.call(value) : value
72
+ end
73
+ end
74
+
75
+ # Reads an SMTP env var, preferring a per-slice prefixed name (e.g. `ADMIN__SMTP_ADDRESS`)
76
+ # and falling back to the unprefixed name shared across slices.
77
+ def env_value(var)
78
+ return ENV[var] if slice.app?
79
+
80
+ slice_prefixed_var = "#{slice.slice_name.name.gsub("/", "__").upcase}__#{var}"
81
+ ENV[slice_prefixed_var] || ENV[var]
82
+ end
83
+
84
+ def warn_missing_smtp
85
+ message = \
86
+ "No SMTP configuration found for #{slice.slice_name.name} in production; " \
87
+ "falling back to the test delivery method — mail will NOT be sent. " \
88
+ "Set SMTP_ADDRESS (and related SMTP_* variables), or register a custom :mailers provider."
89
+
90
+ slice.app["logger"].warn(message)
91
+ end
92
+ end
93
+
94
+ Dry::System.register_provider_source(
95
+ :mailers,
96
+ source: Mailers,
97
+ group: :hanami,
98
+ provider_options: {namespace: true}
99
+ )
100
+ end
101
+ end
data/lib/hanami/routes.rb CHANGED
@@ -137,6 +137,7 @@ module Hanami
137
137
  # @api private
138
138
  def method_missing(name, *args, **kwargs, &block)
139
139
  return super unless respond_to?(name)
140
+
140
141
  definitions << [name, args, kwargs, block]
141
142
  self
142
143
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/constants"
4
+
5
+ module Hanami
6
+ class Settings
7
+ # A settings store that chains multiple stores with fallback resolution.
8
+ #
9
+ # Each store is tried in order. The first store to return a value wins.
10
+ # Stores must implement `#fetch` with the same signature as `Hash#fetch`.
11
+ #
12
+ # @example
13
+ # # config/app.rb
14
+ # config.settings_store = Hanami::Settings::CompositeStore.new(
15
+ # Hanami::Settings::EnvStore.new,
16
+ # MyCustomStore.new
17
+ # )
18
+ #
19
+ # @api public
20
+ # @since x.x.x
21
+ class CompositeStore
22
+ # @api private
23
+ Undefined = Dry::Core::Constants::Undefined
24
+
25
+ # @param stores [Array<#fetch>] ordered list of stores to query
26
+ def initialize(*stores)
27
+ @stores = stores
28
+ end
29
+
30
+ # Fetches a value by trying each store in order.
31
+ #
32
+ # @param name [String, Symbol] the setting name
33
+ # @param args [Array] optional default value
34
+ # @yield [name] optional block for default value
35
+ # @return [Object] the setting value
36
+ # @raise [KeyError] if no store has the key and no default is given
37
+ #
38
+ # @api public
39
+ # @since x.x.x
40
+ def fetch(name, *args, &block)
41
+ @stores.each do |store|
42
+ value = store.fetch(name, Undefined)
43
+ return value unless value.equal?(Undefined)
44
+ end
45
+
46
+ return args.first unless args.empty?
47
+ return yield(name) if block
48
+
49
+ raise KeyError, "key not found: #{name.inspect}"
50
+ end
51
+ end
52
+ end
53
+ end