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 +4 -0
- data/lib/slug/ascii_approximations.rb +14 -0
- data/lib/slug/slug.rb +104 -0
- data/lib/slug.rb +6 -0
- data/test/schema.rb +13 -0
- data/test/test_helper.rb +32 -0
- data/test/test_slug.rb +160 -0
- metadata +81 -0
data/VERSION.yml
ADDED
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
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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|
+
|