module Slug module ClassMethods # Call this to set up slug handling on an ActiveRecord model. # # Params: # * :source - the column the slug will be based on (e.g. :headline) # # Options: # * :column - the column the slug will be saved to (defaults to :slug) # * :validate_uniquness_if - proc to determine whether uniqueness validation runs, same format as validates_uniquness_of :if # # Slug will take care of validating presence and uniqueness of slug. # Before create, Slug will generate and assign the slug if it wasn't explicitly set. # Note that subsequent changes to the source column will have no effect on the slug. # If you'd like to update the slug later on, call @model.set_slug def slug source, opts={} class_attribute :slug_source, :slug_column include InstanceMethods self.slug_source = source self.slug_column = opts.has_key?(:column) ? opts[:column] : :slug uniqueness_opts = {} uniqueness_opts[:if] = opts[:validate_uniqueness_if] if opts.key?(:validate_uniqueness_if) validates self.slug_column, :presence => { :message => "cannot be blank. Is #{self.slug_source} sluggable?" } validates self.slug_column, :uniqueness => uniqueness_opts validates self.slug_column, :format => { :with => /^[a-z0-9-]+$/, :message => "contains invalid characters. Only downcase letters, numbers, and '-' are allowed." } before_validation :set_slug, :on => :create end end module InstanceMethods # Sets the slug. Called before create. # By default, set_slug won't change slug if one already exists. Pass :force => true to overwrite. def set_slug(opts={}) validate_slug_columns return unless self[self.slug_column].blank? || opts[:force] == true original_slug = self[self.slug_column] self[self.slug_column] = self.send(self.slug_source) strip_diacritics_from_slug normalize_slug assign_slug_sequence unless self[self.slug_column] == original_slug # don't try to increment seq if slug hasn't changed end # Overwrite existing slug based on current contents of source column. def reset_slug set_slug(:force => true) end # Overrides to_param to return the model's slug. def to_param self[self.slug_column] end def self.included(klass) klass.extend(ClassMethods) end private # Validates that source and destination methods exist. Invoked at runtime to allow definition # of source/slug methods after slug setup in class. def validate_slug_columns raise ArgumentError, "Source column '#{self.slug_source}' does not exist!" if !self.respond_to?(self.slug_source) raise ArgumentError, "Slug column '#{self.slug_column}' does not exist!" if !self.respond_to?("#{self.slug_column}=") end # Takes the slug, downcases it and replaces non-word characters with a -. # Feel free to override this method if you'd like different slug formatting. def normalize_slug return if self[self.slug_column].blank? s = ActiveSupport::Multibyte.proxy_class.new(self[self.slug_column]).normalize(:kc) s.downcase! s.strip! s.gsub!(/[^a-z0-9\s-]/, '') # Remove non-word characters s.gsub!(/\s+/, '-') # Convert whitespaces to dashes s.gsub!(/-\z/, '') # Remove trailing dashes s.gsub!(/-+/, '-') # get rid of double-dashes self[self.slug_column] = s.to_s end # Converts accented characters to their ASCII equivalents and removes them if they have no equivalent. # Override this with a void function if you don't want accented characters to be stripped. def strip_diacritics_from_slug return if self[self.slug_column].blank? s = ActiveSupport::Multibyte.proxy_class.new(self[self.slug_column]) s = s.normalize(:kd).unpack('U*') s = s.inject([]) do |a,u| if Slug::ASCII_APPROXIMATIONS[u] a += Slug::ASCII_APPROXIMATIONS[u].unpack('U*') elsif (u < 0x300 || u > 0x036F) a << u end a end s = s.pack('U*') self[self.slug_column] = s.to_s end # If a slug of the same name already exists, this will append '-n' to the end of the slug to # make it unique. The second instance gets a '-1' suffix. def assign_slug_sequence return if self[self.slug_column].blank? idx = next_slug_sequence self[self.slug_column] = "#{self[self.slug_column]}-#{idx}" if idx > 0 end # Returns the next unique index for a slug. def next_slug_sequence last_in_sequence = self.class.where("#{self.slug_column} LIKE ?", self[self.slug_column] + '%').order("CAST(REPLACE(#{self.slug_column},'#{self[self.slug_column]}-','') AS UNSIGNED) DESC").first if last_in_sequence.nil? return 0 else sequence_match = last_in_sequence[self.slug_column].match(/^#{self[self.slug_column]}(-(\d+))?/) current = sequence_match.nil? ? 0 : sequence_match[2].to_i return current + 1 end end end end