slug 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.
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
+