otto 2.0.0.pre10 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5e7fb7a7177b6488a5a3cdacc8e3a929d58f92d44fb03bb506effca3f6c3550
4
- data.tar.gz: 351a23a9c8e12aff50296cde108e73478dac7cf052d687deb5855f3ccb35cd79
3
+ metadata.gz: 8539a9a6889a7976aa6ceb87ea3d7d489cb82b65e283cb57d7d731e2f2247159
4
+ data.tar.gz: d5c025e966063e8f1eb618c1a6aa28782db50e7f55769f322b557a0be4725d4b
5
5
  SHA512:
6
- metadata.gz: 651edcf79562877a2046132ccc876f747545d1bed8f4821352af4c16f8e795bec5d02e82afcf27320b368af9e25853df3c71d26c4fa6b6f81878704322d0f29d
7
- data.tar.gz: e763f2d28e2ff4dd0d16a98ff8ab9eda6d495d32e7be10b31a1c21516a075fb1f49a44464eeaf66d92f701821ab3fce91ab72c25d18430efa70e65b2a435cb94
6
+ metadata.gz: 06dc04e8f9ada60160c87c48c32a31e34fbab8b29a0f1755fa9f7bf2878aebe7ea1491110018cfcdc2e689be8de41ec17ae2f413cc0f38ff9136c0d039104f78
7
+ data.tar.gz: c12c1236b295148a9f1f1b31f7fb22aeace22494703e49ec4c47421d7455f74ea0ebcb017019ebe799b5b930cd4a907d5bf6b7ed3f6e58bcac7920432d7ada90
@@ -71,7 +71,7 @@ jobs:
71
71
  continue-on-error: true
72
72
 
73
73
  - name: Upload Reek report as artifact
74
- uses: actions/upload-artifact@v5
74
+ uses: actions/upload-artifact@v6
75
75
  if: always()
76
76
  with:
