DBlog: Implementing Blog Post Slugs in .NET

Back | .net, asp.net | 7/22/2011 |

I was showing some Coffeescript to a candidate today. It happened to be a Backbone.js model with a field called slug. “What’s a slug?” – he asked.

A slug is an external identity to an object reachable by an API call. For example, Steven Assael’s amazing graphite drawing entitled “Amber with Peacock Feathers” has a “steven-assael-amber-with-peacock-feathers” slug. Slugs are much more readable than a database object identity, such as "4dc706fb46895e000100128f". They make URLs prettier and helps search engines index data. Slugs also enable developers to change the way data is stored. In general, I recommend hiding internal IDs and creating external IDs for every object that is exposed to the outside world.

In Ruby we use the mongoid-slug gem. To set this up we include Mongoid::Slug and specify which field to use to generate it.

  1. class Artwork
  2.   include Mongoid::Document
  3.   include Mongoid::Slug
  4.   
  5.   field :title, type: String  
  6.   slug :title, index: true
  7.   
  8.   ...
  9.  
  10. end

I decided to implement the same thing for this blog, which is a bit obsolete architecture-wise and is written in ASP.NET. To keep things simple, I added a slug field to my Post model as an nvarchar(256) and slapped a unique key constraint on it. To generate an actual slug from a title I stole some code from here. It basically strips any non-alphanumeric text from the post’s title.

  1. /// <summary>
  2. /// Transform a string into a slug.
  3. /// See http://www.intrepidstudios.com/blog/2009/2/10/function-to-generate-a-url-friendly-string.aspx
  4. /// </summary>
  5. /// <param name="s"></param>
  6. /// <returns></returns>
  7. public static string ToSlug(string s)
  8. {
  9.     s = s.ToLower();
  10.     // invalid chars, make into spaces
  11.     s = Regex.Replace(s, @"[^a-z0-9\s-]", "");
  12.     // convert multiple spaces/hyphens into one space       
  13.     s = Regex.Replace(s, @"[\s-]+", " ").Trim();
  14.     // hyphens
  15.     s = Regex.Replace(s, @"\s", "-");
  16.     return s;
  17. }

Slugs are unique, so we must avoid duplicates. While there’re more effective approaches to generating a unique slug, we’ll simply iterate until we find a unique value. After all, how often do we need to generate a new slug?

  1. public void GenerateSlug(ISession session)
  2. {
  3.     if (! string.IsNullOrEmpty(Slug))
  4.         return;
  5.  
  6.     String slug_base = Renderer.ToSlug(Title);
  7.     String slug_candidate = "";
  8.     int slug_count = 0;
  9.     Post existing_post = null;
  10.  
  11.     do
  12.     {
  13.         slug_candidate = slug_base + (slug_count == 0 ? "" : string.Format("-{0}", slug_count));
  14.         existing_post = session.CreateCriteria(typeof(Post))
  15.             .Add(Expression.Eq("Slug", slug_candidate))
  16.             .Add(Expression.Not(Expression.Eq("Id", this.Id)))
  17.             .UniqueResult<Post>();
  18.         slug_count += 1;
  19.     } while (existing_post != null);
  20.  
  21.     Slug = slug_candidate;
  22. }

The routing is a bit trickier. Until now the posts were accessible as ShowPost.aspx?id=Integer. I started by making a change where a post can be fetched by slug, such as ShowPost.aspx?slug=String. The next problem is accepting slugs in the URL and internally rewriting the ASP.NET request path to the latter. The best place to do it seems to be Application_BeginRequest in Global.asax.cs.

  1. string path = Request.Path.Substring(Request.ApplicationPath.Length).Trim("/".ToCharArray());
  2. if (! string.IsNullOrEmpty(path))
  3. {
  4.     // rewrite a slug link to a ShowPost.aspx internal url
  5.     if (path.IndexOf('.') < 0)
  6.     {
  7.         string[] parts = Request.Path.Split('/');
  8.         string slug = parts[parts.Length - 1];
  9.         if (! String.IsNullOrEmpty(slug))
  10.         {
  11.             HttpContext.Current.RewritePath(string.Format("ShowPost.aspx?slug={0}", slug));
  12.         }
  13.     }
  14. }

First, we’re removing the virtual path from the request URL, stripping the /blog/ part from applications hosted at a /blog/ virtual directory. Then, we’re going to assume that anything that doesn’t have a period (.) in the URL is a slug and is being redirected to ShowPost.aspx. An alternative is to rely on a /posts/ path, but that will break all relative URLs in my existing application since, for example, /Style.css is not the same as /posts/Style.css. Naturally your mileage may vary depending on your existing requirements.

Secondly, we’d like to permanently redirect anyone with a ShowPost.aspx?id=Integer link to the new slugged URL and anyone directly hitting the ShowPost.aspx?id=slug url to the slug itself. This way there’s only one way to address a post, by it’s slug.

  1. // rewrite ShowPost.aspx link to a slug
  2. if (path == "ShowPost.aspx" && !string.IsNullOrEmpty(Request["id"]))
  3. {
  4.     // fetch the post, its slug and permanently redirect to it
  5. }
  6. // rewrite a slug link
  7. else if (path == "ShowPost.aspx" && !string.IsNullOrEmpty(Request["slug"]))
  8. {
  9.     Response.RedirectPermanent(Request["slug"]);
  10. }

Here’s a URL that I get after running a task to re-slug all existing posts to my infamous Github is Your New Resume post. It’s a lot nicer!

http://code.dblock.org/github-is-your-new-resume

The URL has changed, yet Discus comments are fine (phew.) – they use my unique identifier. The twitter RT count is lost though and is reset at zero, since Twitter is a URL-based system. Too bad, here’s a screenshot for the memories.

image

314 RTs, holy crap! This blog’s source code is available under the MIT license on Github.