slugifiable 0.1.1 → 0.2.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: e9f707db7b8e857da8289a7e17754261aa3dd4b06b517673b983936cc449e530
4
- data.tar.gz: 36144ba06043096a3ead2aa5e3728a3c6c0f54bf9be15c27d085e944d47a1b76
3
+ metadata.gz: f376f3d8ed6b225b61c6bd96cf207d4fb90970a7e138bd0685571225fe750343
4
+ data.tar.gz: 8d77cb7631dd2e4b7eb7be4b15e1d2fd1d0b3bcb6ddfabfcf0c505d776d50ddd
5
5
  SHA512:
6
- metadata.gz: 848886284f316ccb3086afcb268ddf09bf136c040a055c353fa4492021d578e439367ce137d471334a339fb43ff2997271267b2c0668d25f7654803cd4e73cad
7
- data.tar.gz: 984f18e9268a0fff5b64f3e8d28e654c2586fa99c02f2199134bbac798cb311e68e5714b17c22ef6583eae75f51cce22b209ac08791d973dbe7f71a2e8830645
6
+ metadata.gz: ae99e80bff43688ca9402bb2b09074a0df88c685be8974d4120b5772f648b77d583c0cae3631dbbaee711a6477f51706499337c08fa3d46cfa0ba8888b05f4d5
7
+ data.tar.gz: ec724965c4aed46aa09207ef3a2cc0e761466c6097efc871ff0190d05b2833a207ee6afeb6150ca04da970036f727946c25252c574fbde4f9200779634c51d7d
data/.simplecov ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration file (auto-loaded before test suite)
4
+ # This keeps test_helper.rb clean and follows best practices
5
+
6
+ SimpleCov.start do
7
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
8
+ formatter SimpleCov::Formatter::SimpleFormatter
9
+
10
+ # Track coverage for the lib directory (gem source code)
11
+ add_filter "/test/"
12
+
13
+ # Track the lib and app directories
14
+ track_files "{lib,app}/**/*.rb"
15
+
16
+ # Enable branch coverage for more detailed metrics
17
+ enable_coverage :branch
18
+
19
+ # Set minimum coverage threshold to prevent coverage regression
20
+ minimum_coverage line: 90, branch: 90
21
+
22
+ # Disambiguate parallel test runs
23
+ command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
24
+ end
25
+
26
+ # Print coverage summary to terminal after tests complete
27
+ SimpleCov.at_exit do
28
+ SimpleCov.result.format!
29
+ puts "\n" + "=" * 60
30
+ puts "COVERAGE SUMMARY"
31
+ puts "=" * 60
32
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
33
+ puts "Branch Coverage: #{SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || 'N/A'}%"
34
+ puts "=" * 60
35
+ end
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/Appraisals ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Note: Rails < 7.2 is not compatible with Ruby 3.4
4
+ # (Logger became a bundled gem in Ruby 3.4, and only Rails 7.2+ handles this)
5
+ # See: https://stdgems.org/logger/
6
+
7
+ # Test against Rails 7.2 (minimum version compatible with Ruby 3.4)
8
+ appraise "rails-7.2" do
9
+ gem "rails", "~> 7.2.0"
10
+ end
11
+
12
+ # Test against Rails 8.0
13
+ appraise "rails-8.0" do
14
+ gem "rails", "~> 8.0.0"
15
+ end
16
+
17
+ # Test against Rails 8.1 (latest)
18
+ appraise "rails-8.1" do
19
+ gem "rails", "~> 8.1.2"
20
+ end
data/CHANGELOG.md CHANGED
@@ -1,4 +1,18 @@
1
+ ## [0.2.0] - 2026-01-16
2
+
3
+ - Added a full Minitest test suite
4
+ - Fixed `respond_to?(:slug)` returning false for models using method_missing by implementing `respond_to_missing?`
5
+ - Fixed collision resolution generating identical suffixes by switching from id-based deterministic suffixes to SecureRandom-based random suffixes
6
+ - Fixed length parameter edge cases (zero, negative, very large values) by adding length validation and clamping via `normalize_length` method
7
+ - Fixed timestamp fallback to include random suffix for additional uniqueness guarantee
8
+ - Added length validation constants (`MAX_HEX_STRING_LENGTH`, `MAX_NUMBER_LENGTH`) to prevent invalid length values
9
+
10
+ ## [0.1.1] - 2024-03-21
11
+
12
+ - Slugs can be now generated based off methods that return a string, not just based off attributes
13
+ - Enhanced collision resolution strategy so that it doesn't get stuck in infinite loops
14
+ - Added comprehensive test suite
15
+
1
16
  ## [0.1.0] - 2024-10-30
