Today I Learned has a feature I love that we call 'titled slugs'. This week I reimplemented it in Phoenix.
Here's an overview of the feature. Visit any post on 'Today I Learned', and the URL looks like this:
https://til.hashrocket.com/posts/61e2f0db67-logrotation-for-a-rails-app
61e2f0db67
is the url_slug
for the post, a unique string that does not
change. But what's with the rest of the URL? -logrotation-for-a-rails-app
is
a slugified version of the title of the post. It makes the URL easier to read,
and might improve our SEO in some magical way.
Bur what happens if an author changes the title? The slugified version of the title should change, altering the URL and breaking any old links to the post.
To test this scenario, visit this link, and look at the URL in your browser:
https://til.hashrocket.com/posts/61e2f0db67-actually-irrelevant
Surprised? The feature I want to discuss today allows you to remove or replace the title part of the slug, and still load the post. It works because the titled slug is the implied parameter for the post, but we look up the post by the slug itself, without the title. The titled slug is used just for display; while the slug alone makes the post unique.
This is a nicety that I wanted to preserve in our Phoenix port, Tilex, of 'Today I Learned'. Here's the code I wrote to make that happen (Phoenix 1.2.1).
Change the Parameter
In Ruby on Rails, models have an implied parameter of their ID, which you can
verify by calling to_param
on an instance. Here's the Phoenix equivalent:
# web/models/post.ex
defimpl Phoenix.Param, for: Tilex.Post do
def to_param(%{slug: slug, title: title}) do
"#{slug}-#{Tilex.Post.slugified_title(title)}"
end
end
We define the implementation for the given protocol Phoenix.Param.to_param
,
pattern matching the slug and title and returning our 'titled slug'.
Slugify the Title
Next, we need to be able to convert a title into a valid URL slug. To do this, we'll implement the
slugified_title/1
function seen above. Here's the unit test:
# test/models/post_test.ex
test "can slugify its own title" do
title = "Hacking Your Shower!!!"
result = "hacking-your-shower"
assert Post.slugified_title(title) == result
end
And the implementation:
# web/models/post.ex
def slugified_title(title) do
title
|> String.downcase
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.replace(~r/(\s|-)+/, "-")
end
We take the title, downcase it, remove anything not a space, dash, or alphanumeric character, and replace whitespaces with a dash.
Handle the New Parameter
This application is expecting posts to identify with their ID, so we'll need to change how our controllers and links behave.
First, let's name our new parameter 'titled_slug', to reflect what it is:
# web/router.ex
defmodule Tilex.Router do
resources "/posts", PostController, param: "titled_slug"
end
When the controller receives 'titled_slug', we must add some logic:
# web/controllers/post_controller.ex
def show(conn, %{"titled_slug" => titled_slug}) do
[slug|_] = titled_slug |> String.split("-")
post = Repo.get_by!(Post, slug: slug)
|> Repo.preload([:channel])
render(conn, "show.html", post: post)
end
Here, a new parameter (titled_slug
) is pattern-matched, chopped up (again,
with pattern matching), and used to find a post.
Make sure all your links use the model, not an attribute like .slug
or .title
, and that's it.
Integration Test
Here's our new, passing integration test (Wallaby 0.16.1):
# test/features/visitor_views_post_test.exs
test "and sees a titled URL slug", %{session: session} do
post = Factory.insert!(:post, title: "Super Sluggable Title")
url = visit(session, post_path(Endpoint, :show, post))
|> current_url
assert url =~ "#{post.slug}-super-sluggable-title"
changeset = Post.changeset(post, %{title: "Alternate Also Cool Title"})
Repo.update!(changeset)
post = Repo.get(Post, post.id)
url = visit(session, post_path(Endpoint, :show, post))
|> current_url
assert url =~ "#{post.slug}-alternate-also-cool-title"
end
A new title changes how the URL is presented, but not how it works.
Conclusion
Here's the pull request where all these changes were made:
https://github.com/hashrocket/tilex/pull/25
Tilex has been a fun project, forcing us to figure out how features we built in Ruby on Rails might be recreated in Elixir. If you build this feature in a Phoenix application of your own, I'd love to hear your experience and any improvements you might have made.
Photo Credit: Untitled, Nick Tiemeyer, unsplash.com, https://unsplash.com/photos/tNGcZlycLtQ. Accessed 18 March 2017.