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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -19
- data/LICENSE +20 -0
- data/README.md +18 -35
- data/hanami.gemspec +36 -37
- data/lib/hanami/config/db.rb +2 -0
- data/lib/hanami/config/i18n.rb +138 -0
- data/lib/hanami/config/logger.rb +15 -7
- data/lib/hanami/config/null_config.rb +1 -1
- data/lib/hanami/config/views.rb +17 -0
- data/lib/hanami/config.rb +66 -22
- data/lib/hanami/errors.rb +6 -0
- data/lib/hanami/extensions/action/slice_configured_action.rb +1 -1
- data/lib/hanami/extensions/action.rb +2 -2
- data/lib/hanami/extensions/mailer/slice_configured_mailer.rb +120 -0
- data/lib/hanami/extensions/mailer.rb +28 -0
- data/lib/hanami/extensions/operation/slice_configured_db_operation.rb +2 -0
- data/lib/hanami/extensions/view/context.rb +26 -4
- data/lib/hanami/extensions/view/part.rb +2 -0
- data/lib/hanami/extensions/view/slice_configured_context.rb +7 -0
- data/lib/hanami/extensions/view/slice_configured_part.rb +2 -0
- data/lib/hanami/extensions/view/slice_configured_view.rb +8 -8
- data/lib/hanami/extensions/view/standard_helpers.rb +4 -0
- data/lib/hanami/extensions.rb +6 -1
- data/lib/hanami/helpers/assets_helper.rb +0 -4
- data/lib/hanami/helpers/form_helper.rb +1 -1
- data/lib/hanami/helpers/i18n_helper.rb +176 -0
- data/lib/hanami/logger/rack_formatter.rb +73 -0
- data/lib/hanami/logger/sql_formatter.rb +80 -0
- data/lib/hanami/logger/sql_logger.rb +48 -0
- data/lib/hanami/middleware/render_errors.rb +2 -2
- data/lib/hanami/providers/db.rb +7 -2
- data/lib/hanami/providers/db_logging.rb +4 -7
- data/lib/hanami/providers/i18n/backend.rb +369 -0
- data/lib/hanami/providers/i18n/locale/en.yml +57 -0
- data/lib/hanami/providers/i18n.rb +114 -0
- data/lib/hanami/providers/mailers.rb +101 -0
- data/lib/hanami/routes.rb +1 -0
- data/lib/hanami/settings/composite_store.rb +53 -0
- data/lib/hanami/settings.rb +4 -4
- data/lib/hanami/slice/router.rb +15 -10
- data/lib/hanami/slice.rb +71 -11
- data/lib/hanami/slice_registrar.rb +2 -2
- data/lib/hanami/universal_logger.rb +250 -0
- data/lib/hanami/version.rb +1 -1
- data/lib/hanami/web/rack_logger.rb +2 -80
- data/lib/hanami/web/welcome.html.erb +443 -58
- data/lib/hanami.rb +4 -2
- metadata +28 -276
- data/CODE_OF_CONDUCT.md +0 -74
- data/FEATURES.md +0 -269
- data/LICENSE.md +0 -22
- data/spec/integration/action/cookies_spec.rb +0 -58
- data/spec/integration/action/csrf_protection_spec.rb +0 -54
- data/spec/integration/action/format_config_spec.rb +0 -129
- data/spec/integration/action/routes_spec.rb +0 -71
- data/spec/integration/action/sessions_spec.rb +0 -50
- data/spec/integration/action/slice_configuration_spec.rb +0 -284
- data/spec/integration/action/view_rendering/automatic_rendering_spec.rb +0 -247
- data/spec/integration/action/view_rendering/paired_view_inference_spec.rb +0 -115
- data/spec/integration/action/view_rendering/view_context_spec.rb +0 -221
- data/spec/integration/action/view_rendering_spec.rb +0 -89
- data/spec/integration/assets/assets_spec.rb +0 -155
- data/spec/integration/assets/cross_slice_assets_helpers_spec.rb +0 -129
- data/spec/integration/assets/serve_static_assets_spec.rb +0 -152
- data/spec/integration/code_loading/loading_from_app_spec.rb +0 -152
- data/spec/integration/code_loading/loading_from_lib_spec.rb +0 -242
- data/spec/integration/code_loading/loading_from_slice_spec.rb +0 -165
- data/spec/integration/container/application_routes_helper_spec.rb +0 -48
- data/spec/integration/container/auto_injection_spec.rb +0 -53
- data/spec/integration/container/auto_registration_spec.rb +0 -86
- data/spec/integration/container/autoloader_spec.rb +0 -82
- data/spec/integration/container/imports_spec.rb +0 -253
- data/spec/integration/container/prepare_container_spec.rb +0 -125
- data/spec/integration/container/provider_environment_spec.rb +0 -52
- data/spec/integration/container/provider_lifecycle_spec.rb +0 -61
- data/spec/integration/container/shutdown_spec.rb +0 -91
- data/spec/integration/container/standard_providers/rack_provider_spec.rb +0 -44
- data/spec/integration/container/standard_providers_spec.rb +0 -124
- data/spec/integration/db/auto_registration_spec.rb +0 -39
- data/spec/integration/db/commands_spec.rb +0 -80
- data/spec/integration/db/db_inflector_spec.rb +0 -57
- data/spec/integration/db/db_slices_spec.rb +0 -398
- data/spec/integration/db/db_spec.rb +0 -245
- data/spec/integration/db/gateways_spec.rb +0 -361
- data/spec/integration/db/logging_spec.rb +0 -301
- data/spec/integration/db/mappers_spec.rb +0 -84
- data/spec/integration/db/provider_config_spec.rb +0 -88
- data/spec/integration/db/provider_spec.rb +0 -35
- data/spec/integration/db/relations_spec.rb +0 -60
- data/spec/integration/db/repo_spec.rb +0 -300
- data/spec/integration/db/slices_importing_from_parent.rb +0 -130
- data/spec/integration/dotenv_loading_spec.rb +0 -138
- data/spec/integration/logging/exception_logging_spec.rb +0 -120
- data/spec/integration/logging/notifications_spec.rb +0 -68
- data/spec/integration/logging/request_logging_spec.rb +0 -202
- data/spec/integration/operations/extension_spec.rb +0 -122
- data/spec/integration/rack_app/body_parser_spec.rb +0 -108
- data/spec/integration/rack_app/method_override_spec.rb +0 -97
- data/spec/integration/rack_app/middleware_spec.rb +0 -720
- data/spec/integration/rack_app/non_booted_rack_app_spec.rb +0 -104
- data/spec/integration/rack_app/rack_app_spec.rb +0 -442
- data/spec/integration/rake_tasks_spec.rb +0 -107
- data/spec/integration/router/resource_routes_spec.rb +0 -281
- data/spec/integration/settings/access_in_slice_class_body_spec.rb +0 -83
- data/spec/integration/settings/access_to_constants_spec.rb +0 -46
- data/spec/integration/settings/loading_from_env_spec.rb +0 -188
- data/spec/integration/settings/settings_component_loading_spec.rb +0 -113
- data/spec/integration/settings/slice_registration_spec.rb +0 -145
- data/spec/integration/settings/using_types_spec.rb +0 -80
- data/spec/integration/setup_spec.rb +0 -165
- data/spec/integration/slices/external_slice_spec.rb +0 -91
- data/spec/integration/slices/slice_configuration_spec.rb +0 -42
- data/spec/integration/slices/slice_loading_spec.rb +0 -171
- data/spec/integration/slices/slice_registrations_spec.rb +0 -80
- data/spec/integration/slices/slice_routing_spec.rb +0 -219
- data/spec/integration/slices_spec.rb +0 -471
- data/spec/integration/view/config/default_context_spec.rb +0 -149
- data/spec/integration/view/config/inflector_spec.rb +0 -57
- data/spec/integration/view/config/part_class_spec.rb +0 -147
- data/spec/integration/view/config/part_namespace_spec.rb +0 -103
- data/spec/integration/view/config/paths_spec.rb +0 -119
- data/spec/integration/view/config/scope_class_spec.rb +0 -147
- data/spec/integration/view/config/scope_namespace_spec.rb +0 -103
- data/spec/integration/view/config/template_spec.rb +0 -38
- data/spec/integration/view/context/assets_spec.rb +0 -79
- data/spec/integration/view/context/inflector_spec.rb +0 -40
- data/spec/integration/view/context/request_spec.rb +0 -57
- data/spec/integration/view/context/routes_spec.rb +0 -84
- data/spec/integration/view/helpers/form_helper_spec.rb +0 -174
- data/spec/integration/view/helpers/part_helpers_spec.rb +0 -124
- data/spec/integration/view/helpers/scope_helpers_spec.rb +0 -84
- data/spec/integration/view/helpers/user_defined_helpers/part_helpers_spec.rb +0 -162
- data/spec/integration/view/helpers/user_defined_helpers/scope_helpers_spec.rb +0 -119
- data/spec/integration/view/parts/default_rendering_spec.rb +0 -138
- data/spec/integration/view/slice_configuration_spec.rb +0 -289
- data/spec/integration/view/views_spec.rb +0 -103
- data/spec/integration/web/content_security_policy_nonce_spec.rb +0 -251
- data/spec/integration/web/render_detailed_errors_spec.rb +0 -107
- data/spec/integration/web/render_errors_spec.rb +0 -242
- data/spec/integration/web/welcome_view_spec.rb +0 -84
- data/spec/spec_helper.rb +0 -28
- data/spec/support/app_integration.rb +0 -157
- data/spec/support/coverage.rb +0 -1
- data/spec/support/matchers.rb +0 -32
- data/spec/support/rspec.rb +0 -27
- data/spec/unit/hanami/config/actions/content_security_policy_spec.rb +0 -96
- data/spec/unit/hanami/config/actions/cookies_spec.rb +0 -46
- data/spec/unit/hanami/config/actions/csrf_protection_spec.rb +0 -58
- data/spec/unit/hanami/config/actions/default_values_spec.rb +0 -43
- data/spec/unit/hanami/config/actions/sessions_spec.rb +0 -48
- data/spec/unit/hanami/config/actions_spec.rb +0 -52
- data/spec/unit/hanami/config/base_url_spec.rb +0 -25
- data/spec/unit/hanami/config/console_spec.rb +0 -22
- data/spec/unit/hanami/config/db_spec.rb +0 -38
- data/spec/unit/hanami/config/inflector_spec.rb +0 -35
- data/spec/unit/hanami/config/logger_spec.rb +0 -195
- data/spec/unit/hanami/config/render_detailed_errors_spec.rb +0 -25
- data/spec/unit/hanami/config/render_errors_spec.rb +0 -25
- data/spec/unit/hanami/config/router_spec.rb +0 -44
- data/spec/unit/hanami/config/slices_spec.rb +0 -34
- data/spec/unit/hanami/config/views_spec.rb +0 -80
- data/spec/unit/hanami/env_spec.rb +0 -37
- data/spec/unit/hanami/extensions/view/context_spec.rb +0 -59
- data/spec/unit/hanami/helpers/assets_helper/asset_url_spec.rb +0 -120
- data/spec/unit/hanami/helpers/assets_helper/audio_tag_spec.rb +0 -132
- data/spec/unit/hanami/helpers/assets_helper/favicon_tag_spec.rb +0 -87
- data/spec/unit/hanami/helpers/assets_helper/image_tag_spec.rb +0 -92
- data/spec/unit/hanami/helpers/assets_helper/javascript_tag_spec.rb +0 -143
- data/spec/unit/hanami/helpers/assets_helper/stylesheet_tag_spec.rb +0 -126
- data/spec/unit/hanami/helpers/assets_helper/video_tag_spec.rb +0 -136
- data/spec/unit/hanami/helpers/form_helper_spec.rb +0 -2857
- data/spec/unit/hanami/port_spec.rb +0 -117
- data/spec/unit/hanami/providers/db/config/default_config_spec.rb +0 -100
- data/spec/unit/hanami/providers/db/config/gateway_spec.rb +0 -73
- data/spec/unit/hanami/providers/db/config_spec.rb +0 -143
- data/spec/unit/hanami/router/errors/not_allowed_error_spec.rb +0 -27
- data/spec/unit/hanami/router/errors/not_found_error_spec.rb +0 -22
- data/spec/unit/hanami/settings/env_store_spec.rb +0 -52
- data/spec/unit/hanami/settings_spec.rb +0 -111
- data/spec/unit/hanami/slice_configurable_spec.rb +0 -141
- data/spec/unit/hanami/slice_name_spec.rb +0 -47
- data/spec/unit/hanami/slice_spec.rb +0 -99
- 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
|
@@ -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
|