slug 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :minor: 5
3
+ :patch: 0
4
+ :major: 0
@@ -0,0 +1,14 @@
1
+ module Slug
2
+ # ASCII approximations for accented characters.
3
+ ASCII_APPROXIMATIONS = {
4
+ 198 => "AE",
5
+ 208 => "D",
6
+ 216 => "O",
7
+ 222 => "Th",
8
+ 223 => "ss",
9
+ 230 => "ae",
10
+ 240 => "d",
11
+ 248 => "o",
12
+ 254 => "th"
13
+ }.freeze
14
+ end
data/lib/slug/slug.rb ADDED
@@ -0,0 +1,104 @@
1
+ module Slug
2
+ module ClassMethods
3
+
4
+ # Call this to set up slug handling on an ActiveRecord model.
5
+ #
6
+ # Params:
7
+ # * <tt>:source</tt> - the column the slug will be based on (e.g. :<tt>headline</tt>)
8
+ #
9
+ # Options:
10
+ # * <tt>:column</tt> - the column the slug will be saved to (defaults to <tt>:slug</tt>)
11
+ #
12
+ # Slug will take care of validating presence and uniqueness of slug. It will generate the slug before create;
13
+ # subsequent changes to the source column will have no effect on the slug. If you'd like to update the slug
14
+ # later on, call <tt>@model.set_slug</tt>
15
+ def slug source, opts={}
16
+ class_inheritable_accessor :slug_source, :slug_column
17
+
18
+ self.slug_source = source
19
+ raise ArgumentError, "Source column '#{self.slug_source}' does not exist!" if !self.column_names.include?(self.slug_source.to_s)
20
+
21
+ self.slug_column = opts.has_key?(:column) ? opts[:column] : :slug
22
+ raise ArgumentError, "Slug column '#{self.slug_column}' does not exist! #{self.column_names.join(',')}" if !self.column_names.include?(self.slug_column.to_s)
23
+
24
+ validates_presence_of self.slug_column
25
+ validates_uniqueness_of self.slug_column
26
+ before_validation_on_create :set_slug
27
+ end
28
+ end
29
+
30
+ # Sets the slug. Called before create.
31
+ def set_slug
32
+ self[self.slug_column] = self[self.slug_source]
33
+
34
+ strip_diacritics_from_slug
35
+ normalize_slug
36
+ assign_slug_sequence
37
+
38
+ self.errors.add(self.slug_column, "#{self.slug_column} cannot be blank. Is #{self.slug_source} sluggable?") if self[self.slug_column].blank?
39
+ end
40
+
41
+ # Overrides to_param to return the model's slug.
42
+ def to_param
43
+ self[self.slug_column]
44
+ end
45
+
46
+ def self.included(klass)
47
+ klass.extend(ClassMethods)
48
+ end
49
+
50
+ private
51
+ # Takes the slug, downcases it and replaces non-word characters with a -.
52
+ # Feel free to override this method if you'd like different slug formatting.
53
+ def normalize_slug
54
+ return if self[self.slug_column].blank?
55
+ s = ActiveSupport::Multibyte.proxy_class.new(self[self.slug_column]).normalize(:kc)
56
+ s.downcase!
57
+ s.strip!
58
+ s.gsub!(/[\W]/u, ' ') # Remove non-word characters
59
+ s.gsub!(/\s+/u, '-') # Convert whitespaces to dashes
60
+ s.gsub!(/-\z/u, '') # Remove trailing dashes
61
+ self[self.slug_column] = s.to_s
62
+ end
63
+
64
+ # Converts accented characters to their ASCII equivalents and removes them if they have no equivalent.
65
+ # Override this with a void function if you don't want accented characters to be stripped.
66
+ def strip_diacritics_from_slug
67
+ return if self[self.slug_column].blank?
68
+ s = ActiveSupport::Multibyte.proxy_class.new(self[self.slug_column])
69
+ s = s.normalize(:kd).unpack('U*')
70
+ s = s.inject([]) do |a,u|
71
+ if Slug::ASCII_APPROXIMATIONS[u]
72
+ a += Slug::ASCII_APPROXIMATIONS[u].unpack('U*')
73
+ elsif (u < 0x300 || u > 0x036F)
74
+ a << u
75
+ end
76
+ a
77
+ end
78
+ s = s.pack('U*')
79
+ s.gsub!(/[^a-z0-9]+/i, ' ')
80
+ self[self.slug_column] = s.to_s
81
+ end
82
+
83
+ # If a slug of the same name already exists, this will append '-n' to the end of the slug to
84
+ # make it unique. The second instance gets a '-1' suffix.
85
+ def assign_slug_sequence
86
+ return if self[self.slug_column].blank?
87
+ idx = next_slug_sequence
88
+ self[self.slug_column] = "#{self[self.slug_column]}-#{idx}" if idx > 0
89
+ end
90
+
91
+ # Returns the next unique index for a slug.
92
+ def next_slug_sequence
93
+ last_in_sequence = self.class.find(:first, :conditions => ["#{self.slug_column} LIKE ?", self[self.slug_column] + '%'],
94
+ :order => "CAST(REPLACE(#{self.slug_column},'#{self[self.slug_column]}','') AS UNSIGNED)")
95
+ if last_in_sequence.nil?
96
+ return 0
97
+ else
98
+ sequence_match = last_in_sequence[self.slug_column].match(/^#{self[self.slug_column]}(-(\d+))?/)
99
+ current = sequence_match.nil? ? 0 : sequence_match[2].to_i
100
+ return current + 1
101
+ end
102
+ end
103
+
104
+ end
data/lib/slug.rb ADDED
@@ -0,0 +1,6 @@
1
+ require File.join(File.dirname(__FILE__), 'slug', 'slug')
2
+ require File.join(File.dirname(__FILE__), 'slug', 'ascii_approximations')
3
+
4
+ if defined?(ActiveRecord)
5
+ ActiveRecord::Base.class_eval { include Slug }
6
+ end
data/test/schema.rb ADDED
@@ -0,0 +1,13 @@
1
+ ActiveRecord::Schema.define(:version => 1) do
2
+
3
+ create_table "articles", :force => true do |t|
4
+ t.column "headline", "string"
5
+ t.column "slug", "string"
6
+ end
7
+
8
+ create_table "people", :force => true do |t|
9
+ t.column "name", "string"
10
+ t.column "web_slug", "string"
11
+ end
12
+
13
+ end
@@ -0,0 +1,32 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ class Test::Unit::TestCase
7
+ end
8
+
9
+ # You can use "rake test AR_VERSION=2.0.5" to test against 2.0.5, for example.
10
+ # The default is to use the latest installed ActiveRecord.
11
+ if ENV["AR_VERSION"]
12
+ gem 'activerecord', "#{ENV["AR_VERSION"]}"
13
+ gem 'activesupport', "#{ENV["AR_VERSION"]}"
14
+ end
15
+ require 'active_record'
16
+ require 'active_support'
17
+
18
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
19
+ require 'slug'
20
+
21
+ ActiveRecord::Base.establish_connection :adapter => "sqlite3", :database => ":memory:"
22
+ silence_stream(STDOUT) do
23
+ load(File.dirname(__FILE__) + "/schema.rb")
24
+ end
25
+
26
+ class Article < ActiveRecord::Base
27
+ slug :headline
28
+ end
29
+
30
+ class Person < ActiveRecord::Base
31
+ slug :name, :column => :web_slug
32
+ end
data/test/test_slug.rb ADDED
@@ -0,0 +1,160 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class TestSlug < Test::Unit::TestCase
4
+
5
+ def setup
6
+ Article.delete_all
7
+ Person.delete_all
8
+ end
9
+
10
+ should "base slug on specified source column" do
11
+ article = Article.create!(:headline => 'Test Headline')
12
+ assert_equal 'test-headline', article.slug
13
+ end
14
+
15
+ context "slug column" do
16
+ should "save slug to 'slug' column by default" do
17
+ article = Article.create!(:headline => 'Test Headline')
18
+ assert_equal 'test-headline', article.slug
19
+ end
20
+
21
+ should "save slug to :column specified in options" do
22
+ person = Person.create!(:name => 'Test Person')
23
+ assert_equal 'test-person', person.web_slug
24
+ end
25
+ end
26
+
27
+ context "setup validations" do
28
+ teardown do
29
+ Person.slug(:name, :column => :web_slug) # Reset Person slug column to valid config.
30
+ end
31
+
32
+ should "raise ArgumentError if an invalid source column is passed" do
33
+ assert_raises(ArgumentError) { Person.slug(:invalid_source_column) }
34
+ end
35
+
36
+ should "raise an ArgumentError if an invalid slug column is passed" do
37
+ assert_raises(ArgumentError) { Person.slug(:name, :column => :bad_slug_column)}
38
+ end
39
+ end
40
+
41
+ should "set validation error if source column is empty" do
42
+ article = Article.create
43
+ assert !article.valid?
44
+ assert article.errors.on(:slug)
45
+ end
46
+
47
+ should "set validation error if normalization makes source value empty" do
48
+ article = Article.create(:headline => '---')
49
+ assert !article.valid?
50
+ assert article.errors.on(:slug)
51
+ end
52
+
53
+ should "not update the slug even if the source column changes" do
54
+ article = Article.create!(:headline => 'Test Headline')
55
+ article.update_attributes!(:headline => 'New Headline')
56
+ assert_equal 'test-headline', article.slug
57
+ end
58
+
59
+ context "slug normalization" do
60
+ setup do
61
+ @article = Article.new
62
+ end
63
+
64
+ should "should lowercase strings" do
65
+ @article.headline = 'AbC'
66
+ @article.save!
67
+ assert_equal "abc", @article.slug
68
+ end
69
+
70
+ should "should replace whitespace with dashes" do
71
+ @article.headline = 'a b'
72
+ @article.save!
73
+ assert_equal 'a-b', @article.slug
74
+ end
75
+
76
+ should "should replace 2spaces with 1dash" do
77
+ @article.headline = 'a b'
78
+ @article.save!
79
+ assert_equal 'a-b', @article.slug
80
+ end
81
+
82
+ should "should remove punctuation" do
83
+ @article.headline = 'abc!@#$%^&*•¶§∞¢££¡¿()><?"":;][]\.,/'
84
+ @article.save!
85
+ assert_match 'abc', @article.slug
86
+ end
87
+
88
+ should "should strip trailing space" do
89
+ @article.headline = 'ab '
90
+ @article.save!
91
+ assert_equal 'ab', @article.slug
92
+ end
93
+
94
+ should "should strip leading space" do
95
+ @article.headline = ' ab'
96
+ @article.save!
97
+ assert_equal 'ab', @article.slug
98
+ end
99
+
100
+ should "should strip trailing dashes" do
101
+ @article.headline = 'ab-'
102
+ @article.save!
103
+ assert_match 'ab', @article.slug
104
+ end
105
+
106
+ should "should strip leading dashes" do
107
+ @article.headline = '-ab'
108
+ @article.save!
109
+ assert_match 'ab', @article.slug
110
+ end
111
+
112
+ should "should not modify valid slug strings" do
113
+ @article.headline = 'a-b-c-d'
114
+ @article.save!
115
+ assert_match 'a-b-c-d', @article.slug
116
+ end
117
+ end
118
+
119
+ context "diacritics handling" do
120
+ setup do
121
+ @article = Article.new
122
+ end
123
+
124
+ should "should strip diacritics" do
125
+ @article.headline = "açaí"
126
+ @article.save!
127
+ assert_equal "acai", @article.slug
128
+ end
129
+
130
+ should "strip diacritics correctly " do
131
+ @article.headline = "ÀÁÂÃÄÅÆÇÈÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ"
132
+ @article.save!
133
+ expected = "aaaaaaaeceeeiiiidnoooooouuuuythssaaaaaaaeceeeeiiiidnoooooouuuuythy".split(//)
134
+ output = @article.slug.split(//)
135
+ output.each_index do |i|
136
+ assert_equal expected[i], output[i]
137
+ end
138
+ end
139
+ end
140
+
141
+ context "sequence handling" do
142
+ should "not add a sequence if saving first instance of slug" do
143
+ article = Article.create!(:headline => 'Test Headline')
144
+ assert_equal 'test-headline', article.slug
145
+ end
146
+
147
+ should "assign a -1 suffix to the second instance of the slug" do
148
+ article_1 = Article.create!(:headline => 'Test Headline')
149
+ article_2 = Article.create!(:headline => 'Test Headline')
150
+ assert_equal 'test-headline-1', article_2.slug
151
+ end
152
+
153
+ should "assign a -12 suffix to the thirteenth instance of the slug" do
154
+ 12.times { |i| Article.create!(:headline => 'Test Headline') }
155
+ article_13 = Article.create!(:headline => 'Test Headline')
156
+ assert_equal 'test-headline-12', article_13.slug
157
+ end
158
+ end
159
+
160
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slug
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Ben Koski
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-08-31 00:00:00 -04:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: activerecord
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: activesupport
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ description: Simple, straightforward slugs for your ActiveRecord models.
36
+ email: ben.koski@gmail.com
37
+ executables: []
38
+
39
+ extensions: []
40
+
41
+ extra_rdoc_files: []
42
+
43
+ files:
44
+ - VERSION.yml
45
+ - lib/slug/ascii_approximations.rb
46
+ - lib/slug/slug.rb
47
+ - lib/slug.rb
48
+ - test/schema.rb
49
+ - test/test_helper.rb
50
+ - test/test_slug.rb
51
+ has_rdoc: true
52
+ homepage: http://github.com/bkoski/slug
53
+ licenses: []
54
+
55
+ post_install_message:
56
+ rdoc_options:
57
+ - --inline-source
58
+ - --charset=UTF-8
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: "0"
66
+ version:
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: "0"
72
+ version:
73
+ requirements: []
74
+
75
+ rubyforge_project:
76
+ rubygems_version: 1.3.5
77
+ signing_key:
78
+ specification_version: 2
79
+ summary: Simple, straightforward slugs for your ActiveRecord models.
80
+ test_files: []
81
+