rack-icu4x-locale 0.5.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/lib/rack/icu4x/locale/detector/cookie.rb +42 -0
- data/lib/rack/icu4x/locale/detector/header.rb +40 -0
- data/lib/rack/icu4x/locale/detector/query.rb +45 -0
- data/lib/rack/icu4x/locale/detector.rb +65 -0
- data/lib/rack/icu4x/locale/negotiator.rb +86 -0
- data/lib/rack/icu4x/locale/version.rb +10 -0
- data/lib/rack/icu4x/locale.rb +105 -0
- data/sig/rack/icu4x/locale/detector/cookie.rbs +20 -0
- data/sig/rack/icu4x/locale/detector/header.rbs +17 -0
- data/sig/rack/icu4x/locale/detector/query.rbs +20 -0
- data/sig/rack/icu4x/locale/detector.rbs +27 -0
- data/sig/rack/icu4x/locale/negotiator.rbs +29 -0
- data/sig/rack/icu4x/locale.rbs +41 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e9e8d904fcd52b6cff5640053e45c2d75f3cc07a4a0c9db6acfdf402853cdaf4
|
|
4
|
+
data.tar.gz: 8e1ebf2cf313f66211ae47a671e808f09a58d7de2a27b0dd47ddf3d367962893
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a22beb45bcf4fa131cd12cd48b760e438e2b10719a05182388311e6cd5241486cf4c862bca0d8d72a03f2d42b604321641964e9adcd82ac931f8117246e868c8
|
|
7
|
+
data.tar.gz: c21cf1f3797a084f08b92fe9bd8d07d30b349bc454e548c6ad2aa06d0ab4e271e8c8cd106a22136e07ddf72d0a38a1999773f7435664a9edfc4f77edd7d2eece
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.5.0] - 2026-01-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Pluggable detector system for locale sources (#10)
|
|
8
|
+
- Locale detection from query parameters, cookies, and Accept-Language header
|
|
9
|
+
- Script-safe language negotiation using ICU4X's maximize
|
|
10
|
+
- Sinatra demo application in `examples/`
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OZAWA Sakuro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Rack::ICU4X::Locale
|
|
2
|
+
|
|
3
|
+
Rack middleware for locale detection using ICU4X. Detects user's preferred locales from various sources (query parameters, cookies, Accept-Language header), with script-safe language negotiation.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem 'rack-icu4x-locale'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Ruby 3.2+
|
|
14
|
+
- Rack 3.0+
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### Basic (Accept-Language header only)
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
use Rack::ICU4X::Locale, locales: %w[en ja de fr]
|
|
22
|
+
|
|
23
|
+
run ->(env) {
|
|
24
|
+
locales = env["rack.icu4x.locale"]
|
|
25
|
+
[200, {}, ["Locale: #{locales.first}"]]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### With Multiple Detectors
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
use Rack::ICU4X::Locale,
|
|
33
|
+
locales: %w[en ja de fr],
|
|
34
|
+
detectors: [
|
|
35
|
+
{query: "lang"}, # ?lang=ja
|
|
36
|
+
{cookie: "locale"}, # Cookie: locale=ja
|
|
37
|
+
:header # Accept-Language header
|
|
38
|
+
],
|
|
39
|
+
default: "en" # optional: fallback locale when no match
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### With Custom Detector
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
use Rack::ICU4X::Locale,
|
|
46
|
+
locales: %w[en ja de fr],
|
|
47
|
+
detectors: [
|
|
48
|
+
->(env) { env["rack.session"]&.[]("locale") },
|
|
49
|
+
:header
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Detector Types
|
|
54
|
+
|
|
55
|
+
| Type | Example | Description |
|
|
56
|
+
|------|---------|-------------|
|
|
57
|
+
| `:header` | `Accept-Language: ja` | Accept-Language header |
|
|
58
|
+
| `{cookie: "name"}` | `Cookie: name=ja` | Cookie value |
|
|
59
|
+
| `{query: "param"}` | `?param=ja` | Query string parameter |
|
|
60
|
+
| `Proc` | `->(env) { ... }` | Custom detection logic |
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
See [doc/specification.md](doc/specification.md) for detailed specification.
|
|
65
|
+
|
|
66
|
+
## Demo
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd examples
|
|
70
|
+
bundle install
|
|
71
|
+
bundle exec rackup
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Open http://localhost:9292
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
module ICU4X
|
|
5
|
+
class Locale
|
|
6
|
+
module Detector
|
|
7
|
+
# Detects locale from a cookie value.
|
|
8
|
+
#
|
|
9
|
+
# @example With default cookie name
|
|
10
|
+
# detector = Cookie.new
|
|
11
|
+
# env = { "HTTP_COOKIE" => "locale=ja" }
|
|
12
|
+
# detector.call(env) # => "ja"
|
|
13
|
+
#
|
|
14
|
+
# @example With custom cookie name
|
|
15
|
+
# detector = Cookie.new("user_locale")
|
|
16
|
+
# env = { "HTTP_COOKIE" => "user_locale=en" }
|
|
17
|
+
# detector.call(env) # => "en"
|
|
18
|
+
class Cookie
|
|
19
|
+
DEFAULT_NAME = "locale"
|
|
20
|
+
private_constant :DEFAULT_NAME
|
|
21
|
+
|
|
22
|
+
# @param name [String] Cookie name to read locale from
|
|
23
|
+
def initialize(name=DEFAULT_NAME)
|
|
24
|
+
@name = name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param env [Hash] Rack environment
|
|
28
|
+
# @return [String, nil] Locale from cookie, or nil if not present
|
|
29
|
+
def call(env)
|
|
30
|
+
cookies = ::Rack::Utils.parse_cookies(env)
|
|
31
|
+
locale = cookies[@name]
|
|
32
|
+
return nil if locale.nil? || locale.empty?
|
|
33
|
+
|
|
34
|
+
locale
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private attr_reader :name
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
module ICU4X
|
|
5
|
+
class Locale
|
|
6
|
+
module Detector
|
|
7
|
+
# Detects locales from the Accept-Language HTTP header.
|
|
8
|
+
#
|
|
9
|
+
# Returns locales sorted by quality value in descending order.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# detector = Header.new
|
|
13
|
+
# env = { "HTTP_ACCEPT_LANGUAGE" => "ja,en;q=0.9,de;q=0.8" }
|
|
14
|
+
# detector.call(env) # => ["ja", "en", "de"]
|
|
15
|
+
class Header
|
|
16
|
+
# @param env [Hash] Rack environment
|
|
17
|
+
# @return [Array<String>, nil] Locales sorted by quality value, or nil if header is missing
|
|
18
|
+
def call(env)
|
|
19
|
+
header = env["HTTP_ACCEPT_LANGUAGE"]
|
|
20
|
+
return nil if header.nil? || header.empty?
|
|
21
|
+
|
|
22
|
+
parse_accept_language(header)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private def parse_accept_language(header)
|
|
26
|
+
header.split(",")
|
|
27
|
+
.map {|part| parse_entry(part) }
|
|
28
|
+
.sort_by {|_, quality| -quality }
|
|
29
|
+
.map(&:first)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private def parse_entry(part)
|
|
33
|
+
locale, quality = part.strip.split(";q=")
|
|
34
|
+
[locale.strip, Float(quality || "1")]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
module ICU4X
|
|
5
|
+
class Locale
|
|
6
|
+
module Detector
|
|
7
|
+
# Detects locale from a query string parameter.
|
|
8
|
+
#
|
|
9
|
+
# @example With default parameter name
|
|
10
|
+
# detector = Query.new
|
|
11
|
+
# env = Rack::MockRequest.env_for("/?locale=ja")
|
|
12
|
+
# detector.call(env) # => "ja"
|
|
13
|
+
#
|
|
14
|
+
# @example With custom parameter name
|
|
15
|
+
# detector = Query.new("lang")
|
|
16
|
+
# env = Rack::MockRequest.env_for("/?lang=en")
|
|
17
|
+
# detector.call(env) # => "en"
|
|
18
|
+
class Query
|
|
19
|
+
DEFAULT_PARAM = "locale"
|
|
20
|
+
private_constant :DEFAULT_PARAM
|
|
21
|
+
|
|
22
|
+
# @param param [String] Query parameter name to read locale from
|
|
23
|
+
def initialize(param=DEFAULT_PARAM)
|
|
24
|
+
@param = param
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param env [Hash] Rack environment
|
|
28
|
+
# @return [String, nil] Locale from query parameter, or nil if not present
|
|
29
|
+
def call(env)
|
|
30
|
+
query_string = env["QUERY_STRING"]
|
|
31
|
+
return nil if query_string.nil? || query_string.empty?
|
|
32
|
+
|
|
33
|
+
params = ::Rack::Utils.parse_query(query_string)
|
|
34
|
+
locale = params[@param]
|
|
35
|
+
return nil if locale.nil? || locale.empty?
|
|
36
|
+
|
|
37
|
+
locale
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private attr_reader :param
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
module ICU4X
|
|
5
|
+
class Locale
|
|
6
|
+
# Factory module for building detector instances from various specification formats.
|
|
7
|
+
#
|
|
8
|
+
# Detectors are responsible for extracting locale preferences from the Rack environment.
|
|
9
|
+
# All detectors respond to `#call(env)` and return:
|
|
10
|
+
# - String: single locale (e.g., "ja")
|
|
11
|
+
# - Array<String>: multiple locales in preference order (e.g., ["ja", "en"])
|
|
12
|
+
# - nil: no locale detected
|
|
13
|
+
#
|
|
14
|
+
# @example Building detectors from different specification formats
|
|
15
|
+
# Detector.build(:header) # => Detector::Header.new
|
|
16
|
+
# Detector.build({ cookie: "locale" }) # => Detector::Cookie.new("locale")
|
|
17
|
+
# Detector.build(->(env) { "ja" }) # => the Proc itself
|
|
18
|
+
module Detector
|
|
19
|
+
class InvalidSpecificationError < Error; end
|
|
20
|
+
|
|
21
|
+
INFLECTOR = Zeitwerk::Inflector.new
|
|
22
|
+
private_constant :INFLECTOR
|
|
23
|
+
|
|
24
|
+
# Build a detector from various specification formats.
|
|
25
|
+
#
|
|
26
|
+
# @param spec [Symbol, Hash, Proc, #call] Detector specification
|
|
27
|
+
# @return [#call] Detector instance responding to #call(env)
|
|
28
|
+
# @raise [InvalidSpecificationError] if the specification is invalid
|
|
29
|
+
def self.build(spec)
|
|
30
|
+
case spec
|
|
31
|
+
when Symbol then build_from_symbol(spec)
|
|
32
|
+
when Hash then build_from_hash(spec)
|
|
33
|
+
when Proc then spec
|
|
34
|
+
else
|
|
35
|
+
validate_callable!(spec)
|
|
36
|
+
spec
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method def self.build_from_symbol(symbol)
|
|
41
|
+
class_name = INFLECTOR.camelize(symbol.to_s, nil)
|
|
42
|
+
const_get(class_name).new
|
|
43
|
+
rescue NameError
|
|
44
|
+
raise InvalidSpecificationError, "Unknown detector: #{symbol.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private_class_method def self.build_from_hash(hash)
|
|
48
|
+
raise InvalidSpecificationError, "Hash must have exactly one key" unless hash.size == 1
|
|
49
|
+
|
|
50
|
+
type, arg = hash.first
|
|
51
|
+
class_name = INFLECTOR.camelize(type.to_s, nil)
|
|
52
|
+
const_get(class_name).new(arg)
|
|
53
|
+
rescue NameError
|
|
54
|
+
raise InvalidSpecificationError, "Unknown detector type: #{type.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method def self.validate_callable!(obj)
|
|
58
|
+
return if obj.respond_to?(:call)
|
|
59
|
+
|
|
60
|
+
raise InvalidSpecificationError, "Detector must respond to #call: #{obj.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rack
|
|
4
|
+
module ICU4X
|
|
5
|
+
class Locale
|
|
6
|
+
# Performs script-safe language negotiation using ICU4X's maximize.
|
|
7
|
+
#
|
|
8
|
+
# Matches requested locales against available locales, respecting script differences
|
|
9
|
+
# to avoid politically sensitive fallbacks (e.g., zh-TW won't match zh-CN).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# locales = %w[en-US en-GB ja].map { |s| ICU4X::Locale.parse(s) }
|
|
13
|
+
# negotiator = Rack::ICU4X::Locale::Negotiator.new(locales)
|
|
14
|
+
# negotiator.negotiate(%w[en-AU ja-JP]) # => ["en-US", "ja"]
|
|
15
|
+
class Negotiator
|
|
16
|
+
# @param available_locales [Array<ICU4X::Locale>] List of available locales
|
|
17
|
+
def initialize(available_locales)
|
|
18
|
+
@available = build_available_index(available_locales)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Negotiate locales, returning all matches in preference order.
|
|
22
|
+
#
|
|
23
|
+
# @param requested_locales [Array<String>] Requested locale identifiers in preference order
|
|
24
|
+
# @yield [String] Invalid locale string that could not be parsed
|
|
25
|
+
# @return [Array<String>] Matched locale identifiers
|
|
26
|
+
def negotiate(requested_locales)
|
|
27
|
+
matched = []
|
|
28
|
+
remaining = @available.dup
|
|
29
|
+
|
|
30
|
+
requested_locales.each do |req_str|
|
|
31
|
+
req_max = maximize_locale(req_str)
|
|
32
|
+
|
|
33
|
+
# 1. Exact match (language + script + region)
|
|
34
|
+
if (found = find_exact_match(remaining, req_max))
|
|
35
|
+
matched << found[:original]
|
|
36
|
+
remaining.delete(found)
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 2. Language + Script match (ignore region)
|
|
41
|
+
# CRITICAL: Script MUST match to avoid politically sensitive fallbacks
|
|
42
|
+
if (found = find_lang_script_match(remaining, req_max))
|
|
43
|
+
matched << found[:original]
|
|
44
|
+
remaining.delete(found)
|
|
45
|
+
end
|
|
46
|
+
rescue
|
|
47
|
+
yield req_str if block_given?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
matched
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private def build_available_index(locales)
|
|
54
|
+
locales.map do |locale|
|
|
55
|
+
maximized = locale.maximize
|
|
56
|
+
{
|
|
57
|
+
original: locale.to_s,
|
|
58
|
+
locale:,
|
|
59
|
+
maximized:,
|
|
60
|
+
language: maximized.language,
|
|
61
|
+
script: maximized.script,
|
|
62
|
+
region: maximized.region
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private def maximize_locale(locale_str) = ::ICU4X::Locale.parse(locale_str).maximize
|
|
68
|
+
|
|
69
|
+
private def find_exact_match(candidates, req_max)
|
|
70
|
+
candidates.find do |entry|
|
|
71
|
+
entry[:language] == req_max.language &&
|
|
72
|
+
entry[:script] == req_max.script &&
|
|
73
|
+
entry[:region] == req_max.region
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private def find_lang_script_match(candidates, req_max)
|
|
78
|
+
candidates.find do |entry|
|
|
79
|
+
entry[:language] == req_max.language &&
|
|
80
|
+
entry[:script] == req_max.script
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "icu4x"
|
|
4
|
+
require "rack"
|
|
5
|
+
require "zeitwerk"
|
|
6
|
+
require_relative "locale/version"
|
|
7
|
+
|
|
8
|
+
module Rack
|
|
9
|
+
module ICU4X
|
|
10
|
+
# Rack middleware that detects user's preferred locales from various sources.
|
|
11
|
+
#
|
|
12
|
+
# Uses ICU4X's maximize for script-safe language negotiation that respects
|
|
13
|
+
# script boundaries (e.g., zh-TW/Hant will NOT match zh-CN/Hans).
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage (Accept-Language header only)
|
|
16
|
+
# use Rack::ICU4X::Locale, locales: %w[en ja]
|
|
17
|
+
#
|
|
18
|
+
# @example With multiple detectors
|
|
19
|
+
# use Rack::ICU4X::Locale,
|
|
20
|
+
# locales: %w[en ja],
|
|
21
|
+
# detectors: [{ query: "lang" }, { cookie: "locale" }, :header]
|
|
22
|
+
#
|
|
23
|
+
# @example With custom detector
|
|
24
|
+
# use Rack::ICU4X::Locale,
|
|
25
|
+
# locales: %w[en ja],
|
|
26
|
+
# detectors: [->(env) { env["rack.session"]&.[]("locale") }, :header]
|
|
27
|
+
class Locale
|
|
28
|
+
Zeitwerk::Loader.new.tap do |loader|
|
|
29
|
+
loader.push_dir("#{__dir__}/locale", namespace: Rack::ICU4X::Locale)
|
|
30
|
+
loader.ignore("#{__dir__}/locale/version.rb")
|
|
31
|
+
loader.setup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ENV_KEY = "rack.icu4x.locale"
|
|
35
|
+
public_constant :ENV_KEY
|
|
36
|
+
|
|
37
|
+
DEFAULT_DETECTORS = [:header].freeze
|
|
38
|
+
public_constant :DEFAULT_DETECTORS
|
|
39
|
+
|
|
40
|
+
class Error < StandardError; end
|
|
41
|
+
|
|
42
|
+
# @param app [#call] The Rack application
|
|
43
|
+
# @param locales [Array<String, ICU4X::Locale>] List of available locales
|
|
44
|
+
# @param detectors [Array] Detector specifications (optional, default: [:header])
|
|
45
|
+
# @param default [String, ICU4X::Locale, nil] Default locale when no match is found (optional)
|
|
46
|
+
def initialize(app, locales:, detectors: DEFAULT_DETECTORS, default: nil)
|
|
47
|
+
@app = app
|
|
48
|
+
@locales = locales.map {|locale| normalize_locale(locale) }
|
|
49
|
+
@detectors = build_detectors(detectors)
|
|
50
|
+
@default = default && normalize_locale(default)
|
|
51
|
+
validate_default_in_locales!
|
|
52
|
+
@negotiator = Negotiator.new(@locales)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param env [Hash] Rack environment
|
|
56
|
+
# @return [Array] Response from the wrapped application
|
|
57
|
+
def call(env)
|
|
58
|
+
env[ENV_KEY] = detect_locales(env)
|
|
59
|
+
@app.call(env)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private def validate_default_in_locales!
|
|
63
|
+
return unless @default
|
|
64
|
+
return if @locales.include?(@default)
|
|
65
|
+
|
|
66
|
+
raise Error, "default #{@default.to_s.inspect} is not in available locales"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def build_detectors(specs)
|
|
70
|
+
effective_specs = specs.empty? ? DEFAULT_DETECTORS : specs
|
|
71
|
+
effective_specs.map {|spec| Detector.build(spec) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private def detect_locales(env)
|
|
75
|
+
result = try_detectors(env)
|
|
76
|
+
result.empty? && @default ? [@default] : result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private def try_detectors(env)
|
|
80
|
+
@detectors.each do |detector|
|
|
81
|
+
raw = detector.call(env)
|
|
82
|
+
next if raw.nil?
|
|
83
|
+
|
|
84
|
+
requested = Array(raw)
|
|
85
|
+
matched = @negotiator.negotiate(requested) {|invalid_locale|
|
|
86
|
+
log_invalid_locale(env, invalid_locale)
|
|
87
|
+
}
|
|
88
|
+
return matched.map {|locale| ::ICU4X::Locale.parse(locale) } unless matched.empty?
|
|
89
|
+
end
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private def normalize_locale(locale) = locale.is_a?(::ICU4X::Locale) ? locale : ::ICU4X::Locale.parse(locale)
|
|
94
|
+
|
|
95
|
+
private def log_invalid_locale(env, locale)
|
|
96
|
+
message = "Ignored invalid locale: #{locale}"
|
|
97
|
+
if env["rack.logger"]
|
|
98
|
+
env["rack.logger"].warn(message)
|
|
99
|
+
else
|
|
100
|
+
env["rack.errors"]&.puts(message)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
module Detector
|
|
5
|
+
class Cookie
|
|
6
|
+
DEFAULT_NAME: String
|
|
7
|
+
|
|
8
|
+
@name: String
|
|
9
|
+
|
|
10
|
+
def initialize: (?String name) -> void
|
|
11
|
+
def call: (Hash[String, untyped] env) -> String?
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader name: String
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
module Detector
|
|
5
|
+
class Header
|
|
6
|
+
def initialize: () -> void
|
|
7
|
+
def call: (Hash[String, untyped] env) -> Array[String]?
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def parse_accept_language: (String header) -> Array[String]
|
|
12
|
+
def parse_entry: (String part) -> [String, Float]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
module Detector
|
|
5
|
+
class Query
|
|
6
|
+
DEFAULT_PARAM: String
|
|
7
|
+
|
|
8
|
+
@param: String
|
|
9
|
+
|
|
10
|
+
def initialize: (?String param) -> void
|
|
11
|
+
def call: (Hash[String, untyped] env) -> String?
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader param: String
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
module Detector
|
|
5
|
+
class InvalidSpecificationError < Error
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
INFLECTOR: Zeitwerk::Inflector
|
|
9
|
+
|
|
10
|
+
type detector_result = String | Array[String] | nil
|
|
11
|
+
type detector_spec = Symbol | Hash[Symbol, String] | ^(Hash[String, untyped]) -> detector_result | _Detector
|
|
12
|
+
|
|
13
|
+
def self.build: (detector_spec spec) -> _Detector
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def self.build_from_symbol: (Symbol symbol) -> _Detector
|
|
18
|
+
def self.build_from_hash: (Hash[Symbol, String] hash) -> _Detector
|
|
19
|
+
def self.validate_callable!: (untyped obj) -> void
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
interface _Detector
|
|
26
|
+
def call: (Hash[String, untyped] env) -> (String | Array[String] | nil)
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
class Negotiator
|
|
5
|
+
type available_entry = {
|
|
6
|
+
original: String,
|
|
7
|
+
locale: ICU4X::Locale,
|
|
8
|
+
maximized: ICU4X::Locale,
|
|
9
|
+
language: String,
|
|
10
|
+
script: String,
|
|
11
|
+
region: String
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@available: Array[available_entry]
|
|
15
|
+
|
|
16
|
+
def initialize: (Array[ICU4X::Locale] available_locales) -> void
|
|
17
|
+
|
|
18
|
+
def negotiate: (Array[String] requested_locales) ?{ (String) -> void } -> Array[String]
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def build_available_index: (Array[ICU4X::Locale] locales) -> Array[available_entry]
|
|
23
|
+
def maximize_locale: (String locale_str) -> ICU4X::Locale
|
|
24
|
+
def find_exact_match: (Array[available_entry] candidates, ICU4X::Locale req_max) -> available_entry?
|
|
25
|
+
def find_lang_script_match: (Array[available_entry] candidates, ICU4X::Locale req_max) -> available_entry?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Rack
|
|
2
|
+
module ICU4X
|
|
3
|
+
class Locale
|
|
4
|
+
VERSION: String
|
|
5
|
+
ENV_KEY: String
|
|
6
|
+
DEFAULT_DETECTORS: Array[Symbol]
|
|
7
|
+
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
type locale_input = String | ICU4X::Locale
|
|
12
|
+
|
|
13
|
+
@app: _RackApp
|
|
14
|
+
@locales: Array[ICU4X::Locale]
|
|
15
|
+
@detectors: Array[_Detector]
|
|
16
|
+
@default: ICU4X::Locale?
|
|
17
|
+
@negotiator: Negotiator
|
|
18
|
+
|
|
19
|
+
def initialize: (
|
|
20
|
+
_RackApp app,
|
|
21
|
+
locales: Array[locale_input],
|
|
22
|
+
?detectors: Array[Detector::detector_spec],
|
|
23
|
+
?default: locale_input?
|
|
24
|
+
) -> void
|
|
25
|
+
def call: (Hash[String, untyped] env) -> [Integer, Hash[String, String], _ToA[String]]
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_default_in_locales!: () -> void
|
|
30
|
+
def build_detectors: (Array[Detector::detector_spec] specs) -> Array[_Detector]
|
|
31
|
+
def detect_locales: (Hash[String, untyped] env) -> Array[ICU4X::Locale]
|
|
32
|
+
def try_detectors: (Hash[String, untyped] env) -> Array[ICU4X::Locale]
|
|
33
|
+
def normalize_locale: (locale_input locale) -> ICU4X::Locale
|
|
34
|
+
def log_invalid_locale: (Hash[String, untyped] env, String locale) -> void
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
interface _RackApp
|
|
40
|
+
def call: (Hash[String, untyped] env) -> [Integer, Hash[String, String], _ToA[String]]
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rack-icu4x-locale
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- OZAWA Sakuro
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-01-11 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: icu4x
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.8'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rack
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: zeitwerk
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.7'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.7'
|
|
55
|
+
description: rack-icu4x-locale
|
|
56
|
+
email:
|
|
57
|
+
- 10973+sakuro@users.noreply.github.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/rack/icu4x/locale.rb
|
|
66
|
+
- lib/rack/icu4x/locale/detector.rb
|
|
67
|
+
- lib/rack/icu4x/locale/detector/cookie.rb
|
|
68
|
+
- lib/rack/icu4x/locale/detector/header.rb
|
|
69
|
+
- lib/rack/icu4x/locale/detector/query.rb
|
|
70
|
+
- lib/rack/icu4x/locale/negotiator.rb
|
|
71
|
+
- lib/rack/icu4x/locale/version.rb
|
|
72
|
+
- sig/rack/icu4x/locale.rbs
|
|
73
|
+
- sig/rack/icu4x/locale/detector.rbs
|
|
74
|
+
- sig/rack/icu4x/locale/detector/cookie.rbs
|
|
75
|
+
- sig/rack/icu4x/locale/detector/header.rbs
|
|
76
|
+
- sig/rack/icu4x/locale/detector/query.rbs
|
|
77
|
+
- sig/rack/icu4x/locale/negotiator.rbs
|
|
78
|
+
homepage: https://github.com/sakuro/rack-icu4x-locale
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
homepage_uri: https://github.com/sakuro/rack-icu4x-locale
|
|
83
|
+
source_code_uri: https://github.com/sakuro/rack-icu4x-locale.git
|
|
84
|
+
changelog_uri: https://github.com/sakuro/rack-icu4x-locale/blob/main/CHANGELOG.md
|
|
85
|
+
rubygems_mfa_required: 'true'
|
|
86
|
+
post_install_message:
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '3.2'
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 3.4.19
|
|
102
|
+
signing_key:
|
|
103
|
+
specification_version: 4
|
|
104
|
+
summary: rack-icu4x-locale
|
|
105
|
+
test_files: []
|