building a content managment system
Why did I ever use anything else? That’s what I keep asking. I can’t tell you how many times I have struggled with various Rails based content management systems. Its ridiculous. They have got to be the easiest thing in the world. If you can build a blog in fifteen minutes, basic content management should take five.
After struggling with Comatose and trying to install it again, and trying to use it again and having it not work at all I was fed up. I will write my own, I thought. So I jumped into a nice friendly rails channel venting all kinds of hate and said I was going to use method_missing in a controller to handle routes. crookshanks pointed me to route globbing and a few minutes later I had phase one. Easy. I reworked quite a bit of it when editing proved to be cumbersome and ended up with something that was easy and that I could hold in my head. When something didn’t work, I knew why instantly. So here’s how to do it (though I plan on working this into a plugin with a generator sometime soon).
Step one, you are going to need some things
First things first, you need stringex. What is that? It is the best slug generating plugin going. It is based on permalink_fu but doesn’t use iconv. It is better than salty_slugs and friendly_id. Get it.
sudo gem install rsl-stringex-s http://gems.github.com
Next you want to be able to render content in friendly ways. Markdown and Textile seem to be the standard. Liquid just creates pain. For Textile you want RedCloth:
sudo gem install redcloth
There are some variants of RedCloth around you might find better. There is a nice fork on GitHub you could choose instead, but for me I just went with the normal gem. You will also need echoe:
sudo gem install echoe
For Markdown you need BlueCloth. BlueCloth can be found on GitHub in a fork by mislav but it doesn’t work out of the box without moving some files around. So I just installed the gem:
sudo gem install BlueCloth
Step two, you need a model and a controller; aka a resource
You need to store all of your pages somewhere. Honestly, I want this to just be a git repository, but for now the database will have to do. Go to your Rails folder and run:
$ script/generate resource Content text:text title:string url:string engine:string published:boolean |
Okay you are half way done. Okay, that’s hyperbole. Migrate that in:
$ rake db:migrate |
You want a few restrictions on your model. Probably more than I have here, but keeping with our minimal theme:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
require 'stringex' class Content < ActiveRecord::Base acts_as_url :title validates_presence_of :title validates_uniqueness_of :title validates_uniqueness_of :url named_scope :published, :conditions => 'published = 1' def to_param url end end |
Note that we require ‘stringex’ here where it will be used. This makes sense, but in a threadsafe world, it is probably better to just do this earlier in the initializer. We override the parameter with the generated url field. This makes our life easier later.
Step four, you need more in your controller too
For the controller, it is you everyday run of the mill restful resource. Mostly. I will break out each of the methods here; all of which could be dried up if you were using something fancy:
1 2 3 |
def index @contents = Content.find(:all) end |
Index is basic, grab a list of the items so you can have a landing page for your users. If you plan on having lots of contents, you may want to use will_paginate here.
1 2 3 4 |
def show @content = Content.published.find_by_url(params[:id] || params[:url].join('-slash-')) render(:text => 'Page could not be found', :layout => true, :status => 404) unless @content end |
The show method is where the magic of the whole thing is. We look up the content by the url (which comes in as the id parameter normally) so that we can show it. We also check for another parameter url which we join using the very strange ‘slash’. This is a trick which you may not want to use. Basically when you glob routes, you will get an array value for the parameter which is each of the steps. So if I submit:
/content/chunky/bacon
And have a route glob:
map.content '/content/*url', :controller => 'content', :action => 'show'
Then I will get the parameter url with the value +[‘chunky’, ‘bacon’]. Now to be honest, stringex and my Content model won’t know what to do with this, but I know that if someone were to type in the title “chunky/bacon” that stringex turns it into “chunky-slash-bacon”. So I cheat. A simple join on the array gives us what we want. So if there is no id, but there is a url we are good to go. If we can’t find what we were looking for we render a quick 404 page. You could render your site 404 if you wanted, or even alternate the message based on who is logged in. Up to you.
1 2 3 |
def new @content = Content.new end |
You want to create new pieces of content don’t you?
1 2 3 4 |
def edit @content = Content.find_by_url(params[:id]) render(:text => 'Page could not be found', :layout => true, :status => 404) unless @content end |
The edit method also handles 404 responses, but that isn’t strictly necessary. Also, notice that we don’t need the special route glob handling. We won’t get in here if there is no id. Well, that is probably not one hundred percent true. Someone might be able to name something weird and really mess things up. I am ignoring that.
The next methods are more or less boilerplate restful resource code. There is a lot of opportunity to dry things up here:
1 2 3 4 5 6 7 8 9 10 11 12 |
def create @content = Content.new(params[:content]) respond_to do |format| if @content.save format.html { redirect_to content_url(@content) } format.xml { head :created, :location => contents_url } else format.html { render :action => "new" } format.xml { render :xml => @content.errors.to_xml } end end end |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def update @content = Content.find_by_url(params[:id]) render(:text => 'Page could not be found', :layout => true, :status => 404) and return unless @content respond_to do |format| if @content.update_attributes(params[:content]) format.html { redirect_to contents_url(@content) } format.xml { head :ok } else format.html { render :action => "edit" } format.xml { render :xml => @content.errors.to_xml } end end end |
1 2 3 4 5 6 7 8 9 |
def destroy @content = Content.find_by_url(params[:id]) render(:text => 'Page could not be found', :layout => true, :status => 404) and return unless @content @content.destroy respond_to do |format| format.html { redirect_to contents_url } format.xml { head :ok } end end |
Step five, some views
My views are, again, pretty basic. I am using a form helper in my app that combines tags and lables. So this isn’t exactly basic erb. The edit template looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<div id="errors"> <%= error_messages_for :content %> </div> <% form_for(:content, :url => contents_url(@content), :html => { :method => :put }) do |f| %> <%= f.text_field :title, :label => 'Title' %> <%= f.label :text, 'Enter the content' %> <%= f.text_area :text, :style => 'width:100%' %> <%= f.check_box :published, :style => 'display:inline' %> <%= f.label :published, 'Published', :style => 'display:inline' %> <%= f.select :engine, [['Plain','plain'], ['Markdown', 'markdown'], ['Textile', 'textile']], :label => 'Format' %> <%= submit_tag %> <% end %> |
The only difference in the new template is the form tag:
<% form_for(:content, :url => contents_url) do |f| %> |
The index template is also basic:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<table>
<tr>
<th style="width:300px">Title</th>
<th style="width:150px">Link</th>
<th style="width:220px;text-align:center">Published</th>
<th style="width:220px"></th>
</tr>
<% for content in @contents %>
<tr class="<%= cycle("even","odd") -%>" >
<td><%= link_to content.title || 'No title', content_url(content) %></td>
<td><%= link_to content.url || 'No url', edit_content_url(content) %></td>
<td align="center"><%=h content.published ? 'Y' : 'N' %></td>
<td align="right"><%= link_to 'Destroy', content_url(content), :confirm => 'Are you sure?', :method => :delete %></td>
</tr>
<% end %>
</table>
<div>
<%= link_to 'New page', new_content_url %>
</div>
|
The last template is the show template, which simply calls out to our helper:
<%= render_contents(@content) %> |
Step six, helpers
We need one helper: render_contents. We need to require our engines and process away:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
require 'redcloth' require 'bluecloth' module ContentsHelper def render_contents(content) return '' unless content case content.engine when 'markdown' markdown(@content.text) when 'textile' RedCloth.new(@content.text).to_html else content.text end end end |
Note that we just return the content if it is not Textile or Markdown. This is probably a big security hole. In my application, I trust the people entering the data. If they hack themselves it is their business. You may not be so lucky. If not, just drop the text “engine” out of the formats list.
You may need to modify your application helper to include the ContentsHelper module for all controllers. This will make sense later.
Step seven, routes
The real magic is in the routes.
1 2 |
map.resources :content map.content '/content/*url', :controller => 'content', :action => 'show' |
By default we setup the content controller as a resource. This gives us the index, new, edit, create, update, destroy route handling for free. For simple urls, this gives us show as well. But if we have urls that have ”/” in them we will need something more robust which is the route glob.
Now this handles everyting nicely, but forces all of your created pages to start with ’/content/’ which may not be ideal. So at the bottom of the routes file (the lowest priority) I added one more route:
map.connect '*url', :controller => 'content', :action => 'show' |
If no controller catches the route, it will hit this point and give you one last chance to show the page using your content management system.
Extra credit
With everything working, you may sit back drink your cocoa and call it a night. Not so fast. With all of this you get a little extra magic: you can render your contents as partials anywhere else in your application. Simply create a page with the title ‘footer’ (it doesn’t even need to be published) and in the footer of your layout you can add:
<%= render_contents(Content.find_by_url('footer')) %> |
And blammo! Now your users can add in their own links too. In this case it is necessary to predefine the extension points in the application and again lots can go wrong. But it is simple and easy to fix if something goes wrong.
1 comment
Jump to comment form | comments rss [?]