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 +4 -4
- data/.github/workflows/code-smells.yml +1 -1
- data/CHANGELOG.rst +15 -0
- data/Gemfile.lock +3 -3
- data/lib/otto/locale/config.rb +6 -3
- data/lib/otto/locale/middleware.rb +78 -10
- data/lib/otto/version.rb +1 -1
- data/otto.gemspec +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8539a9a6889a7976aa6ceb87ea3d7d489cb82b65e283cb57d7d731e2f2247159
|
|
4
|
+
data.tar.gz: d5c025e966063e8f1eb618c1a6aa28782db50e7f55769f322b557a0be4725d4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06dc04e8f9ada60160c87c48c32a31e34fbab8b29a0f1755fa9f7bf2878aebe7ea1491110018cfcdc2e689be8de41ec17ae2f413cc0f38ff9136c0d039104f78
|
|
7
|
+
data.tar.gz: c12c1236b295148a9f1f1b31f7fb22aeace22494703e49ec4c47421d7455f74ea0ebcb017019ebe799b5b930cd4a907d5bf6b7ed3f6e58bcac7920432d7ada90
|
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
|
|
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.
|
|
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.
|
|
66
|
+
json_schemer (2.5.0)
|
|
67
67
|
bigdecimal
|
|
68
68
|
hana (~> 1.3)
|
|
69
69
|
regexp_parser (~> 2.0)
|
data/lib/otto/locale/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
#
|
|
115
|
-
languages.sort_by { |_, q| -q }.each do |lang_tag, _|
|
|
116
|
-
|
|
117
|
-
|
|
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
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 = '
|
|
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
|
|
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:
|
|
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:
|
|
349
|
+
summary: Define your rack-apps in plaintext.
|
|
350
350
|
test_files: []
|