2
17
 
3
18
  - Initial release
4
-
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/README.md CHANGED
@@ -1,8 +1,11 @@
1
- # 🐌 `slugifiable` - Rails gem to generate SEO-friendly slugs
1
+ # 🐌 `slugifiable` - Generate SEO-optimized URL slugs
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/slugifiable.svg)](https://badge.fury.io/rb/slugifiable)
3
+ [![Gem Version](https://badge.fury.io/rb/slugifiable.svg)](https://badge.fury.io/rb/slugifiable) [![Build Status](https://github.com/rameerez/slugifiable/workflows/Tests/badge.svg)](https://github.com/rameerez/slugifiable/actions)
4
4
 
5
- Automatically generates unique slugs for your Rails' model records, so you can expose SEO-friendly URLs.
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=slugifiable)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=slugifiable)!
7
+
8
+ Ruby gem to automatically generate unique slugs for your Rails' model records, so you can expose SEO-friendly URLs.
6
9
 
7
10
  Example:
8
11
  ```
@@ -183,7 +186,7 @@ end
183
186
  This will generate slugs like:
184
187
  ```ruby
185
188
  Event.first.slug
186
- => "my-awesome-event-new-york-new-york" # Automatically parameterized
189
+ => "my-awesome-event-new-york" # Automatically parameterized
187
190
  ```
188
191
 
189
192
  There may be collisions if two records share the same name – but slugs should be unique! To resolve this, when this happens, `slugifiable` will append a unique string at the end to make the slug unique:
@@ -0,0 +1,17 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.0"
7
+
8
+ group :development, :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "rack-test"
13
+ gem "simplecov", require: false
14
+ gem "sqlite3", ">= 2.1"
15
+ end
16
+
17
+ gemspec path: "../"
@@ -0,0 +1,17 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.0.0"
7
+
8
+ group :development, :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "rack-test"
13
+ gem "simplecov", require: false
14
+ gem "sqlite3", ">= 2.1"
15
+ end
16
+
17
+ gemspec path: "../"
@@ -0,0 +1,17 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.2"
7
+
8
+ group :development, :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "rack-test"
13
+ gem "simplecov", require: false
14
+ gem "sqlite3", ">= 2.1"
15
+ end
16
+
17
+ gemspec path: "../"
@@ -28,6 +28,11 @@ module Slugifiable
28
28
  DEFAULT_SLUG_STRING_LENGTH = 11
29
29
  DEFAULT_SLUG_NUMBER_LENGTH = 6
30
30
 
31
+ # SHA256 produces 64 hex characters
32
+ MAX_HEX_STRING_LENGTH = 64
33
+ # 10^18 fits safely in a 64-bit integer
34
+ MAX_NUMBER_LENGTH = 18
35
+
31
36
  # Maximum number of attempts to generate a unique slug
32
37
  # before falling back to timestamp-based suffix
33
38
  MAX_SLUG_GENERATION_ATTEMPTS = 10
@@ -51,13 +56,17 @@ module Slugifiable
51
56
  end
52
57
 
53
58
  def method_missing(missing_method, *args, &block)
54
- if missing_method.to_s == "slug" && !self.methods.include?(:slug)
59
+ if missing_method.to_s == "slug" && !has_slug_method?
55
60
  compute_slug
56
61
  else
57
62
  super
58
63
  end
59
64
  end
60
65
 
66
+ def respond_to_missing?(method_name, include_private = false)
67
+ method_name.to_s == "slug" && !has_slug_method? || super
68
+ end
69
+
61
70
  def compute_slug
62
71
  strategy, options = determine_slug_generation_method
63
72
 
@@ -71,12 +80,12 @@ module Slugifiable
71
80
  end
72
81
 
73
82
  def compute_slug_as_string(length = DEFAULT_SLUG_STRING_LENGTH)
74
- length ||= DEFAULT_SLUG_STRING_LENGTH
83
+ length = normalize_length(length, DEFAULT_SLUG_STRING_LENGTH, MAX_HEX_STRING_LENGTH)
75
84
  (Digest::SHA2.hexdigest self.id.to_s).first(length)
76
85
  end
77
86
 
78
87
  def compute_slug_as_number(length = DEFAULT_SLUG_NUMBER_LENGTH)
79
- length ||= DEFAULT_SLUG_NUMBER_LENGTH
88
+ length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
80
89
  generate_random_number_based_on_id_hex(length)
81
90
  end
82
91
 
@@ -129,8 +138,14 @@ module Slugifiable
129
138
 
130
139
  private
131
140
 
141
+ def normalize_length(length, default, max)
142
+ length = length.to_i
143
+ return default if length <= 0
144
+ [length, max].min
145
+ end
146
+
132
147
  def generate_random_number_based_on_id_hex(length = DEFAULT_SLUG_NUMBER_LENGTH)
133
- length ||= DEFAULT_SLUG_NUMBER_LENGTH
148
+ length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
134
149
  ((Digest::SHA2.hexdigest(id.to_s)).hex % (10 ** length))
135
150
  end
136
151
 
@@ -147,14 +162,16 @@ module Slugifiable
147
162
 
148
163
  while self.class.exists?(slug: slug_candidate) && attempts < MAX_SLUG_GENERATION_ATTEMPTS
149
164
  attempts += 1
150
- random_suffix = compute_slug_as_number
165
+ # Use SecureRandom for truly random suffixes during collision resolution
166
+ # This ensures each attempt tries a different suffix
167
+ random_suffix = SecureRandom.random_number(10 ** DEFAULT_SLUG_NUMBER_LENGTH)
151
168
  slug_candidate = "#{base_slug}-#{random_suffix}"
152
169
  end
153
170
 
154
171
  # If we couldn't find a unique slug after MAX_SLUG_GENERATION_ATTEMPTS,
155
- # append timestamp to ensure uniqueness
172
+ # append timestamp + random to ensure uniqueness
156
173
  if attempts == MAX_SLUG_GENERATION_ATTEMPTS
157
- slug_candidate = "#{base_slug}-#{Time.current.to_i}"
174
+ slug_candidate = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
158
175
  end
159
176
 
160
177
  slug_candidate
@@ -185,7 +202,7 @@ module Slugifiable
185
202
  return [DEFAULT_SLUG_GENERATION_STRATEGY, options]
186
203
  end
187
204
  elsif strategy.key?(:attribute)
188
- return [:compute_slug_based_on_attribute, strategy[:attribute], options]
205
+ return [:compute_slug_based_on_attribute, strategy[:attribute]]
189
206
  end
190
207
  end
191
208
 
@@ -193,7 +210,12 @@ module Slugifiable
193
210
  end
194
211
 
195
212
  def slug_persisted?
196
- self.methods.include?(:slug) && self.attributes.include?("slug")
213
+ has_slug_method? && self.attributes.include?("slug")
214
+ end
215
+
216
+ def has_slug_method?
217
+ # Check if slug method exists from ActiveRecord (not from method_missing)
218
+ self.class.method_defined?(:slug) || self.class.private_method_defined?(:slug)
197
219
  end
198
220
 
199
221
  def set_slug
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slugifiable
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/slugifiable.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "slugifiable/version"
4
6
  require_relative "slugifiable/model"
5
7
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slugifiable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-23 00:00:00.000000000 Z
10
+ date: 2026-01-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -32,10 +32,17 @@ executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
+ - ".simplecov"
36
+ - AGENTS.md
37
+ - Appraisals
35
38
  - CHANGELOG.md
39
+ - CLAUDE.md
36
40
  - LICENSE.txt
37
41
  - README.md
38
42
  - Rakefile
43
+ - gemfiles/rails_7.2.gemfile
44
+ - gemfiles/rails_8.0.gemfile
45
+ - gemfiles/rails_8.1.gemfile
39
46
  - lib/slugifiable.rb
40
47
  - lib/slugifiable/model.rb
41
48
  - lib/slugifiable/version.rb