slugalicious 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +26 -0
- data/.rspec +1 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +107 -0
- data/LICENSE +20 -0
- data/README.textile +197 -0
- data/Rakefile +37 -0
- data/VERSION +1 -0
- data/lib/slugalicious.rb +242 -0
- data/lib/slugalicious_generator.rb +23 -0
- data/slugalicious.gemspec +68 -0
- data/spec/factories.rb +14 -0
- data/spec/slug_spec.rb +65 -0
- data/spec/slugalicious_spec.rb +232 -0
- data/spec/spec_helper.rb +45 -0
- data/templates/create_slugs.rb +20 -0
- data/templates/slug.rb +63 -0
- metadata +112 -0
data/.document
ADDED
data/.gitignore
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
## MAC OS
|
2
|
+
.DS_Store
|
3
|
+
|
4
|
+
## TEXTMATE
|
5
|
+
*.tmproj
|
6
|
+
tmtags
|
7
|
+
|
8
|
+
## EMACS
|
9
|
+
*~
|
10
|
+
\#*
|
11
|
+
.\#*
|
12
|
+
|
13
|
+
## VIM
|
14
|
+
*.swp
|
15
|
+
|
16
|
+
## PROJECT::GENERAL
|
17
|
+
coverage
|
18
|
+
rdoc
|
19
|
+
pkg
|
20
|
+
.bundle
|
21
|
+
.rvmrc
|
22
|
+
test.sqlite
|
23
|
+
|
24
|
+
## PROJECT::DOCUMENTATION
|
25
|
+
.yardoc
|
26
|
+
doc
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
-cfs
|
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
source :rubygems
|
2
|
+
|
3
|
+
# DEPENDENCIES
|
4
|
+
gem 'rails', '>= 3.0'
|
5
|
+
gem 'stringex'
|
6
|
+
|
7
|
+
# DEVELOPMENT
|
8
|
+
gem 'jeweler'
|
9
|
+
gem 'yard'
|
10
|
+
gem 'RedCloth', require: 'redcloth'
|
11
|
+
gem 'sqlite3'
|
12
|
+
|
13
|
+
# TEST
|
14
|
+
gem 'rspec'
|
15
|
+
group :test do
|
16
|
+
gem 'factory_girl'
|
17
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
RedCloth (4.2.3)
|
5
|
+
abstract (1.0.0)
|
6
|
+
actionmailer (3.0.1)
|
7
|
+
actionpack (= 3.0.1)
|
8
|
+
mail (~> 2.2.5)
|
9
|
+
actionpack (3.0.1)
|
10
|
+
activemodel (= 3.0.1)
|
11
|
+
activesupport (= 3.0.1)
|
12
|
+
builder (~> 2.1.2)
|
13
|
+
erubis (~> 2.6.6)
|
14
|
+
i18n (~> 0.4.1)
|
15
|
+
rack (~> 1.2.1)
|
16
|
+
rack-mount (~> 0.6.12)
|
17
|
+
rack-test (~> 0.5.4)
|
18
|
+
tzinfo (~> 0.3.23)
|
19
|
+
activemodel (3.0.1)
|
20
|
+
activesupport (= 3.0.1)
|
21
|
+
builder (~> 2.1.2)
|
22
|
+
i18n (~> 0.4.1)
|
23
|
+
activerecord (3.0.1)
|
24
|
+
activemodel (= 3.0.1)
|
25
|
+
activesupport (= 3.0.1)
|
26
|
+
arel (~> 1.0.0)
|
27
|
+
tzinfo (~> 0.3.23)
|
28
|
+
activeresource (3.0.1)
|
29
|
+
activemodel (= 3.0.1)
|
30
|
+
activesupport (= 3.0.1)
|
31
|
+
activesupport (3.0.1)
|
32
|
+
arel (1.0.1)
|
33
|
+
activesupport (~> 3.0.0)
|
34
|
+
builder (2.1.2)
|
35
|
+
diff-lcs (1.1.2)
|
36
|
+
erubis (2.6.6)
|
37
|
+
abstract (>= 1.0.0)
|
38
|
+
factory_girl (1.3.2)
|
39
|
+
ffi (0.6.3)
|
40
|
+
rake (>= 0.8.7)
|
41
|
+
gemcutter (0.6.1)
|
42
|
+
git (1.2.5)
|
43
|
+
i18n (0.4.2)
|
44
|
+
jeweler (1.4.0)
|
45
|
+
gemcutter (>= 0.1.0)
|
46
|
+
git (>= 1.2.5)
|
47
|
+
rubyforge (>= 2.0.0)
|
48
|
+
json_pure (1.4.6)
|
49
|
+
mail (2.2.9)
|
50
|
+
activesupport (>= 2.3.6)
|
51
|
+
i18n (~> 0.4.1)
|
52
|
+
mime-types (~> 1.16)
|
53
|
+
treetop (~> 1.4.8)
|
54
|
+
mime-types (1.16)
|
55
|
+
polyglot (0.3.1)
|
56
|
+
rack (1.2.1)
|
57
|
+
rack-mount (0.6.13)
|
58
|
+
rack (>= 1.0.0)
|
59
|
+
rack-test (0.5.6)
|
60
|
+
rack (>= 1.0)
|
61
|
+
rails (3.0.1)
|
62
|
+
actionmailer (= 3.0.1)
|
63
|
+
actionpack (= 3.0.1)
|
64
|
+
activerecord (= 3.0.1)
|
65
|
+
activeresource (= 3.0.1)
|
66
|
+
activesupport (= 3.0.1)
|
67
|
+
bundler (~> 1.0.0)
|
68
|
+
railties (= 3.0.1)
|
69
|
+
railties (3.0.1)
|
70
|
+
actionpack (= 3.0.1)
|
71
|
+
activesupport (= 3.0.1)
|
72
|
+
rake (>= 0.8.4)
|
73
|
+
thor (~> 0.14.0)
|
74
|
+
rake (0.8.7)
|
75
|
+
rspec (2.0.1)
|
76
|
+
rspec-core (~> 2.0.1)
|
77
|
+
rspec-expectations (~> 2.0.1)
|
78
|
+
rspec-mocks (~> 2.0.1)
|
79
|
+
rspec-core (2.0.1)
|
80
|
+
rspec-expectations (2.0.1)
|
81
|
+
diff-lcs (>= 1.1.2)
|
82
|
+
rspec-mocks (2.0.1)
|
83
|
+
rspec-core (~> 2.0.1)
|
84
|
+
rspec-expectations (~> 2.0.1)
|
85
|
+
rubyforge (2.0.4)
|
86
|
+
json_pure (>= 1.1.7)
|
87
|
+
sqlite3 (0.1.1)
|
88
|
+
ffi (>= 0.6.3)
|
89
|
+
stringex (1.2.0)
|
90
|
+
thor (0.14.3)
|
91
|
+
treetop (1.4.8)
|
92
|
+
polyglot (>= 0.3.1)
|
93
|
+
tzinfo (0.3.23)
|
94
|
+
yard (0.6.1)
|
95
|
+
|
96
|
+
PLATFORMS
|
97
|
+
ruby
|
98
|
+
|
99
|
+
DEPENDENCIES
|
100
|
+
RedCloth
|
101
|
+
factory_girl
|
102
|
+
jeweler
|
103
|
+
rails (>= 3.0)
|
104
|
+
rspec
|
105
|
+
sqlite3
|
106
|
+
stringex
|
107
|
+
yard
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Tim Morgan
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
h1. Slugalicious -- Easy and powerful URL slugging for Rails 3
|
2
|
+
|
3
|
+
_*(no monkey-patching required)*_
|
4
|
+
|
5
|
+
| *Author* | Tim Morgan |
|
6
|
+
| *Version* | 1.0 (Oct 30, 2010) |
|
7
|
+
| *License* | Released under the MIT license. |
|
8
|
+
|
9
|
+
h2. About
|
10
|
+
|
11
|
+
Slugalicious is an easy-to-use slugging library that helps you generate pretty
|
12
|
+
URLs for your ActiveRecord objects. It's built for Rails 3 and is cordoned off
|
13
|
+
in a monkey patching-free zone.
|
14
|
+
|
15
|
+
Slugalicious is easy to use and powerful enough to cover all of the most common
|
16
|
+
use-cases for slugging. Slugs are stored in a separate table, meaning you don't
|
17
|
+
have to make schema changes to your models, and you can change slugs while still
|
18
|
+
keeping the old URLs around for redirecting purposes.
|
19
|
+
|
20
|
+
Slugalicious is an intelligent slug generator: You can specify multiple ways to
|
21
|
+
generate slugs, and Slugalicious will try them all until it finds one that
|
22
|
+
generates a unique slug. If all else fails, Slugalicious will fall back on a
|
23
|
+
less pretty but guaranteed-unique backup slug generation strategy.
|
24
|
+
|
25
|
+
Slugalicious works with the Stringex Ruby library, meaning you get meaningful
|
26
|
+
slugs via the @String#to_url@ method. Below are two examples of how powerful
|
27
|
+
Stringex is:
|
28
|
+
|
29
|
+
<pre><code>
|
30
|
+
"$6 Dollar Burger".to_url #=> "six-dollar-burger"
|
31
|
+
"新年好".to_url #=> "xin-nian-hao"
|
32
|
+
</code></pre>
|
33
|
+
|
34
|
+
h2. Installation
|
35
|
+
|
36
|
+
*Important Note:* Slugalicious is written for Rails 3.0 and Ruby 1.9 only.
|
37
|
+
|
38
|
+
Firstly, add the gem to your Rails project's @Gemfile@:
|
39
|
+
|
40
|
+
<pre><code>
|
41
|
+
gem 'slugalicious'
|
42
|
+
</code></pre>
|
43
|
+
|
44
|
+
Next, use the generator to add the @Slug@ model and its migration to your
|
45
|
+
project:
|
46
|
+
|
47
|
+
<pre><code>
|
48
|
+
rails generate slugalicious
|
49
|
+
</code></pre>
|
50
|
+
|
51
|
+
Then run the migration to set up your database.
|
52
|
+
|
53
|
+
h2. Usage
|
54
|
+
|
55
|
+
For any model you want to slug, include the @Slugalicious@ module and call
|
56
|
+
@slugged@:
|
57
|
+
|
58
|
+
<pre><code>
|
59
|
+
class User < ActiveRecord::Base
|
60
|
+
include Slugalicious
|
61
|
+
slugged ->(user) { "#{user.first_name} #{user.last_name}" }
|
62
|
+
end
|
63
|
+
</code></pre>
|
64
|
+
|
65
|
+
Doing this sets the @to_param@ method, so you can go ahead and start generating
|
66
|
+
URLs using your models. You can use the @find_from_slug@ method to load a record
|
67
|
+
from a slug:
|
68
|
+
|
69
|
+
<pre><code>
|
70
|
+
user = User.find_from_slug(params[:id])
|
71
|
+
</code></pre>
|
72
|
+
|
73
|
+
h3. Multiple slug generators
|
74
|
+
|
75
|
+
The @slugged@ method takes a list of method names (as symbols) or @Procs@ that
|
76
|
+
each attempt to generate a slug. Each of these generators is tried in order
|
77
|
+
until a unique slug is generated. (The output of each of these generators is run
|
78
|
+
through the slugifier to convert it to a URL-safe string. The slugifier is by
|
79
|
+
default @String#to_url@, provided by the Stringex gem.)
|
80
|
+
|
81
|
+
So, if we had our @User@ class, and we first wanted to slug by last name only,
|
82
|
+
but then add in the first name if two people share a last name, we'd call
|
83
|
+
@slugged@ like so:
|
84
|
+
|
85
|
+
<pre><code>
|
86
|
+
slugged :last_name, ->(user) { "#{user.first_name} #{user.last_name}" }
|
87
|
+
</code></pre>
|
88
|
+
|
89
|
+
In the event that none of these generators manages to make a unique slug, a
|
90
|
+
fallback generator is used. This generator prepends the ID of the record, making
|
91
|
+
it guaranteed unique. Let's use the example generators shown above. If we create
|
92
|
+
a user with the name "Sancho Sample", he will get the slug "sample". Create
|
93
|
+
another user with the same name, and that user will get the slug
|
94
|
+
"sancho-sample;2". The semicolon is the default ID separator (and it can be
|
95
|
+
overridden).
|
96
|
+
|
97
|
+
h3. Scoped slugs
|
98
|
+
|
99
|
+
Slugs must normally be unique for a single model type. Thus, if you have a
|
100
|
+
@User@ named Hammer and a @Product@ named hammer, they can both share the
|
101
|
+
"hammer" slug.
|
102
|
+
|
103
|
+
If you want to decrease the uniqueness scope of a slug, you can do so with the
|
104
|
+
@:scope@ option on the @slugged@ method. Let's say you wanted to limit the scope
|
105
|
+
of a @Product@'s slug to its associated @Department@; that way you could have a
|
106
|
+
product named "keyboard" in both the Computer Supplies and the Music Supplies
|
107
|
+
departments. To do so, override the @:scope@ option with a method name (as
|
108
|
+
symbol) or a @Proc@ that limits the scope of the uniqueness requirement:
|
109
|
+
|
110
|
+
<pre><code>
|
111
|
+
class Product < ActiveRecord::Base
|
112
|
+
include Slugalicious
|
113
|
+
belongs_to :department
|
114
|
+
slugged :name, scope: :department_url_component
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def department_url_component
|
119
|
+
department.name.to_url + "/"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
</code></pre>
|
123
|
+
|
124
|
+
Now, your computer keyboard's slug will be "computer-supplies/keyboard" and your
|
125
|
+
piano keyboard's slug will be "music-supplies/keyboard". There's an important
|
126
|
+
thing to notice here: The method or proc you use to scope the slug must return a
|
127
|
+
proper URL substring. That typically means you need to URL-escape it and add a
|
128
|
+
slash at the end, as shown in the example above.
|
129
|
+
|
130
|
+
When you call @to_param@ on your piano keyboard, instead of just "keyboard", you
|
131
|
+
will get "music-supplies/keyboard". Likewise, you can use the
|
132
|
+
@find_from_slug_path@ method to find a record from its full path, slug and scope
|
133
|
+
included. You would usually use this method in conjunction with route globbing.
|
134
|
+
For example, we could set up our @routes.rb@ file like so:
|
135
|
+
|
136
|
+
<pre><code>
|
137
|
+
get '/products/*path', 'products#show', as: :products
|
138
|
+
</code></pre>
|
139
|
+
|
140
|
+
Then, in our @ProductsController@, we load the product from the path slug like
|
141
|
+
so:
|
142
|
+
|
143
|
+
<pre><code>
|
144
|
+
def find_product
|
145
|
+
@product = Product.find_from_slug_path(params[:path])
|
146
|
+
end
|
147
|
+
</code></pre>
|
148
|
+
|
149
|
+
This is why it's very convenient to have your @:scope@ method/proc not only
|
150
|
+
return the uniqueness constraint, but also the scoped portion of the URL
|
151
|
+
preceding the slug.
|
152
|
+
|
153
|
+
h3. Altering and expiring slugs
|
154
|
+
|
155
|
+
When a model is created, it gets one slug, marked as the active slug (by
|
156
|
+
default). This slug is the first generator that produces a unique slug string.
|
157
|
+
|
158
|
+
If a model is updated, its slug is regenerated. Each of the slug generators is
|
159
|
+
invoked, and if any of them produces an existing slug assigned to the object,
|
160
|
+
that slug is made the active slug. (Priority goes to the first slug generator
|
161
|
+
that produces an existing slug [active or inactive]).
|
162
|
+
|
163
|
+
If none of the slug generators generates a known, existing slug belonging to the
|
164
|
+
object, then the first unique slug is used. A new @Slug@ instance is created and
|
165
|
+
marked as active, and any other slugs belonging to the object are marked as
|
166
|
+
inactive.
|
167
|
+
|
168
|
+
Inactive slugs do not act any differently from active slugs. An object can be
|
169
|
+
found by its inactive slug just as well as its active slug. The flag is there so
|
170
|
+
you can alter the behavior of your application depending on whether the slug is
|
171
|
+
current.
|
172
|
+
|
173
|
+
A common application of this is to have inactive slugs 301-redirect to the
|
174
|
+
active slug, as a way of both updating search engines' indexes and ensuring that
|
175
|
+
people know the URL has changed. As an example of how do this, we alter the
|
176
|
+
@find_product@ method shown above to be like so:
|
177
|
+
|
178
|
+
<pre><code>
|
179
|
+
def find_product
|
180
|
+
@product = Product.find_from_slug_path(params[:path])
|
181
|
+
unless @product.active_slug?(params[:path].split('/').last)
|
182
|
+
redirect_to product_url(@product), status: :moved_permanently
|
183
|
+
return false
|
184
|
+
end
|
185
|
+
return true
|
186
|
+
end
|
187
|
+
</code></pre>
|
188
|
+
|
189
|
+
The old URL will remain indefinitely, but users who hit it will be redirected to the new URL. Ideally, links to the old URL will be replaced over time with links to the new URL.
|
190
|
+
|
191
|
+
The problem is that even though the old slug is inactive, it's still "taken." If you create a product called "Keyboard", but then rename it to "Piano", the product will claim both the "keyboard" and "piano" slugs. If you had renamed it to make room for a different product called "Keyboard" (like a computer keyboard), you'd find its slug is "keyboard;2" or similar.
|
192
|
+
|
193
|
+
To prevent the slug namespace from becoming more and more polluted over time, websites generally expire inactive slugs after a period of time. To do this in Slugalicious, write a task that periodically checks for and deletes old, inactive @Slug@ records. Such a task could be invoked through a cron job, for instance. An example:
|
194
|
+
|
195
|
+
<pre><code>
|
196
|
+
Slug.inactive.where([ "created_at < ?", 30.days.ago ]).delete_all
|
197
|
+
</code></pre>
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'rake'
|
2
|
+
begin
|
3
|
+
require 'bundler'
|
4
|
+
rescue LoadError
|
5
|
+
puts "Bundler is not installed; install with `gem install bundler`."
|
6
|
+
exit 1
|
7
|
+
end
|
8
|
+
|
9
|
+
Bundler.require :default
|
10
|
+
|
11
|
+
Jeweler::Tasks.new do |gem|
|
12
|
+
gem.name = "slugalicious"
|
13
|
+
gem.summary = %Q{Easy-to-use and powerful slugging for Rails 3}
|
14
|
+
gem.description = %Q{Slugalicious adds simple and powerful slugging to your ActiveRecord models.}
|
15
|
+
gem.email = "git@timothymorgan.info"
|
16
|
+
gem.homepage = "http://github.com/riscfuture/slugalicious"
|
17
|
+
gem.authors = [ "Tim Morgan" ]
|
18
|
+
gem.required_ruby_version = '>= 1.9'
|
19
|
+
gem.add_dependency "rails", ">= 3.0"
|
20
|
+
gem.add_dependency 'stringex'
|
21
|
+
end
|
22
|
+
Jeweler::GemcutterTasks.new
|
23
|
+
|
24
|
+
require 'rspec/core/rake_task'
|
25
|
+
RSpec::Core::RakeTask.new
|
26
|
+
|
27
|
+
YARD::Rake::YardocTask.new('doc') do |doc|
|
28
|
+
doc.options << "-m" << "textile"
|
29
|
+
doc.options << "--protected"
|
30
|
+
doc.options << "-r" << "README.textile"
|
31
|
+
doc.options << "-o" << "doc"
|
32
|
+
doc.options << "--title" << "Slugalicious Documentation".inspect
|
33
|
+
|
34
|
+
doc.files = [ 'lib/**/*', 'README.textile', 'templates/slug.rb' ]
|
35
|
+
end
|
36
|
+
|
37
|
+
task(default: :spec)
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/lib/slugalicious.rb
ADDED
@@ -0,0 +1,242 @@
|
|
1
|
+
require 'slugalicious_generator'
|
2
|
+
require 'stringex'
|
3
|
+
|
4
|
+
# Adds the @slugged@ method to an @ActiveRecord::Base@ subclass. You can then
|
5
|
+
# call this method to add slugging support to your model. See the
|
6
|
+
# {ClassMethods#slugged} method for more details.
|
7
|
+
#
|
8
|
+
# @example Basic example of a slugged model
|
9
|
+
# class Widget < ActiveRecord::Base
|
10
|
+
# include Slugalicious
|
11
|
+
# slugged :title
|
12
|
+
# end
|
13
|
+
|
14
|
+
module Slugalicious
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
|
17
|
+
# The maximum length of a slug.
|
18
|
+
MAX_SLUG_LENGTH = 126
|
19
|
+
|
20
|
+
included do
|
21
|
+
extend ActiveSupport::Memoizable
|
22
|
+
memoize :slug, :active_slug?
|
23
|
+
alias_method :to_param, :slug_with_path
|
24
|
+
has_many :slugs, as: :sluggable
|
25
|
+
end
|
26
|
+
|
27
|
+
# Methods added to the class when this module is included.
|
28
|
+
|
29
|
+
module ClassMethods
|
30
|
+
|
31
|
+
# Locates a record matching a given slug.
|
32
|
+
#
|
33
|
+
# @param [String] slug The slug to locate.
|
34
|
+
# @param [String] scope The scope to search in (for use with scoped-unique
|
35
|
+
# slugs). This should be a string equal to the portion of the URL path
|
36
|
+
# preceding the slug.
|
37
|
+
# @return [ActiveRecord::Base] The object with that slug.
|
38
|
+
# @raise [ActiveRecord::RecordNotFound] If no object with that slug is
|
39
|
+
# found.
|
40
|
+
|
41
|
+
def find_from_slug(slug, scope=nil)
|
42
|
+
Slug.from_slug(self, scope, slug).first.try(:sluggable) || raise(ActiveRecord::RecordNotFound)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Locates a record from a given path, that consists of a slug and its scope,
|
46
|
+
# as would appear in a URL path component.
|
47
|
+
#
|
48
|
+
# @param [String] path The scope and slug concatenated together.
|
49
|
+
# @return [ActiveRecord::Base] The object with that slug.
|
50
|
+
# @raise [ActiveRecord::RecordNotFound] If no object with that slug is
|
51
|
+
# found.
|
52
|
+
|
53
|
+
def find_from_slug_path(path)
|
54
|
+
slug = path.split('/').last
|
55
|
+
scope = path[0..(-(slug.size + 1))]
|
56
|
+
find_from_slug slug, scope
|
57
|
+
end
|
58
|
+
|
59
|
+
protected
|
60
|
+
|
61
|
+
# Call this method to indicate that your model uses slugging. Pass a list of
|
62
|
+
# *slug generators*: either symbols (method names) or procs that return
|
63
|
+
# strings. These strings will be used to generate the slug. You must pass at
|
64
|
+
# least one generator. If you pass more than one, the first one that returns
|
65
|
+
# a unique slug will be used.
|
66
|
+
#
|
67
|
+
# The generator does not need to sanitize or parameterize its output; the
|
68
|
+
# @:slugifier@ option can be used to override the default parameterization.
|
69
|
+
#
|
70
|
+
# In the event that no generator returns a unique slug, the slug returned by
|
71
|
+
# the last generator will have the ID of the record appended to it. The ID
|
72
|
+
# and the slug will be separated by the @:id_separator@ option (semicolon by
|
73
|
+
# default). _This_ slug is hopefully unique, because if not, an exception is
|
74
|
+
# raised.
|
75
|
+
#
|
76
|
+
# Slugs are automatically generated before validation and updated when
|
77
|
+
# necessary.
|
78
|
+
#
|
79
|
+
# h2. Scopes
|
80
|
+
#
|
81
|
+
# You can scope your slugs to certain URL subpaths using the @:scope@
|
82
|
+
# option. The @:scope:@ option takes a method name or a @Proc@ that, when
|
83
|
+
# run, returns a string that scopes the uniqueness constraint of a slug.
|
84
|
+
# Rather than being globally unique, the slug must only be unique among
|
85
|
+
# other slugs that share the same scope.
|
86
|
+
#
|
87
|
+
# *Important note:* The method or @Proc@ that you use for the @:scope@
|
88
|
+
# option should return the portion of the URL preceding the slug, _slash
|
89
|
+
# included_. Let's say you have slugged your @User@ model's @login@ field,
|
90
|
+
# and you have two scopes: customers and merchants. In that case, you would
|
91
|
+
# want the @:scope@ method/proc to return either "clients/" or "merchants/".
|
92
|
+
#
|
93
|
+
# The string returned by the @:scope@ option will be used to build the full
|
94
|
+
# URL to an object. If you have a client @User@ with login "fancylad", a
|
95
|
+
# call to @to_param@ will return "clients/fancyland". The scope portion of
|
96
|
+
# that URL path is used un-sanitized, un-escaped, and un-processed. It is
|
97
|
+
# therefore up to _you_ to ensure your scopes are valid URL strings, using
|
98
|
+
# say @String#to_url@ (included as part of this gem).
|
99
|
+
#
|
100
|
+
# @overload slugged(generator, ..., options={})
|
101
|
+
# @param [Proc, Symbol] generator If it's a @Symbol@, indicates a method
|
102
|
+
# that will be called that will return a @String@ to be used for the
|
103
|
+
# slug.
|
104
|
+
# @param [Hash] options Additonal options that control slug generation.
|
105
|
+
# @option options [Proc] :slugifier (&:to_url) A proc that, when given a
|
106
|
+
# string, produces a URL-safe slugged version of that string.
|
107
|
+
# @option options [String] :id_separator (';') A separator to be used in
|
108
|
+
# the "last-resort" slug between the slug and the model ID. This should
|
109
|
+
# be an URL-safe character that would never be produced by your
|
110
|
+
# slugifier.
|
111
|
+
# @option options [Symbol, Proc] :scope A method name or @Proc@ to run
|
112
|
+
# (receives the object being slugged) that returns a string. Slugs must
|
113
|
+
# be unique across all objects for which this method/proc returns the
|
114
|
+
# same value. If not provided, slugs must be globally unique for this
|
115
|
+
# model. The string returned should be equal to the portion of the URL
|
116
|
+
# path that precedes the slug.
|
117
|
+
# @option options [Array<String>, String] :blacklist ([ 'new', 'edit', 'delete' ])
|
118
|
+
# A list of slugs that are disallowed. You would use this to prevent
|
119
|
+
# slugs from sharing the same name as actions in your resource
|
120
|
+
# controller.
|
121
|
+
# @raise [ArgumentError] If no generators are provided.
|
122
|
+
|
123
|
+
def slugged(*slug_procs)
|
124
|
+
options = slug_procs.extract_options!
|
125
|
+
raise ArgumentError, "Must provide at least one field or proc to slug" if slug_procs.empty?
|
126
|
+
|
127
|
+
class_inheritable_array :_slug_procs, :_slug_blacklist
|
128
|
+
class_inheritable_accessor :_slugifier, :_slug_id_separator, :_slug_scope
|
129
|
+
|
130
|
+
self._slug_procs = slug_procs.map { |slug_proc| slug_proc.kind_of?(Symbol) ? ->(obj) { obj.send(slug_proc) } : slug_proc }
|
131
|
+
self._slugifier = options[:slugifier] || ->(string) { string.to_url }
|
132
|
+
self._slug_id_separator = options[:id_separator] || ';'
|
133
|
+
self._slug_scope = if options[:scope].kind_of?(Symbol) then
|
134
|
+
->(record) { record.send(options[:scope]).to_s }
|
135
|
+
elsif options[:scope].kind_of?(Proc) then
|
136
|
+
options[:scope]
|
137
|
+
elsif options[:scope] then
|
138
|
+
raise ArgumentError, ":scope must be a symbol or proc"
|
139
|
+
end
|
140
|
+
self._slug_blacklist = Array.wrap(options[:blacklist] || %w( new edit delete ))
|
141
|
+
|
142
|
+
after_save :make_slug
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Methods added to instances when this module is included.
|
147
|
+
|
148
|
+
module InstanceMethods
|
149
|
+
|
150
|
+
# @return [String, nil] The slug for this object, or @nil@ if none has been
|
151
|
+
# assigned.
|
152
|
+
|
153
|
+
def slug
|
154
|
+
(slugs.loaded? ? slugs.detect(&:active?) : slugs.active.first).try(:slug)
|
155
|
+
end
|
156
|
+
|
157
|
+
# @return [String, nil] The full slug and path for this object, with scope
|
158
|
+
# included, or @nil@ if none has been assigned.
|
159
|
+
|
160
|
+
def slug_with_path
|
161
|
+
slug = slugs.loaded? ? slugs.detect(&:active) : slugs.active.first
|
162
|
+
if slug then
|
163
|
+
slug.scope.to_s + slug.slug
|
164
|
+
else
|
165
|
+
nil
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# @param [String] slug A slug for this object.
|
170
|
+
# @return [true, false, nil] @true@ if the slug is the currently active one
|
171
|
+
# (should not redirect), @false@ if it's inactive (should redirect), and
|
172
|
+
# @nil@ if it's not a known slug for the object (should 404).
|
173
|
+
|
174
|
+
def active_slug?(slug)
|
175
|
+
slug = if slugs.loaded? then
|
176
|
+
slugs.detect { |s| s.slug.downcase == slug.downcase }
|
177
|
+
else
|
178
|
+
slugs.where(slug: slug).first
|
179
|
+
end
|
180
|
+
if slug then
|
181
|
+
slug.active?
|
182
|
+
else
|
183
|
+
nil
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def make_slug
|
190
|
+
slugs_in_use = if slugs.loaded? then
|
191
|
+
slugs.map(&:slug)
|
192
|
+
else
|
193
|
+
slugs.select(:slug).all.map(&:slug)
|
194
|
+
end
|
195
|
+
|
196
|
+
# grab a list of all potential slugs derived from the generators
|
197
|
+
potential_slugs = self.class._slug_procs.map { |slug_proc| slug_proc[self] }.
|
198
|
+
compact.
|
199
|
+
map { |slug| self.class._slugifier[slug] }.
|
200
|
+
map { |slug| slug[0, MAX_SLUG_LENGTH] }
|
201
|
+
raise "All slug generators returned nil for #{self.inspect}" if potential_slugs.empty?
|
202
|
+
# include the last-resort slug, trimmed for length
|
203
|
+
last_resort_append = "#{self.class._slug_id_separator}#{id}"
|
204
|
+
potential_slugs << "#{potential_slugs.first[0, [ 1, MAX_SLUG_LENGTH - last_resort_append.length ].max]}#{last_resort_append}"[0, MAX_SLUG_LENGTH]
|
205
|
+
# subtract out blacklisted slugs
|
206
|
+
potential_slugs -= self.class._slug_blacklist
|
207
|
+
|
208
|
+
# if one of these slugs is already in use, we don't need to change the slug
|
209
|
+
# instead, activate the one of highest prioirty and we're done
|
210
|
+
valid_slugs_in_use = potential_slugs & slugs_in_use
|
211
|
+
unless valid_slugs_in_use.empty?
|
212
|
+
Slug.transaction do
|
213
|
+
slugs.update_all(active: false)
|
214
|
+
slugs.where(slug: valid_slugs_in_use.first).update_all(active: true)
|
215
|
+
end
|
216
|
+
return
|
217
|
+
end
|
218
|
+
|
219
|
+
Slug.transaction do
|
220
|
+
# grab a list of all the slugs we can't use
|
221
|
+
scope = Slug.select(:slug).where(sluggable_type: self.class.to_s, slug: potential_slugs)
|
222
|
+
if self.class._slug_scope then
|
223
|
+
scope = scope.where(scope: self.class._slug_scope[self])
|
224
|
+
end
|
225
|
+
taken_slug_objects = scope.all
|
226
|
+
|
227
|
+
# subtract them out from all the potential slugs to make the available slugs
|
228
|
+
available_slugs = potential_slugs - taken_slug_objects.map(&:slug)
|
229
|
+
# no slugs available? nothing much else we can do
|
230
|
+
raise "Couldn't find a slug for #{self.inspect}; tried #{potential_slugs.join(', ')}" if available_slugs.empty?
|
231
|
+
|
232
|
+
slugs.update_all(active: false)
|
233
|
+
Slug.create!(sluggable: self,
|
234
|
+
slug: available_slugs.first,
|
235
|
+
active: true,
|
236
|
+
scope: self.class._slug_scope.try(:call, self))
|
237
|
+
end
|
238
|
+
|
239
|
+
unmemoize_all
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators'
|
3
|
+
require 'rails/generators/migration'
|
4
|
+
|
5
|
+
# @private
|
6
|
+
class SlugaliciousGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
source_root "#{File.dirname __FILE__}/../templates"
|
10
|
+
|
11
|
+
def self.next_migration_number(dirname)
|
12
|
+
if ActiveRecord::Base.timestamped_migrations then
|
13
|
+
Time.now.utc.strftime "%Y%m%d%H%M%S"
|
14
|
+
else
|
15
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def copy_files
|
20
|
+
copy_file 'slug.rb', 'app/models/slug.rb'
|
21
|
+
migration_template "create_slugs.rb", "db/migrate/create_slugs.rb"
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{slugalicious}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Tim Morgan"]
|
12
|
+
s.date = %q{2010-10-30}
|
13
|
+
s.description = %q{Slugalicious adds simple and powerful slugging to your ActiveRecord models.}
|
14
|
+
s.email = %q{git@timothymorgan.info}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.textile"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".gitignore",
|
22
|
+
".rspec",
|
23
|
+
"Gemfile",
|
24
|
+
"Gemfile.lock",
|
25
|
+
"LICENSE",
|
26
|
+
"README.textile",
|
27
|
+
"Rakefile",
|
28
|
+
"VERSION",
|
29
|
+
"lib/slugalicious.rb",
|
30
|
+
"lib/slugalicious_generator.rb",
|
31
|
+
"slugalicious.gemspec",
|
32
|
+
"spec/factories.rb",
|
33
|
+
"spec/slug_spec.rb",
|
34
|
+
"spec/slugalicious_spec.rb",
|
35
|
+
"spec/spec_helper.rb",
|
36
|
+
"templates/create_slugs.rb",
|
37
|
+
"templates/slug.rb"
|
38
|
+
]
|
39
|
+
s.homepage = %q{http://github.com/riscfuture/slugalicious}
|
40
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
41
|
+
s.require_paths = ["lib"]
|
42
|
+
s.required_ruby_version = Gem::Requirement.new(">= 1.9")
|
43
|
+
s.rubygems_version = %q{1.3.7}
|
44
|
+
s.summary = %q{Easy-to-use and powerful slugging for Rails 3}
|
45
|
+
s.test_files = [
|
46
|
+
"spec/factories.rb",
|
47
|
+
"spec/slug_spec.rb",
|
48
|
+
"spec/slugalicious_spec.rb",
|
49
|
+
"spec/spec_helper.rb"
|
50
|
+
]
|
51
|
+
|
52
|
+
if s.respond_to? :specification_version then
|
53
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
54
|
+
s.specification_version = 3
|
55
|
+
|
56
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
57
|
+
s.add_runtime_dependency(%q<rails>, [">= 3.0"])
|
58
|
+
s.add_runtime_dependency(%q<stringex>, [">= 0"])
|
59
|
+
else
|
60
|
+
s.add_dependency(%q<rails>, [">= 3.0"])
|
61
|
+
s.add_dependency(%q<stringex>, [">= 0"])
|
62
|
+
end
|
63
|
+
else
|
64
|
+
s.add_dependency(%q<rails>, [">= 3.0"])
|
65
|
+
s.add_dependency(%q<stringex>, [">= 0"])
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
data/spec/factories.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
Factory.define :slug do |f|
|
2
|
+
f.sequence(:slug) { |n| "slug-#{n}" }
|
3
|
+
f.active true
|
4
|
+
end
|
5
|
+
|
6
|
+
Factory.define :user do |f|
|
7
|
+
f.first_name "Doctor"
|
8
|
+
f.last_name "Spaceman"
|
9
|
+
end
|
10
|
+
|
11
|
+
Factory.define :abuser do |f|
|
12
|
+
f.first_name "Doctor"
|
13
|
+
f.last_name "Spaceman"
|
14
|
+
end
|
data/spec/slug_spec.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Slug do
|
4
|
+
before :each do
|
5
|
+
@record = Factory(:user)
|
6
|
+
Slug.delete_all
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "validation" do
|
10
|
+
it "should not allow an active slug to be created if one already exists" do
|
11
|
+
Factory(:slug, sluggable: @record)
|
12
|
+
slug = Factory.build(:slug, sluggable: @record)
|
13
|
+
slug.should_not be_valid
|
14
|
+
slug.errors[:active].should_not be_empty
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should not allow a slug to be made active if one already exists" do
|
18
|
+
Factory(:slug, sluggable: @record)
|
19
|
+
slug = Factory(:slug, sluggable: @record, active: false)
|
20
|
+
slug.active = true
|
21
|
+
slug.should_not be_valid
|
22
|
+
slug.errors[:active].should_not be_empty
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should allow an active slug to be created if none exists" do
|
26
|
+
slug = Factory.build(:slug, sluggable: @record)
|
27
|
+
slug.should be_valid
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should allow a slug to be made active if none exists" do
|
31
|
+
slug = Factory(:slug, sluggable: @record, active: false)
|
32
|
+
slug.active = true
|
33
|
+
slug.should be_valid
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should allow an inactive slug to be modified if the active field is not changing" do
|
37
|
+
Factory(:slug, sluggable: @record)
|
38
|
+
slug = Factory(:slug, sluggable: @record, active: false)
|
39
|
+
slug.scope = 'test'
|
40
|
+
slug.should be_valid
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe "#activate!" do
|
45
|
+
it "should mark the slug as active" do
|
46
|
+
slug = Factory(:slug, sluggable: Factory(:user), active: false)
|
47
|
+
slug.activate!
|
48
|
+
slug.should be_active
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should deactivate all the record's other slugs" do
|
52
|
+
record = Factory(:user)
|
53
|
+
s1 = Slug.for(record).first
|
54
|
+
s2 = Factory(:slug, sluggable: record, active: false)
|
55
|
+
s3 = Factory(:slug, sluggable: record, active: false)
|
56
|
+
|
57
|
+
slug = Factory(:slug, sluggable: record, active: false)
|
58
|
+
slug.activate!
|
59
|
+
|
60
|
+
s1.reload.should_not be_active
|
61
|
+
s2.reload.should_not be_active
|
62
|
+
s3.reload.should_not be_active
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,232 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe Slugalicious do
|
4
|
+
before :each do
|
5
|
+
@model = Class.new
|
6
|
+
@model.send :include, ActiveModel::Validations
|
7
|
+
@model.send :include, ActiveModel::Validations::Callbacks
|
8
|
+
@model.send :include, ActiveRecord::Callbacks
|
9
|
+
@model.send :include, ActiveRecord::Validations
|
10
|
+
@model.stub!(:has_many)
|
11
|
+
@model.send :include, Slugalicious
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#slugged" do
|
15
|
+
it "should raise an error if no generators are given" do
|
16
|
+
expect { @model.send(:slugged, { options: 'here' }) }.to raise_error(ArgumentError)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "#to_param" do
|
21
|
+
it "should return the slug" do
|
22
|
+
user = Factory(:user)
|
23
|
+
user.slug.should_not be_nil
|
24
|
+
user.to_param.should eql(user.slug)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should return the full slug path when scoped" do
|
28
|
+
User._slug_procs.clear
|
29
|
+
User.send :slugged, :first_name, scope: :last_name
|
30
|
+
|
31
|
+
user = Factory(:user, last_name: 'test/')
|
32
|
+
user.to_param.should eql('test/doctor')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "slug generation" do
|
37
|
+
before :each do
|
38
|
+
User._slug_procs.clear
|
39
|
+
User._slug_blacklist.clear
|
40
|
+
end
|
41
|
+
|
42
|
+
it "should set the slug according to the generator and apply the slugifier" do
|
43
|
+
User.send :slugged, :first_name
|
44
|
+
object = Factory(:user, first_name: "Sancho", last_name: "Sample")
|
45
|
+
object.slug.should eql("sancho")
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should generate from a proc as well as a symbol" do
|
49
|
+
User.send :slugged, ->(person) { "#{person.first_name} #{person.last_name}" }
|
50
|
+
object = Factory(:user, first_name: "Foo", last_name: "bar")
|
51
|
+
object.slug.should eql("foo-bar")
|
52
|
+
end
|
53
|
+
|
54
|
+
it "should give priority to the first non-nil slug" do
|
55
|
+
User.send :slugged, :gender, :last_name
|
56
|
+
object = Factory(:user, gender: nil, last_name: "Bar")
|
57
|
+
object.slug.should eql("bar")
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should not create a new slug if an existing one matches" do
|
61
|
+
User.send :slugged, :first_name, :last_name
|
62
|
+
object = Factory(:user, first_name: 'Foo', last_name: "Bar")
|
63
|
+
object.slug.should eql("foo")
|
64
|
+
other_slug = Factory(:slug, slug: 'bar', sluggable: object, active: false)
|
65
|
+
|
66
|
+
object.update_attribute :last_name, 'baz'
|
67
|
+
object.slug.should eql("foo")
|
68
|
+
other_slug.reload.active.should be_false
|
69
|
+
end
|
70
|
+
|
71
|
+
it "should ignore slugs from other models" do
|
72
|
+
User.send :slugged, :first_name, :last_name
|
73
|
+
Factory(:abuser, last_name: 'Foo').slug.should eql('foo')
|
74
|
+
Factory(:user, first_name: 'Foo', last_name: 'Foo').slug.should eql('foo')
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should use the last-resort generator if nothing else is unique" do
|
78
|
+
User.send :slugged, :first_name, :last_name
|
79
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar').slug.should eql('foo')
|
80
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar').slug.should eql('bar')
|
81
|
+
user = Factory(:user, first_name: 'Foo', last_name: 'Bar')
|
82
|
+
user.slug.should eql("foo;#{user.id}")
|
83
|
+
end
|
84
|
+
|
85
|
+
it "should raise an error if all generators return nil" do
|
86
|
+
User.send :slugged, :gender, :birthdate
|
87
|
+
-> { Factory(:user, gender: nil, birthdate: nil) }.should raise_error
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should use the first non-nil slug for the last-resort generator" do
|
91
|
+
User.send :slugged, :gender, :first_name, :last_name
|
92
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', gender: nil)
|
93
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', gender: nil)
|
94
|
+
user = Factory(:user, first_name: 'Foo', last_name: 'Bar', gender: nil)
|
95
|
+
user.slug.should eql("foo;#{user.id}")
|
96
|
+
end
|
97
|
+
|
98
|
+
it "should use a custom id separator if given" do
|
99
|
+
User.send :slugged, :first_name, :last_name, id_separator: ':'
|
100
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar')
|
101
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar')
|
102
|
+
user = Factory(:user, first_name: 'Foo', last_name: 'Bar')
|
103
|
+
user.slug.should eql("foo:#{user.id}")
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should avoid blacklisted slugs" do
|
107
|
+
User.send :slugged, :first_name, :last_name
|
108
|
+
Factory(:user, first_name: 'New', last_name: 'Bar').slug.should eql('bar')
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should use a custom blacklist" do
|
112
|
+
User.send :slugged, :first_name, :last_name, blacklist: %w( foo bar )
|
113
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz').slug.should eql('baz')
|
114
|
+
end
|
115
|
+
|
116
|
+
it "should wrap the blacklist array" do
|
117
|
+
User.send :slugged, :first_name, :last_name, blacklist: 'foo'
|
118
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz').slug.should eql('baz')
|
119
|
+
end
|
120
|
+
|
121
|
+
it "should only search for available slugs inside the scope if given" do
|
122
|
+
User.send :slugged, :first_name, :last_name, scope: :callsign
|
123
|
+
|
124
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', callsign: 'One').slug.should eql('foo')
|
125
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz', callsign: 'One').slug.should eql('baz')
|
126
|
+
Factory(:user, first_name: 'Boo', last_name: 'Bar', callsign: 'One').slug.should eql('boo')
|
127
|
+
|
128
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', callsign: 'Two').slug.should eql('foo')
|
129
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz', callsign: 'Two').slug.should eql('baz')
|
130
|
+
end
|
131
|
+
|
132
|
+
it "should accept a proc for a scope" do
|
133
|
+
User.send :slugged, :first_name, :last_name, scope: ->(object) { object.callsign[0] }
|
134
|
+
|
135
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', callsign: 'One').slug.should eql('foo')
|
136
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz', callsign: 'Only').slug.should eql('baz')
|
137
|
+
Factory(:user, first_name: 'Boo', last_name: 'Bar', callsign: 'Ocho').slug.should eql('boo')
|
138
|
+
|
139
|
+
Factory(:user, first_name: 'Foo', last_name: 'Bar', callsign: 'Two').slug.should eql('foo')
|
140
|
+
Factory(:user, first_name: 'Foo', last_name: 'Baz', callsign: 'Tres').slug.should eql('baz')
|
141
|
+
end
|
142
|
+
|
143
|
+
it "should enforce a maximum length of 126 characters" do
|
144
|
+
User.send :slugged, :first_name
|
145
|
+
|
146
|
+
user = Factory(:user)
|
147
|
+
user.first_name = 'A'*500
|
148
|
+
user.save(validate: false)
|
149
|
+
user.slug.should eql('a'*126)
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should shorten left of the ID separator" do
|
153
|
+
User.send :slugged, :first_name
|
154
|
+
|
155
|
+
user1 = Factory(:user)
|
156
|
+
user1.first_name = 'A'*500
|
157
|
+
user1.save(validate: false)
|
158
|
+
|
159
|
+
user2 = Factory(:user)
|
160
|
+
user2.first_name = 'A'*500
|
161
|
+
user2.save(validate: false)
|
162
|
+
|
163
|
+
user2.slug.size.should eql(126)
|
164
|
+
user2.slug.should match(/^a+;#{user2.id}$/)
|
165
|
+
end
|
166
|
+
|
167
|
+
it "should raise an error if no unique slugs are available" do
|
168
|
+
old_length = Slugalicious::MAX_SLUG_LENGTH
|
169
|
+
Slugalicious::MAX_SLUG_LENGTH = 1
|
170
|
+
|
171
|
+
User.send :slugged, ->(object) { 'f' }, blacklist: 'f'
|
172
|
+
-> { Factory(:user, first_name: 'Foo', last_name: 'Bar') }.should raise_error
|
173
|
+
|
174
|
+
Slugalicious::MAX_SLUG_LENGTH = old_length
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
describe "#find_from_slug" do
|
179
|
+
it "should return a Slug object for a slug" do
|
180
|
+
User.send :slugged, :first_name, :last_name
|
181
|
+
user1 = Factory(:user, first_name: "FN1", last_name: "LN1")
|
182
|
+
User.find_from_slug('fn1').should eql(user1)
|
183
|
+
end
|
184
|
+
|
185
|
+
it "should exclude slugs of other models" do
|
186
|
+
User.send :slugged, :first_name, :last_name
|
187
|
+
user1 = Factory(:user, first_name: "FN1", last_name: "LN1")
|
188
|
+
Factory(:abuser, first_name: 'FN1')
|
189
|
+
User.find_from_slug('fn1').should eql(user1)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "should locate an object within a given scope" do
|
193
|
+
User.send :slugged, :first_name, scope: :last_name
|
194
|
+
user1 = Factory(:user, first_name: "FN1", last_name: "LN1")
|
195
|
+
user2 = Factory(:user, first_name: "FN1", last_name: "LN2")
|
196
|
+
User.find_from_slug('fn1', 'LN1').should eql(user1)
|
197
|
+
User.find_from_slug('fn1', 'LN2').should eql(user2)
|
198
|
+
end
|
199
|
+
|
200
|
+
it "should raise ActiveRecord::RecordNotFound if the slug does not exist" do
|
201
|
+
-> { User.find_from_slug('nonexist') }.should raise_error(ActiveRecord::RecordNotFound)
|
202
|
+
end
|
203
|
+
|
204
|
+
it "should raise ActiveRecord::RecordNotFound if the slug does not exist in scope" do
|
205
|
+
User.send :slugged, :first_name, scope: :last_name
|
206
|
+
Factory(:user, first_name: "FN1", last_name: "LN1")
|
207
|
+
Factory(:user, first_name: "FN2", last_name: "LN2")
|
208
|
+
-> { User.find_from_slug('fn2', 'ln1') }.should raise_error(ActiveRecord::RecordNotFound)
|
209
|
+
end
|
210
|
+
|
211
|
+
it "should find inactive slugs" do
|
212
|
+
User.send :slugged, :first_name
|
213
|
+
user = Factory(:user, first_name: 'New')
|
214
|
+
Factory(:slug, sluggable: user, slug: 'old', active: false)
|
215
|
+
User.find_from_slug('old').should eql(user)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
describe "#find_from_slug_path" do
|
220
|
+
it "should call #find_from_slug with the slug and no scope for unscoped models" do
|
221
|
+
User.send :slugged, :first_name
|
222
|
+
User.should_receive(:find_from_slug).once.with("test", '')
|
223
|
+
User.find_from_slug_path("test")
|
224
|
+
end
|
225
|
+
|
226
|
+
it "should call #find_from_slug with the slug and scope for scoped models" do
|
227
|
+
User.send :slugged, :first_name, scope: :last_name
|
228
|
+
User.should_receive(:find_from_slug).once.with("test", "path/to/")
|
229
|
+
User.find_from_slug_path("path/to/test")
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
Bundler.require :default, :test
|
2
|
+
require 'active_support'
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
6
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
7
|
+
|
8
|
+
require 'slugalicious'
|
9
|
+
|
10
|
+
ActiveRecord::Base.establish_connection(
|
11
|
+
adapter: 'sqlite3',
|
12
|
+
database: 'test.sqlite'
|
13
|
+
)
|
14
|
+
require "#{File.dirname __FILE__}/../templates/slug"
|
15
|
+
|
16
|
+
class User < ActiveRecord::Base
|
17
|
+
include Slugalicious
|
18
|
+
slugged :last_name, ->(user) { "#{user.first_name} #{user.last_name}" }
|
19
|
+
end
|
20
|
+
class Abuser < ActiveRecord::Base
|
21
|
+
include Slugalicious
|
22
|
+
slugged :last_name, ->(user) { "#{user.first_name} #{user.last_name}" }
|
23
|
+
end
|
24
|
+
|
25
|
+
require "#{File.dirname __FILE__}/factories"
|
26
|
+
|
27
|
+
RSpec.configure do |config|
|
28
|
+
config.before(:each) do
|
29
|
+
Slug.connection.execute "DROP TABLE IF EXISTS slugs"
|
30
|
+
Slug.connection.execute <<-SQL
|
31
|
+
CREATE TABLE slugs (
|
32
|
+
id INTEGER PRIMARY KEY ASC,
|
33
|
+
sluggable_type VARCHAR(126) NOT NULL,
|
34
|
+
sluggable_id INTEGER NOT NULL,
|
35
|
+
active BOOLEAN NOT NULL DEFAULT 1,
|
36
|
+
slug VARCHAR(126) NOT NULL,
|
37
|
+
scope VARCHAR(126)
|
38
|
+
)
|
39
|
+
SQL
|
40
|
+
User.connection.execute "DROP TABLE IF EXISTS users"
|
41
|
+
User.connection.execute "CREATE TABLE users (id INTEGER PRIMARY KEY ASC, first_name TEXT, last_name TEXT, callsign TEXT, gender VARCHAR(7))"
|
42
|
+
Abuser.connection.execute "DROP TABLE IF EXISTS abusers"
|
43
|
+
Abuser.connection.execute "CREATE TABLE abusers (id INTEGER PRIMARY KEY ASC, first_name TEXT, last_name TEXT, callsign TEXT, gender VARCHAR(7))"
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateSlugs < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :slugs do |t|
|
4
|
+
t.belongs_to :sluggable, polymorphic: true, null: false
|
5
|
+
t.boolean :active, null: false, default: true
|
6
|
+
t.string :slug, null: false, limit: 126
|
7
|
+
t.string :scope, limit: 126
|
8
|
+
t.datetime :created_at
|
9
|
+
end
|
10
|
+
|
11
|
+
change_table :slugs do |t|
|
12
|
+
t.index [ :sluggable_type, :sluggable_id, :active ], name: 'slugs_for_record'
|
13
|
+
t.index [ :sluggable_type, :scope, :slug ], unique: true, name: 'slugs_unique'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.down
|
18
|
+
drop_table :slugs
|
19
|
+
end
|
20
|
+
end
|
data/templates/slug.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# Stores slugs used to prettify URLs. For more information, see the
|
2
|
+
# {Slugalicious} mixin.
|
3
|
+
#
|
4
|
+
# As new slugs are created, old ones are marked as inactive and kept around for
|
5
|
+
# redirect purposes. Once a slug is old enough, it is deleted and its value can
|
6
|
+
# be used for new records (no longer redirects).
|
7
|
+
#
|
8
|
+
# h2. Associations
|
9
|
+
#
|
10
|
+
# | @sluggable@ | The record that this slug references. |
|
11
|
+
#
|
12
|
+
# h2. Properties
|
13
|
+
#
|
14
|
+
# | @slug@ | The slug, lowercased and normalized. Slugs must be unique to their @sluggable_type@ and @scope@. |
|
15
|
+
# | @active@ | Whether this is the most recently generated slug for the sluggable. |
|
16
|
+
# | @scope@ | Freeform data scoping this slug to a certain subset of records within the model. |
|
17
|
+
|
18
|
+
class Slug < ActiveRecord::Base
|
19
|
+
belongs_to :sluggable, polymorphic: true
|
20
|
+
|
21
|
+
scope :for, ->(object_or_type, object_id=nil) {
|
22
|
+
object_type = object_id ? object_or_type : object_or_type.class.to_s
|
23
|
+
object_id ||= object_or_type.id
|
24
|
+
where(sluggable_type: object_type, sluggable_id: object_id)
|
25
|
+
}
|
26
|
+
scope :for_class, ->(model) { where(sluggable_type: model.to_s) }
|
27
|
+
scope :from_slug, ->(klass, scope, slug) {
|
28
|
+
where(sluggable_type: klass.to_s, slug: slug, scope: scope)
|
29
|
+
}
|
30
|
+
scope :active, where(active: true)
|
31
|
+
scope :inactive, where(active: false)
|
32
|
+
|
33
|
+
validates :sluggable_type,
|
34
|
+
presence: true
|
35
|
+
validates :sluggable_id,
|
36
|
+
presence: true,
|
37
|
+
numericality: { only_integer: true }
|
38
|
+
validates :slug,
|
39
|
+
presence: true,
|
40
|
+
length: { maximum: 126 },
|
41
|
+
uniqueness: { case_sensitive: false, scope: [ :scope, :sluggable_type ] } #TODO validate scope case-insensitively
|
42
|
+
validates :scope,
|
43
|
+
length: { maximum: 126 },
|
44
|
+
allow_blank: true
|
45
|
+
validate :one_active_slug_per_object
|
46
|
+
|
47
|
+
# Marks a slug as active and deactivates all other slugs assigned to the
|
48
|
+
# record.
|
49
|
+
|
50
|
+
def activate!
|
51
|
+
self.class.transaction do
|
52
|
+
Slug.for(sluggable_type, sluggable_id).update_all(active: false)
|
53
|
+
update_attribute :active, true
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def one_active_slug_per_object
|
60
|
+
return unless new_record? or (active? and active_changed?)
|
61
|
+
errors.add(:active, :one_per_sluggable) if active? and Slug.active.for(sluggable_type, sluggable_id).count > 0
|
62
|
+
end
|
63
|
+
end
|
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: slugalicious
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 1
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
version: 1.0.0
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Tim Morgan
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2010-10-30 00:00:00 -07:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: rails
|
22
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
23
|
+
none: false
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
segments:
|
28
|
+
- 3
|
29
|
+
- 0
|
30
|
+
version: "3.0"
|
31
|
+
type: :runtime
|
32
|
+
prerelease: false
|
33
|
+
version_requirements: *id001
|
34
|
+
- !ruby/object:Gem::Dependency
|
35
|
+
name: stringex
|
36
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
37
|
+
none: false
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
segments:
|
42
|
+
- 0
|
43
|
+
version: "0"
|
44
|
+
type: :runtime
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *id002
|
47
|
+
description: Slugalicious adds simple and powerful slugging to your ActiveRecord models.
|
48
|
+
email: git@timothymorgan.info
|
49
|
+
executables: []
|
50
|
+
|
51
|
+
extensions: []
|
52
|
+
|
53
|
+
extra_rdoc_files:
|
54
|
+
- LICENSE
|
55
|
+
- README.textile
|
56
|
+
files:
|
57
|
+
- .document
|
58
|
+
- .gitignore
|
59
|
+
- .rspec
|
60
|
+
- Gemfile
|
61
|
+
- Gemfile.lock
|
62
|
+
- LICENSE
|
63
|
+
- README.textile
|
64
|
+
- Rakefile
|
65
|
+
- VERSION
|
66
|
+
- lib/slugalicious.rb
|
67
|
+
- lib/slugalicious_generator.rb
|
68
|
+
- slugalicious.gemspec
|
69
|
+
- spec/factories.rb
|
70
|
+
- spec/slug_spec.rb
|
71
|
+
- spec/slugalicious_spec.rb
|
72
|
+
- spec/spec_helper.rb
|
73
|
+
- templates/create_slugs.rb
|
74
|
+
- templates/slug.rb
|
75
|
+
has_rdoc: true
|
76
|
+
homepage: http://github.com/riscfuture/slugalicious
|
77
|
+
licenses: []
|
78
|
+
|
79
|
+
post_install_message:
|
80
|
+
rdoc_options:
|
81
|
+
- --charset=UTF-8
|
82
|
+
require_paths:
|
83
|
+
- lib
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
segments:
|
90
|
+
- 1
|
91
|
+
- 9
|
92
|
+
version: "1.9"
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
segments:
|
99
|
+
- 0
|
100
|
+
version: "0"
|
101
|
+
requirements: []
|
102
|
+
|
103
|
+
rubyforge_project:
|
104
|
+
rubygems_version: 1.3.7
|
105
|
+
signing_key:
|
106
|
+
specification_version: 3
|
107
|
+
summary: Easy-to-use and powerful slugging for Rails 3
|
108
|
+
test_files:
|
109
|
+
- spec/factories.rb
|
110
|
+
- spec/slug_spec.rb
|
111
|
+
- spec/slugalicious_spec.rb
|
112
|
+
- spec/spec_helper.rb
|