Monday, July 23, 2007

Creating a Unique URL

SEO optimization these days requires that you have nicely-dashed-urls instead of ids. While there's a few examples of how to generate these on the web, I found all of them lacking in flexibility and reuse. So here's my solution to the problem. The approach decomposes the problem into three subproblems:
  1. Eliminate unsafe characters from a string.
  2. Identify the uniqueness of a string.
  3. Generation of endings to construct uniqueness candidates.
You can then put them all together according to your needs. For example:
#Generate a unique name by adding integers to the end
uniquify(url_safe(name.downcase)) do |candidate| 
  Article.find_by_url_name(candidate).blank?
end
The code is formatted as a Ruby module that can be mixed in to your classes as needed. I hope you find it useful!
module UrlUtils
  # Makes a string safe for urls
  # Options:
  #   :replacements - a Hash of a replacement string to a regex that should match for replacement
  #   :char - when :replacements is not provided, this is the string that will be used to replace unsafe characters. defaults to '-'
  #   :collapse - set to false if multiple, consecutive unsafe characters should not be replaced with only a single instance of :char. defaults to true.
  def url_safe(s, options = {})
    default_regex = options.fetch(:collapse, true) ? /[^a-zA-Z0-9-]+/ : /[^a-zA-Z0-9-]/
    replacements = options.fetch(:replacements, { options.fetch(:char,"-") => default_regex })
    replacements.each do |replacement, regex|
      s = s.gsub(regex,replacement)
    end
    return s
  end
  
  # Generate integers
  # Options:
  #   :start => 1, The integer to start with
  #   :end => nil, the last integer to generate, when nil this becomes an infinite sequence
  #   :increment => 1, the amount to add for each iteration
  def int_generator(options = {})
    start = options.fetch(:start,1)
    last = options[:end]
    increment = options.fetch(:increment, 1)
    raise ArgumentError if increment == 0
    raise ArgumentError if last && (increment > 0 && start > last) || (increment < 0 && start < last)
    Generator.new do |g|
      i = start
      loop do
        g.yield i
        return if !last.nil? && (increment > 0 && i >= last) || (increment < 0 && i <= last)
        i = i + increment
      end
    end
  end
  
  # accepts a block that will be passed a candidate string and should return true if it is unique.
  # Options:
  # => :separator => "-", a string that will be injected between the base string and the uniqifier
  # => :endings => generator, a Generator that provides endings to be placed at the end of the base.
  #                           defaults to the set of positive integers.
  def uniquify(base, options = {})
    sep = options.fetch(:separator, "-")
    endings = options[:endings] || int_generator
    return base if yield base
    while endings.next? do
      candidate = base+sep+endings.next.to_s
      return candidate if yield candidate
    end
    raise ArgumentError.new("No unique construction found for \"#{base}\"")
  end
end
If you prefer you can get the code as a pastie.