slugalicious 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
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
@@ -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
@@ -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