slugifiable 0.1.0 → 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: d06633ba0907f8ee400648856e3d8dbc95fc647be40d60780ae01ebbb6eeb7d3
4
- data.tar.gz: d40cae2d3fc081af04bd6fac08ea8640d8174c1e59b07652c92bab72eb8a18ca
3
+ metadata.gz: f376f3d8ed6b225b61c6bd96cf207d4fb90970a7e138bd0685571225fe750343
4
+ data.tar.gz: 8d77cb7631dd2e4b7eb7be4b15e1d2fd1d0b3bcb6ddfabfcf0c505d776d50ddd
5
5
  SHA512:
6
- metadata.gz: 39dfad3e03b4cfef9946a929c5fe21585f2aab1ef1922d2f13ad8d10d6d4b732e3eaf6d4dcff545d9664d0395909fa6fa5e40a92801627625ef4034467b93bd9
7
- data.tar.gz: 8b726b22d56141b633f415460f8a8f8e7042379bc06aebb383f15360914b294bf29a0fffe2aa5e97e25577dab3e962d2b221bf7f18ab0d8822437f77ca97bfef
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
  ```
@@ -77,6 +80,10 @@ Product.first.slug
77
80
 
78
81
  If your model has a `slug` attribute in the database, `slugifiable` will automatically generate a slug for that model upon instance creation, and save it to the DB.
79
82
 
83
+ > [!IMPORTANT]
84
+ > Your `slug` attribute **SHOULD NOT** have `null: false` in the migration / database. If it does, `slugifiable` will not be able to save the slug to the database, and will raise an error like `ERROR: null value in column "slug" of relation "posts" violates not-null constraint (PG::NotNullViolation)`
85
+ > This is because records are created without a slug, and the slug is generated later.
86
+
80
87
  If you're generating slugs based off the model `id`, you can also set a desired length:
81
88
  ```ruby
82
89
  class Product < ApplicationRecord
@@ -157,12 +164,56 @@ Product.first.slug
157
164
  => "big-red-backpack"
158
165
  ```
159
166
 
167
+ You can also use instance methods to generate more complex slugs. This is useful when you need to combine multiple attributes:
168
+ ```ruby
169
+ class Event < ApplicationRecord
170
+ include Slugifiable::Model
171
+ belongs_to :location
172
+
173
+ generate_slug_based_on :title_with_location
174
+
175
+ # The method can return any string - slugifiable will handle the parameterization
176
+ def title_with_location
177
+ if location.present?
178
+ "#{title} #{location.city} #{location.region}" # Returns raw string, slugifiable parameterizes it
179
+ else
180
+ title
181
+ end
182
+ end
183
+ end
184
+ ```
185
+
186
+ This will generate slugs like:
187
+ ```ruby
188
+ Event.first.slug
189
+ => "my-awesome-event-new-york" # Automatically parameterized
190
+ ```
191
+
160
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:
161
193
  ```ruby
162
194
  Product.first.slug
163
195
  => "big-red-backpack-321678"
