This repository has been archived by the owner on Feb 5, 2019. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 5
/
slugalicious.rb
241 lines (210 loc) · 9.89 KB
/
slugalicious.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
require 'slugalicious_generator'
require 'stringex'
# Adds the @slugged@ method to an @ActiveRecord::Base@ subclass. You can then
# call this method to add slugging support to your model. See the
# {ClassMethods#slugged} method for more details.
#
# @example Basic example of a slugged model
# class Widget < ActiveRecord::Base
# include Slugalicious
# slugged :title
# end
module Slugalicious
extend ActiveSupport::Concern
# The maximum length of a slug.
MAX_SLUG_LENGTH = 126
included do
alias_method :to_param, :slug_with_path
has_many :slugs, :as => :sluggable, :dependent => :delete_all
end
# Methods added to the class when this module is included.
module ClassMethods
# Locates a record matching a given slug.
#
# @param [String] slug The slug to locate.
# @param [String] scope The scope to search in (for use with scoped-unique
# slugs). This should be a string equal to the portion of the URL path
# preceding the slug.
# @return [ActiveRecord::Base] The object with that slug.
# @raise [ActiveRecord::RecordNotFound] If no object with that slug is
# found.
def find_from_slug(slug, scope=nil)
Slug.from_slug(self, scope, slug).first.try(:sluggable) || raise(ActiveRecord::RecordNotFound)
end
# Locates a record from a given path, that consists of a slug and its scope,
# as would appear in a URL path component.
#
# @param [String] path The scope and slug concatenated together.
# @return [ActiveRecord::Base] The object with that slug.
# @raise [ActiveRecord::RecordNotFound] If no object with that slug is
# found.
def find_from_slug_path(path)
slug = path.split('/').last
scope = path[0..(-(slug.size + 1))]
find_from_slug slug, scope
end
protected
# Call this method to indicate that your model uses slugging. Pass a list of
# *slug generators*: either symbols (method names) or procs that return
# strings. These strings will be used to generate the slug. You must pass at
# least one generator. If you pass more than one, the first one that returns
# a unique slug will be used.
#
# The generator does not need to sanitize or parameterize its output; the
# @:slugifier@ option can be used to override the default parameterization.
#
# In the event that no generator returns a unique slug, the slug returned by
# the last generator will have the ID of the record appended to it. The ID
# and the slug will be separated by the @:id_separator@ option (semicolon by
# default). _This_ slug is hopefully unique, because if not, an exception is
# raised.
#
# Slugs are automatically generated before validation and updated when
# necessary.
#
# h2. Scopes
#
# You can scope your slugs to certain URL subpaths using the @:scope@
# option. The @:scope:@ option takes a method name or a @Proc@ that, when
# run, returns a string that scopes the uniqueness constraint of a slug.
# Rather than being globally unique, the slug must only be unique among
# other slugs that share the same scope.
#
# *Important note:* The method or @Proc@ that you use for the @:scope@
# option should return the portion of the URL preceding the slug, _slash
# included_. Let's say you have slugged your @User@ model's @login@ field,
# and you have two scopes: customers and merchants. In that case, you would
# want the @:scope@ method/proc to return either "clients/" or "merchants/".
#
# The string returned by the @:scope@ option will be used to build the full
# URL to an object. If you have a client @User@ with login "fancylad", a
# call to @to_param@ will return "clients/fancyland". The scope portion of
# that URL path is used un-sanitized, un-escaped, and un-processed. It is
# therefore up to _you_ to ensure your scopes are valid URL strings, using
# say @String#to_url@ (included as part of this gem).
#
# @overload slugged(generator, ..., options={})
# @param [Proc, Symbol] generator If it's a @Symbol@, indicates a method
# that will be called that will return a @String@ to be used for the
# slug.
# @param [Hash] options Additonal options that control slug generation.
# @option options [Proc] :slugifier (&:to_url) A proc that, when given a
# string, produces a URL-safe slugged version of that string.
# @option options [String] :id_separator (';') A separator to be used in
# the "last-resort" slug between the slug and the model ID. This should
# be an URL-safe character that would never be produced by your
# slugifier.
# @option options [Symbol, Proc] :scope A method name or @Proc@ to run
# (receives the object being slugged) that returns a string. Slugs must
# be unique across all objects for which this method/proc returns the
# same value. If not provided, slugs must be globally unique for this
# model. The string returned should be equal to the portion of the URL
# path that precedes the slug.
# @option options [Array<String>, String] :blacklist ([ 'new', 'edit', 'delete' ])
# A list of slugs that are disallowed. You would use this to prevent
# slugs from sharing the same name as actions in your resource
# controller.
# @raise [ArgumentError] If no generators are provided.
def slugged(*slug_procs)
options = slug_procs.extract_options!
raise ArgumentError, "Must provide at least one field or proc to slug" if slug_procs.empty?
class_attribute :_slug_procs, :_slug_blacklist
class_attribute :_slugifier, :_slug_id_separator, :_slug_scope
self._slug_procs = slug_procs.map { |slug_proc| slug_proc.kind_of?(Symbol) ? lambda { |obj| obj.send(slug_proc) } : slug_proc }
self._slugifier = options[:slugifier] || lambda { |string| string.to_url }
self._slug_id_separator = options[:id_separator] || ';'
self._slug_scope = if options[:scope].kind_of?(Symbol) then
lambda { |record| record.send(options[:scope]).to_s }
elsif options[:scope].kind_of?(Proc) then
options[:scope]
elsif options[:scope] then
raise ArgumentError, ":scope must be a symbol or proc"
end
self._slug_blacklist = Array.wrap(options[:blacklist] || %w( new edit delete ))
after_save :make_slug
end
end
def slug_object
slugs.loaded? ? slugs.detect(&:active) : slugs.active.first
end
private :slug_object
# @return [String, nil] The slug for this object, or @nil@ if none has been
# assigned.
def slug
Rails.cache.fetch("Slug/#{self.class.to_s}/#{id}/slug") do
slug_object.try(:slug)
end
end
# @return [String, nil] The full slug and path for this object, with scope
# included, or @nil@ if none has been assigned.
def slug_with_path
Rails.cache.fetch("Slug/#{self.class.to_s}/#{id}/slug_with_path") do
slug_object ? (slug_object.scope.to_s + slug_object.slug) : nil
end
end
# @param [String] slug A slug for this object.
# @return [true, false, nil] @true@ if the slug is the currently active one
# (should not redirect), @false@ if it's inactive (should redirect), and
# @nil@ if it's not a known slug for the object (should 404).
def active_slug?(slug)
@active_slug ||= begin
slug = if slugs.loaded? then
slugs.detect { |s| s.slug.downcase == slug.downcase }
else
slugs.where(:slug => slug).first
end
if slug then
slug.active?
else
nil
end
end
end
private
def make_slug
slugs_in_use = if slugs.loaded? then
slugs.map(&:slug)
else
slugs.select(:slug).all.map(&:slug)
end
# grab a list of all potential slugs derived from the generators
potential_slugs = self.class._slug_procs.map { |slug_proc| slug_proc[self] }.
compact.
map { |slug| self.class._slugifier[slug] }.
map { |slug| slug[0, MAX_SLUG_LENGTH] }
raise "All slug generators returned nil for #{self.inspect}" if potential_slugs.empty?
# include the last-resort slug, trimmed for length
last_resort_append = "#{self.class._slug_id_separator}#{id}"
potential_slugs << "#{potential_slugs.first[0, [ 1, MAX_SLUG_LENGTH - last_resort_append.length ].max]}#{last_resort_append}"[0, MAX_SLUG_LENGTH]
# subtract out blacklisted slugs
potential_slugs -= self.class._slug_blacklist
# if one of these slugs is already in use, we don't need to change the slug
# instead, activate the one of highest prioirty and we're done
valid_slugs_in_use = potential_slugs & slugs_in_use
unless valid_slugs_in_use.empty?
Slug.transaction do
slugs.update_all(:active => false)
slugs.where(:slug => valid_slugs_in_use.first).update_all(:active => true)
end
return
end
Slug.transaction do
# grab a list of all the slugs we can't use
scope = Slug.select(:slug).where(:sluggable_type => self.class.to_s, :slug => potential_slugs)
if self.class._slug_scope then
scope = scope.where(:scope => self.class._slug_scope[self])
end
taken_slug_objects = scope.all
# subtract them out from all the potential slugs to make the available slugs
available_slugs = potential_slugs - taken_slug_objects.map(&:slug)
# no slugs available? nothing much else we can do
raise "Couldn't find a slug for #{self.inspect}; tried #{potential_slugs.join(', ')}" if available_slugs.empty?
slugs.update_all(:active => false)
Slug.create!(:sluggable => self,
:slug => available_slugs.first,
:active => true,
:scope => self.class._slug_scope.try(:call, self))
end
@active_slug = nil
end
end