rack-url-canonicalizer 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad9bcf0b3bfa0b272e1ec7d8eb41e3a98b19460341241c24559150d0f2cdb009
4
+ data.tar.gz: 90c19805083edf665a1c84f0b5966fb937f4249df73be65592d8e4359762e1a3
5
+ SHA512:
6
+ metadata.gz: f407b4ca1f132cc6f87124706e84532d329a94cb9eb0176a20488518e630fb591439f9d1f191c844ddec558065bd248403897159a24f4ea1d1929e79425d1a25
7
+ data.tar.gz: d982f92f9dfc76c23b0888ac68b1268fe6a749e8acf57ff0ec9a2888ce686e34cc89c70111f3c511b4f26430cb7dc48fba5f772aa9ee914b8054cbf85e3817c0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Rack::UrlCanonicalizer
2
+
3
+ `rack-url-canonicalizer` is a lightweight, zero-dependency Rack middleware for Ruby applications (Rails, Sinatra, Hanami, Roda, or plain Rack).
4
+
5
+ It automatically canonicalizes incoming request URLs to prevent search engine **Duplicate Content** penalties by enforcing:
6
+ 1. Removal of the `www.` sub-domain prefix.
7
+ 2. Collapsing duplicate slashes (`//path//to` ➔ `/path/to`).
8
+ 3. Stripping trailing slashes from paths (`/path/` ➔ `/path`).
9
+ 4. Validating `locale` GET query parameters against an allowed list and stripping invalid locales.
10
+
11
+ ---
12
+
13
+ ## When Is This Gem Necessary?
14
+
15
+ Search engines (Google, Yandex, Bing) treat every unique URL string as a distinct page. Without strict URL normalization, subtle variations in request paths create **Duplicate Content**, which splits link authority (PageRank), degrades search rankings, and wastes crawl budget.
16
+
17
+ This gem is essential when:
18
+ - **Protecting SEO & Domain Authority**: You need to prevent index cannibalization caused by variations like `example.com/item` vs. `www.example.com/item`, `example.com/item/`, or `example.com//item`.
19
+ - **Enforcing Single Canonical URLs**: You want incoming traffic from user typos or legacy backlinks to be redirected with an HTTP 301 (Moved Permanently) status to a single canonical address before reaching your application logic.
20
+ - **Handling Multi-Locale Query Parameters**: Your app supports localization (e.g. `?locale=en`) and you want to sanitize or discard invalid/malformed locale parameters (e.g. `?locale=xyz`) that would otherwise generate infinite duplicate URL variants.
21
+ - **High-Performance Early Redirects**: You want URL normalization to happen at the lightweight Rack middleware layer—avoiding unnecessary Rails controller instantiations, routing checks, or database queries.
22
+ - **Simplified Deployment with Kamal & Modern Stacks**: Ideal for containerized setups deployed with **Kamal**, **Thruster**, or Docker, where configuring and maintaining separate Nginx/Caddy redirect rules or complex DNS edge layers adds unnecessary overhead. This gem handles canonicalization directly inside your application stack for fast, zero-config deployments.
23
+
24
+ ---
25
+
26
+
27
+ ## Installation
28
+
29
+ Add this line to your application's `Gemfile`:
30
+
31
+ ```ruby
32
+ gem "rack-url-canonicalizer"
33
+ ```
34
+
35
+ And then execute:
36
+
37
+ ```bash
38
+ bundle install
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Configuration Options
44
+
45
+ | Option | Type | Default | Description |
46
+ | :--- | :--- | :--- | :--- |
47
+ | `strip_www` | `Boolean` | `true` | Removes `www.` prefix from host. |
48
+ | `collapse_slashes` | `Boolean` | `true` | Replaces multiple slashes `//` with `/`. |
49
+ | `strip_trailing_slash` | `Boolean` | `true` | Removes trailing slash from path (except root `/`). |
50
+ | `exclude_paths` | `Array<String>` | `[]` | Array of path prefixes to bypass (e.g. `%w[/api /assets]`). |
51
+ | `locale_param` | `String` | `"locale"` | GET parameter key for locale. |
52
+ | `allowed_locales` | `Array<String> \| Proc` | `nil` | List of allowed locales or callable returning them. |
53
+ | `redirect_status` | `Integer` | `301` | HTTP status code for redirects. |
54
+ | `cache_control` | `String` | `"public, max-age=86400"` | `Cache-Control` header for redirect responses. |
55
+
56
+ ---
57
+
58
+ ## Usage
59
+
60
+ ### Ruby on Rails
61
+
62
+ In `config/application.rb`:
63
+
64
+ ```ruby
65
+ config.middleware.use Rack::UrlCanonicalizer,
66
+ strip_www: true,
67
+ collapse_slashes: true,
68
+ strip_trailing_slash: true,
69
+ exclude_paths: %w[/api /assets /up],
70
+ allowed_locales: -> { I18n.available_locales }
71
+ ```
72
+
73
+ ### Sinatra / Plain Rack
74
+
75
+ In `config.ru`:
76
+
77
+ ```ruby
78
+ require "rack/url_canonicalizer"
79
+
80
+ use Rack::UrlCanonicalizer,
81
+ strip_www: true,
82
+ exclude_paths: ["/api"]
83
+
84
+ run MyApp
85
+ ```
86
+
87
+ ### Global Configuration
88
+
89
+ Alternatively, set global defaults:
90
+
91
+ ```ruby
92
+ Rack::UrlCanonicalizer.configure do |config|
93
+ config.strip_www = true
94
+ config.collapse_slashes = true
95
+ config.allowed_locales = %w[en ru es]
96
+ end
97
+
98
+ # In Rack stack:
99
+ use Rack::UrlCanonicalizer
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Development & Testing
105
+
106
+ Run tests with RSpec:
107
+
108
+ ```bash
109
+ bundle exec rspec
110
+ ```
111
+
112
+ ---
113
+
114
+ ## License
115
+
116
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
117
+ # rack-url-canonicalizer
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class UrlCanonicalizer
5
+ class Configuration
6
+ attr_accessor :strip_www,
7
+ :collapse_slashes,
8
+ :strip_trailing_slash,
9
+ :exclude_paths,
10
+ :locale_param,
11
+ :allowed_locales,
12
+ :redirect_status,
13
+ :cache_control
14
+
15
+ def initialize
16
+ @strip_www = true
17
+ @collapse_slashes = true
18
+ @strip_trailing_slash = true
19
+ @exclude_paths = []
20
+ @locale_param = "locale"
21
+ @allowed_locales = nil
22
+ @redirect_status = 301
23
+ @cache_control = "public, max-age=86400"
24
+ end
25
+
26
+ def allowed_locales_list
27
+ return nil if allowed_locales.nil?
28
+
29
+ locs = allowed_locales.respond_to?(:call) ? allowed_locales.call : allowed_locales
30
+ Array(locs).map(&:to_s)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails::Railtie)
4
+ module Rack
5
+ class UrlCanonicalizer
6
+ class Railtie < ::Rails::Railtie
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class UrlCanonicalizer
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require_relative "url_canonicalizer/version"
5
+ require_relative "url_canonicalizer/configuration"
6
+ require_relative "url_canonicalizer/railtie"
7
+
8
+ module Rack
9
+ class UrlCanonicalizer
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+
19
+ def reset_configuration!
20
+ @configuration = Configuration.new
21
+ end
22
+ end
23
+
24
+ def initialize(app, options = {})
25
+ @app = app
26
+ @config = self.class.configuration.dup
27
+
28
+ options.each do |key, value|
29
+ writer = "#{key}="
30
+ @config.send(writer, value) if @config.respond_to?(writer)
31
+ end
32
+ end
33
+
34
+ def call(env)
35
+ req = Rack::Request.new(env)
36
+
37
+ return @app.call(env) unless req.get? || req.head?
38
+ return @app.call(env) if xhr_request?(req, env)
39
+
40
+ path_info = env["PATH_INFO"] || ""
41
+
42
+ if excluded_path?(path_info)
43
+ return @app.call(env)
44
+ end
45
+
46
+ host = req.host || ""
47
+ host_redirect = @config.strip_www && host.start_with?("www.")
48
+ target_host = host_redirect ? host.sub(/\Awww\./, "") : host
49
+
50
+ raw_path = path_info
51
+ normalized_path = raw_path.dup
52
+
53
+ if @config.collapse_slashes
54
+ normalized_path.gsub!(%r{/{2,}}, "/")
55
+ end
56
+
57
+ if @config.strip_trailing_slash && normalized_path.length > 1 && normalized_path.end_with?("/")
58
+ normalized_path.chomp!("/")
59
+ end
60
+
61
+ query_params = req.GET.dup
62
+ locale_redirect = false
63
+ locale_key = @config.locale_param
64
+
65
+ if locale_key && query_params.key?(locale_key)
66
+ allowed = @config.allowed_locales_list
67
+ if allowed && !allowed.include?(query_params[locale_key].to_s)
68
+ query_params.delete(locale_key)
69
+ locale_redirect = true
70
+ end
71
+ end
72
+
73
+ if host_redirect || raw_path != normalized_path || locale_redirect
74
+ scheme = req.scheme
75
+ port = req.port
76
+ port_part = [ 80, 443 ].include?(port) ? "" : ":#{port}"
77
+
78
+ new_query = Rack::Utils.build_nested_query(query_params)
79
+ new_url = +"#{scheme}://#{target_host}#{port_part}#{normalized_path}"
80
+ new_url << "?#{new_query}" unless new_query.empty?
81
+
82
+ return [
83
+ @config.redirect_status,
84
+ {
85
+ "location" => new_url,
86
+ "content-type" => "text/html",
87
+ "cache-control" => @config.cache_control
88
+ },
89
+ [ redirect_body(@config.redirect_status) ]
90
+ ]
91
+ end
92
+
93
+ @app.call(env)
94
+ end
95
+
96
+ private
97
+
98
+ def xhr_request?(req, env)
99
+ return true if req.respond_to?(:xhr?) && req.xhr?
100
+
101
+ env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
102
+ end
103
+
104
+ def excluded_path?(path_info)
105
+ return false if @config.exclude_paths.nil? || @config.exclude_paths.empty?
106
+
107
+ @config.exclude_paths.any? do |prefix|
108
+ path_info.start_with?(prefix)
109
+ end
110
+ end
111
+
112
+ def redirect_body(status)
113
+ case status
114
+ when 301 then "Moved Permanently"
115
+ when 302 then "Found"
116
+ when 307 then "Temporary Redirect"
117
+ when 308 then "Permanent Redirect"
118
+ else "Redirected"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/url_canonicalizer"
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-url-canonicalizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Abramov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack-test
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ description: Removes www, collapses slashes, strips trailing slashes, and validates
83
+ locale params to prevent duplicate content SEO penalties.
84
+ email:
85
+ - omgout.200@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE.txt
91
+ - README.md
92
+ - lib/rack-url-canonicalizer.rb
93
+ - lib/rack/url_canonicalizer.rb
94
+ - lib/rack/url_canonicalizer/configuration.rb
95
+ - lib/rack/url_canonicalizer/railtie.rb
96
+ - lib/rack/url_canonicalizer/version.rb
97
+ homepage: https://github.com/o-200/rack-url-canonicalizer
98
+ licenses:
99
+ - MIT
100
+ metadata: {}
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 3.0.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 4.0.16
116
+ specification_version: 4
117
+ summary: Rack middleware for URL normalization and SEO canonicalization
118
+ test_files: []