164
196
  ```
165
197
 
198
+ ## Testing
199
+
200
+ The gem includes a comprehensive test suite that covers:
201
+
202
+ - Default slug generation behavior
203
+ - Attribute-based slug generation
204
+ - Method-based slug generation (including private/protected methods)
205
+ - Collision resolution and uniqueness handling
206
+ - Special cases and edge conditions
207
+
208
+ To run the tests:
209
+
210
+ ```bash
211
+ bundle install
212
+ bundle exec rake test
213
+ ```
214
+
215
+ The test suite uses SQLite3 in-memory database and requires no additional setup.
216
+
166
217
  ## Development
167
218
 
168
219
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -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,15 @@ 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
+
36
+ # Maximum number of attempts to generate a unique slug
37
+ # before falling back to timestamp-based suffix
38
+ MAX_SLUG_GENERATION_ATTEMPTS = 10
39
+
31
40
  included do
32
41
  after_create :set_slug
33
42
  after_find :update_slug_if_nil
@@ -36,25 +45,32 @@ module Slugifiable
36
45
 
37
46
  class_methods do
38
47
  def generate_slug_based_on(strategy, options = {})
48
+ # Remove previous definition if it exists to avoid warnings
49
+ remove_method(:generate_slug_based_on) if method_defined?(:generate_slug_based_on)
50
+
51
+ # Define the method that returns the strategy
39
52
  define_method(:generate_slug_based_on) do
40
53
  [strategy, options]
41
54
  end
42
55
  end
43
-
44
56
  end
45
57
 
46
58
  def method_missing(missing_method, *args, &block)
47
- if missing_method.to_s == "slug" && !self.methods.include?(:slug)
59
+ if missing_method.to_s == "slug" && !has_slug_method?
48
60
  compute_slug
49
61
  else
50
62
  super
51
63
  end
52
64
  end
53
65
 
66
+ def respond_to_missing?(method_name, include_private = false)
67
+ method_name.to_s == "slug" && !has_slug_method? || super
68
+ end
69
+
54
70
  def compute_slug
55
71
  strategy, options = determine_slug_generation_method
56
72
 
57
- length = options[:length] if options.is_a?(Hash) || nil
73
+ length = options[:length] if options.is_a?(Hash)
58
74
 
59
75
  if strategy == :compute_slug_based_on_attribute
60
76
  self.send(strategy, options)
@@ -64,29 +80,72 @@ module Slugifiable
64
80
  end
65
81
 
66
82
  def compute_slug_as_string(length = DEFAULT_SLUG_STRING_LENGTH)
67
- length ||= DEFAULT_SLUG_STRING_LENGTH
83
+ length = normalize_length(length, DEFAULT_SLUG_STRING_LENGTH, MAX_HEX_STRING_LENGTH)
68
84
  (Digest::SHA2.hexdigest self.id.to_s).first(length)
69
85
  end
70
86
 
71
87
  def compute_slug_as_number(length = DEFAULT_SLUG_NUMBER_LENGTH)
72
- length ||= DEFAULT_SLUG_NUMBER_LENGTH
88
+ length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
73
89
  generate_random_number_based_on_id_hex(length)
74
90
  end
75
91
 
76
92
  def compute_slug_based_on_attribute(attribute_name)
77
- return compute_slug_as_string unless self.attributes.include?(attribute_name.to_s)
78
-
79
- base_slug = self.send(attribute_name)&.to_s&.strip&.parameterize
80
- base_slug = base_slug.presence || generate_random_number_based_on_id_hex
81
-
93
+ # This method generates a slug from either:
94
+ # 1. A database column (e.g. generate_slug_based_on :title)
95
+ # 2. An instance method (e.g. generate_slug_based_on :title_with_location)
96
+ #
97
+ # Priority:
98
+ # - Database columns take precedence over methods with the same name
99
+ # - Falls back to methods if no matching column exists
100
+ # - Falls back to ID-based slug if neither exists
101
+ #
102
+ # Flow:
103
+ # 1. Check if source exists (DB column first, then method)
104
+ # 2. Get raw value
105
+ # 3. Parameterize (convert "My Title" -> "my-title")
106
+ # 4. Ensure uniqueness
107
+ # 5. Fallback to random number if anything fails
108
+
109
+ # First check if we can get a value from the database
110
+ has_attribute = self.attributes.include?(attribute_name.to_s)
111
+
112
+ # Only check for methods if no DB attribute exists
113
+ # We check all method types to be thorough
114
+ responds_to_method = !has_attribute && (
115
+ self.class.method_defined?(attribute_name) ||
116
+ self.class.private_method_defined?(attribute_name) ||
117
+ self.class.protected_method_defined?(attribute_name)
118
+ )
119
+
120
+ # If we can't get a value from either source, fallback to using the record's ID
121
+ return compute_slug_as_string unless has_attribute || responds_to_method
122
+
123
+ # Get and clean the raw value (e.g. " My Title " -> "My Title")
124
+ # Works for both DB attributes and methods thanks to Ruby's send
125
+ raw_value = self.send(attribute_name)
126
+ return generate_random_number_based_on_id_hex if raw_value.nil?
127
+
128
+ # Convert to URL-friendly format
129
+ # e.g. "My Title" -> "my-title"
130
+ base_slug = raw_value.to_s.strip.parameterize
131
+ return generate_random_number_based_on_id_hex if base_slug.blank?
132
+
133
+ # Handle duplicate slugs by adding a random suffix if needed
134
+ # e.g. "my-title" -> "my-title-123456"
82
135
  unique_slug = generate_unique_slug(base_slug)
83
136
  unique_slug.presence || generate_random_number_based_on_id_hex
84
137
  end
85
138
 
86
139
  private
87
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
+
88
147
  def generate_random_number_based_on_id_hex(length = DEFAULT_SLUG_NUMBER_LENGTH)
89
- length ||= DEFAULT_SLUG_NUMBER_LENGTH
148
+ length = normalize_length(length, DEFAULT_SLUG_NUMBER_LENGTH, MAX_NUMBER_LENGTH)
90
149
  ((Digest::SHA2.hexdigest(id.to_s)).hex % (10 ** length))
91
150
  end
92
151
 
@@ -96,13 +155,23 @@ module Slugifiable
96
155
  return slug_candidate unless slug_persisted?
97
156
 
98
157
  # Collision resolution logic:
158
+ # Try up to MAX_SLUG_GENERATION_ATTEMPTS times with random suffixes
159
+ # This prevents infinite loops while still giving us good odds
160
+ # of finding a unique slug
161
+ attempts = 0
162
+
163
+ while self.class.exists?(slug: slug_candidate) && attempts < MAX_SLUG_GENERATION_ATTEMPTS
164
+ attempts += 1
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)
168
+ slug_candidate = "#{base_slug}-#{random_suffix}"
169
+ end
99
170
 
100
- count = 0
101
-
102
- while self.class.exists?(slug: slug_candidate)
103
- count += 1
104
- # slug_candidate = "#{base_slug}-#{count}"
105
- slug_candidate = "#{base_slug}-#{compute_slug_as_number}"
171
+ # If we couldn't find a unique slug after MAX_SLUG_GENERATION_ATTEMPTS,
172
+ # append timestamp + random to ensure uniqueness
173
+ if attempts == MAX_SLUG_GENERATION_ATTEMPTS
174
+ slug_candidate = "#{base_slug}-#{Time.current.to_i}-#{SecureRandom.random_number(1000)}"
106
175
  end
107
176
 
108
177
  slug_candidate
@@ -133,7 +202,7 @@ module Slugifiable
133
202
  return [DEFAULT_SLUG_GENERATION_STRATEGY, options]
134
203
  end
135
204
  elsif strategy.key?(:attribute)
136
- return [:compute_slug_based_on_attribute, strategy[:attribute], options]
205
+ return [:compute_slug_based_on_attribute, strategy[:attribute]]
137
206
  end
138
207
  end
139
208
 
@@ -141,7 +210,12 @@ module Slugifiable
141
210
  end
142
211
 
143
212
  def slug_persisted?
144
- 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)
145
219
  end
146
220
 
147
221
  def set_slug
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slugifiable
4
- VERSION = "0.1.0"
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,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slugifiable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-30 00:00:00.000000000 Z
10
+ date: 2026-01-16 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -33,10 +32,17 @@ executables: []
33
32
  extensions: []
34
33
  extra_rdoc_files: []
35
34
  files:
35
+ - ".simplecov"
36
+ - AGENTS.md
37
+ - Appraisals
36
38
  - CHANGELOG.md
39
+ - CLAUDE.md
37
40
  - LICENSE.txt
38
41
  - README.md
39
42
  - Rakefile
43
+ - gemfiles/rails_7.2.gemfile
44
+ - gemfiles/rails_8.0.gemfile
45
+ - gemfiles/rails_8.1.gemfile
40
46
  - lib/slugifiable.rb
41
47
  - lib/slugifiable/model.rb
42
48
  - lib/slugifiable/version.rb
@@ -49,7 +55,6 @@ metadata:
49
55
  homepage_uri: https://github.com/rameerez/slugifiable
50
56
  source_code_uri: https://github.com/rameerez/slugifiable
51
57
  changelog_uri: https://github.com/rameerez/slugifiable/blob/main/CHANGELOG.md
52
- post_install_message:
53
58
  rdoc_options: []
54
59
  require_paths:
55
60
  - lib
@@ -64,8 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
64
69
  - !ruby/object:Gem::Version
65
70
  version: '0'
66
71
  requirements: []
67
- rubygems_version: 3.5.16
68
- signing_key:
72
+ rubygems_version: 3.6.2
69
73
  specification_version: 4
70
74
  summary: Easily generate unique and SEO optimized slugs for your Rails ActiveRecord
71
75
  model records, based on any attribute