77
77
  name: reek-report
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,21 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.0.0:
11
+
12
+ 2.0.0 — 2026-03-14
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Optional ``fallback_locale`` configuration for ``Otto::Locale::Middleware`` and ``Locale::Config``, enabling custom locale fallback chains between exact region match and primary code resolution
19
+
20
+ Fixed
21
+ -----
22
+
23
+ - Locale middleware now tries exact region match (``fr-FR`` → ``fr_FR``) before falling back to primary language code, fixing locale resolution for region-qualified ``available_locales`` entries (#117)
24
+
10
25
  .. _changelog-2.0.0.pre10:
11
26
 
12
27
  2.0.0.pre10 — 2025-12-09
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.0.pre10)
4
+ otto (2.0.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  facets (~> 3.1)
7
7
  ipaddr (~> 1, < 2.0)
@@ -16,7 +16,7 @@ GEM
16
16
  specs:
17
17
  ast (2.4.3)
18
18
  benchmark (0.5.0)
19
- bigdecimal (3.2.3)
19
+ bigdecimal (3.3.1)
20
20
  concurrent-ruby (1.3.5)
21
21
  crass (1.0.6)
22
22
  date (3.4.1)
@@ -63,7 +63,7 @@ GEM
63
63
  rdoc (>= 4.0.0)
64
64
  reline (>= 0.4.2)
65
65
  json (2.16.0)
66
- json_schemer (2.4.0)
66
+ json_schemer (2.5.0)
67
67
  bigdecimal
68
68
  hana (~> 1.3)
69
69
  regexp_parser (~> 2.0)
@@ -24,15 +24,17 @@ class Otto
24
24
  class Config
25
25
  include Otto::Core::Freezable
26
26
 
27
- attr_accessor :available_locales, :default_locale
27
+ attr_accessor :available_locales, :default_locale, :fallback_locale
28
28
 
29
29
  # Initialize locale configuration
30
30
  #
31
31
  # @param available_locales [Hash, nil] Hash of locale codes to names
32
32
  # @param default_locale [String, nil] Default locale code
33
- def initialize(available_locales: nil, default_locale: nil)
33
+ # @param fallback_locale [Hash, nil] Hash of locale codes to fallback chains
34
+ def initialize(available_locales: nil, default_locale: nil, fallback_locale: nil)
34
35
  @available_locales = available_locales
35
36
  @default_locale = default_locale
37
+ @fallback_locale = fallback_locale
36
38
  end
37
39
 
38
40
  # Convert to hash for compatibility with existing code
@@ -41,7 +43,8 @@ class Otto
41
43
  def to_h
42
44
  {
43
45
  available_locales: @available_locales,
44
- default_locale: @default_locale,
46
+ default_locale: @default_locale,
47
+ fallback_locale: @fallback_locale,
45
48
  }.compact
46
49
  end
47
50
 
@@ -37,11 +37,13 @@ class Otto
37
37
  # @param app [#call] Rack application
38
38
  # @param available_locales [Hash<String, String>] Hash of locale codes to language names
39
39
  # @param default_locale [String] Default locale code
40
+ # @param fallback_locale [Hash, nil] Hash of locale codes to fallback chains
40
41
  # @param debug [Boolean] Enable debug logging
41
- def initialize(app, available_locales:, default_locale:, debug: false)
42
+ def initialize(app, available_locales:, default_locale:, fallback_locale: nil, debug: false)
42
43
  @app = app
43
44
  @available_locales = available_locales
44
45
  @default_locale = default_locale
46
+ @fallback_locale = fallback_locale
45
47
  @debug = debug
46
48
 
47
49
  validate_config!
@@ -90,7 +92,13 @@ class Otto
90
92
  # Handles formats like:
91
93
  # - "en-US,en;q=0.9,fr;q=0.8" → finds first available from [en, en, fr]
92
94
  # - "es,en;q=0.9" → returns "en" if "es" unavailable but "en" is
93
- # - "fr-CA" → "fr"
95
+ # - "fr-FR" → "fr_FR" (exact match with region) or "fr" (fallback)
96
+ # - "pt-BR,en;q=0.9" → "pt_BR" if available
97
+ #
98
+ # Resolution order per tag (via resolve_locale):
99
+ # 1. Exact match with region (fr-FR → fr_FR)
100
+ # 2. Fallback chain (if configured)
101
+ # 3. Primary language code (fr-FR → fr)
94
102
  #
95
103
  # Respects q-values (quality factors) and returns the highest-priority
96
104
  # available locale instead of just the first language tag.
@@ -102,20 +110,19 @@ class Otto
102
110
 
103
111
  # Parse all language tags with their q-values
104
112
  # Format: "en-US,en;q=0.9,fr;q=0.8" → [[en-US, 1.0], [en, 0.9], [fr, 0.8]]
105
- languages = header.split(',').map do |tag|
113
+ languages = header.split(',').each_with_index.map do |tag, idx|
106
114
  # Split on semicolon and extract q-value
107
115
  parts = tag.strip.split(/\s*;\s*q\s*=\s*/)
108
116
  locale_str = parts[0]
109
117
  q_value = parts[1] ? parts[1].to_f : 1.0
110
- [locale_str, q_value]
118
+ [locale_str, q_value, idx]
111
119
  end
112
120
 
113
- # Sort by q-value descending (highest preference first)
114
- # and find the first locale that matches available_locales
115
- languages.sort_by { |_, q| -q }.each do |lang_tag, _|
116
- # Extract primary language code: "en-US" → "en", "fr" → "fr"
117
- locale_code = lang_tag.split('-').first.downcase
118
- return locale_code if valid_locale?(locale_code)
121
+ # Sort by q-value descending (highest preference first),
122
+ # using original header position as tiebreaker for stable ordering
123
+ languages.sort_by { |_, q, i| [-q, i] }.each do |lang_tag, _, _|
124
+ resolved = resolve_locale(lang_tag)
125
+ return resolved if resolved
119
126
  end
120
127
 
121
128
  nil # No matching locale found
@@ -124,6 +131,61 @@ class Otto
124
131
  nil
125
132
  end
126
133
 
134
+ # Resolve a single language tag against available locales
135
+ #
136
+ # Resolution order:
137
+ # 1. Exact match with region (fr-FR → fr_FR)
138
+ # 2. Fallback chain (if configured)
139
+ # 3. Primary language code (fr-FR → fr)
140
+ #
141
+ # @param lang_tag [String] BCP 47 language tag (e.g. "fr-FR", "en")
142
+ # @return [String, nil] Matched locale code or nil
143
+ def resolve_locale(lang_tag)
144
+ # Step 1: Exact match — convert HTTP format to locale format (fr-FR → fr_FR)
145
+ normalized = lang_tag.strip.tr('-', '_')
146
+ return normalized if valid_locale?(normalized)
147
+
148
+ downcased = normalized.downcase
149
+ return downcased if downcased != normalized && valid_locale?(downcased)
150
+
151
+ # Step 1b: Try BCP 47 canonical form (lowercase language, uppercase region)
152
+ parts = normalized.split('_', 2)
153
+ if parts.length == 2
154
+ canonical = "#{parts[0].downcase}_#{parts[1].upcase}"
155
+ return canonical if canonical != normalized && canonical != downcased && valid_locale?(canonical)
156
+ end
157
+
158
+ # Step 2: Fallback chain (if configured)
159
+ resolved = resolve_fallback_chain(normalized)
160
+ return resolved if resolved
161
+
162
+ # Step 3: Primary language code (fr-FR → fr)
163
+ primary = lang_tag.split('-').first.downcase
164
+ return primary if valid_locale?(primary)
165
+
166
+ nil
167
+ end
168
+
169
+ # Consult fallback chain for a normalized locale tag
170
+ #
171
+ # @param normalized [String] Normalized locale tag (e.g. "fr_FR")
172
+ # @return [String, nil] First matching fallback locale or nil
173
+ def resolve_fallback_chain(normalized)
174
+ return nil unless @fallback_locale
175
+
176
+ chain = @fallback_locale[normalized] || @fallback_locale[normalized.downcase]
177
+ if !chain
178
+ parts = normalized.split('_', 2)
179
+ if parts.length == 2
180
+ canonical = "#{parts[0].downcase}_#{parts[1].upcase}"
181
+ chain = @fallback_locale[canonical]
182
+ end
183
+ end
184
+ return nil unless chain
185
+
186
+ chain.find { |fallback| valid_locale?(fallback) }
187
+ end
188
+
127
189
  # Check if locale is valid
128
190
  #
129
191
  # @param locale [String, nil] Locale code to validate
@@ -140,6 +202,12 @@ class Otto
140
202
  raise ArgumentError, 'available_locales must be a Hash' unless @available_locales.is_a?(Hash)
141
203
  raise ArgumentError, 'available_locales cannot be empty' if @available_locales.empty?
142
204
  raise ArgumentError, 'default_locale must be in available_locales' unless @available_locales.key?(@default_locale)
205
+ return unless @fallback_locale
206
+
207
+ raise ArgumentError, 'fallback_locale must be a Hash' unless @fallback_locale.is_a?(Hash)
208
+ @fallback_locale.each do |key, chain|
209
+ raise ArgumentError, "fallback_locale values must be Arrays, got #{chain.class} for key '#{key}'" unless chain.is_a?(Array)
210
+ end
143
211
  end
144
212
 
145
213
  # Log debug information about locale detection
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre10'
6
+ VERSION = '2.0.0'
7
7
  end
data/otto.gemspec CHANGED
@@ -5,7 +5,7 @@ require_relative 'lib/otto/version'
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'otto'
7
7
  spec.version = Otto::VERSION.to_s
8
- spec.summary = 'Auto-define your rack-apps in plaintext.'
8
+ spec.summary = 'Define your rack-apps in plaintext.'
9
9
  spec.description = "Otto: #{spec.summary}"
10
10
  spec.email = 'gems@solutious.com'
11
11
  spec.authors = ['Delano Mandelbaum']
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre10
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -145,7 +145,7 @@ dependencies:
145
145
  - - "~>"
146
146
  - !ruby/object:Gem::Version
147
147
  version: '2.20'
148
- description: 'Otto: Auto-define your rack-apps in plaintext.'
148
+ description: 'Otto: Define your rack-apps in plaintext.'
149
149
  email: gems@solutious.com
150
150
  executables: []
151
151
  extensions: []
@@ -346,5 +346,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
346
346
  requirements: []
347
347
  rubygems_version: 3.7.2
348
348
  specification_version: 4
349
- summary: Auto-define your rack-apps in plaintext.
349
+ summary: Define your rack-apps in plaintext.
350
350
  test_files: []