Search Engine Optimisation

By
Dave
Project
Published
16 Jul 2010 17:16
Last Modified
13 Jan 2013 18:17

Search Engine Optimisation (SEO) is an important consideration for any website. In addition to the sitemap already discussed for my blog engine, I'd been meaning to do some further work on SEO and I thought I'd start by downloading the SEO Toolkit and throwing it at my site.

Fortunately, it picked up all sorts of "violations". In order to correct these, and other issues relevant to SEO, I made the following changes:

  1. Corrected HTML formatting issues such as non-closed or improperly-nested tags
  2. Added <meta name="keywords"> and <meta name="description"> tags to the post detail view and post model (and updated my posts with relevant tags and descriptions)
  3. Removed multiple canonical formats when using paging-links. For example, instead of rendering a link to http://drdave.co.uk/blog/?page=1, I just use http://drdave.co.uk/blog/
  4. Issued HTTP 301 (Moved Permanently) status codes for links to my previous blog engine, as discussed in the comments on legacy & not-found links.

These are just some of the potential updates that may help optimise my blog engine for search engines, but they are a good starting point.

Blog Engine Download

By
Dave
Project
Published
11 Jul 2010 16:10
Last Modified
3 Jan 2011 01:54

I thought I'd make available the Blog Engine I've just written using ASP.NET MVC2. This is a lightweight, personal blogging engine, and is being used to run this site. It is intended for use with IIS7 (running in Integrated Mode). Currently it uses XML files for storage, and no databases are required.

The following downloads are available:

  1. Dr Dave's Blog Engine (binaries and ASP.NET pages) (zip'd), 188Kb. Files required to run Dr Dave's Blog Engine.
  2. Dr Dave's Blog Engine (source code) (zip'd), 47Kb. Visual Studio 2010 project for Dr Dave's Blog Engine

To get started with the binaries, follow these steps:

  1. Un-zip the blog engine binaries and ASP.NET pages.
  2. If your website is not on the root web, i.e. http://{domain}, but instead sits under a sub-directory, e.g. http://{domain}/{directory}, update the "VirtualPath" setting in the Web.Config file with the directory, e.g.

    <add key="virtualpath" value="/{directory}">

    Don't forget the forward-slash before (but not after) the directory name.

  3. Change the Title and Subtitle properties, also in Web.Config.
  4. If you would like to change the Author name, update the value in the /App_Data/users.xml file.
  5. Update the feed title and link properties in the site.master file, e.g.

    <link rel="alternate" type="application/rss+xml"
        title="My Title" href="http://{domain}/{directory}/feed" />
  6. Copy the files to your website.
  7. Navigate to http://{domain}/{directory}, and you should see the following:

    front page

    Figure 1. Front page after installation

  8. Click "Sign In". The default username is "admin" with a password of "password".
  9. Click on "Change Password" and change the password. A combination of upper-case letters, lower-case letters and numbers is recommended, with a length of at least six characters.

If you decide to use this engine to run your blog, please keep the "Powered by" link, and I'd be grateful if you could inlcude an acknowledgement.

This project remains under active development as and when bugs are identified, or further features are required and I have the time. Note that I do not have time to support the installation or operation of this blog engine, however I will endeavour to answer questions in comments.

Sitemaps

By
Dave
Project
Published
10 Jul 2010 17:19
Last Modified
13 Jan 2013 18:20

As part of optimising the results from search engines such as Bing and Google, I added a dynamic site map. The structure of this file is defined by sitemaps.org. In order to generate a suitable file, I added a simple action as shown below in Listing 1.

public ContentResult Sitemap()
{
    XNamespace xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9";
    XElement root = new XElement(xmlns + "urlset");

    // home
    root.Add(GetUrlElement(xmlns, ""));

    // posts, categories etc
    foreach (...)
    {
        string url = Url.RouteUrl("MyRoute", new { myparam = value });
        root.Add(GetUrlElement(xmlns, url);
    }

    return Content(root.ToString(), "text/xml", Encoding.UTF8);
}

private XElement GetUrlElement(XNamespace xmlns, string relativeUri)
{
    return new XElement(xmlns + "url",
        new XElement(xmlns + "loc", new Uri(Request.Url, relativeUri).AbsoluteUri));
}

Listing 1. Action to generate dynamic sitemap

While not shown above in Listing 1, I also added <lastmod /> elements, e.g. which corresponded to the last-modified date of a post. The final step was to add a suitable route to global.asax, as shown below in Listing 2.

routes.MapRoute(
    "Sitemap",
    "sitemap.xml",
    new { controller = "MyController", action = "Sitemap" }
);

Listing 2. Sitemap route

The sitemap is then available at http://{domain}/sitemap.xml and can be submitted to search engines using their webmaster tools. For Bing, the webmaster pages can be found at http://www.bing.com/webmaster. For Google the link is http://www.google.com/webmasters/tools.

Legacy & Not-Found Links

By
Dave
Project
Published
10 Jul 2010 17:06
Last Modified
3 Jan 2011 01:32

I needed to deal with existing links to my previous blog engine, as well as invalid requests to the new engine.

Legacy Links

As I mentioned previously, my new blog engine has opted for a different URL structure to my old engine. In order to support existing links to my old blog posts, I added some legacy routes to my routing table, as follows:

  1. Category links of the format http://{domain}/{directory}/category/{name}.aspx are mapped to my existing controller with the following route:
    routes.MapRoute(
        "MyRoute",
        "category/{name}.aspx",
        new { controller = "MyController", action = "MyAction" });
    
  2. Post links of the format http://{domain}/{directory}/post/{name}.aspx are mapped to my existing controller with the following route:
    routes.MapRoute(
        "MyRoute",
        "post/{name}.aspx",
        new { controller = "MyController", action = "MyAction" });
    
  3. Month links of the format http://{domain}/{directory}/{year}/{month}/default.aspx are mapped to my existing controller with the following route:
    routes.MapRoute(
        "MyRoute",
        "{year}/{month}/default.aspx",
        new { controller = "MyController", action = "MyAction" });
    
  4. The syndication link http://{domain}/{directory}/syndication.axd?format={format} is mapped to my existing controller with the following route:
    routes.MapRoute(
        "MyRoute",
        "syndication.axd",
        new { controller = "MyController", action = "MyAction" });
    

The nice thing about this approach is that I can support these legacy links without any additional code, just these additions to my routing table.

Not-Found Links

Requests to the new blog engine might not return anything for a couple of reasons:

  1. An invalid controller or action is specified
  2. A invalid parameter is passed to a controller action

For invalid controller requests, a catch-all route is a good starting point, such as the following:

routes.MapRoute(
    "Error",
    "{*url}",
    new { controller = "Error", action = "Http404" });

This ensures that requests of an unrecognised pattern will be passed to an error controller, and an appropriate view used to render an error.

For invalid parameters, additional code is required in the relevant controllers to redirect to a "Not Found" view, for example when a requested post is not found.

Checkbox Lists

By
Dave
Project
Published
28 Jun 2010 01:17
Last Modified
8 Dec 2012 16:42

Categories and tags are commonly used to classify blog posts. In order to specify the relevant categories and tags when creating and editing posts, two different approaches were taken, as shown below in Figure 1.

Checkbox list

Figure 1. Category checkbox list and comma-seperated tags

For the tags, a simple textbox can be used, with a controller action which parses a comma-seperated list. For the category list, a series of checkboxes can be rendered with the following view code:

<div>
    <% foreach (SelectListItem item in Model.CategoryList)
       {%>
    <input type="checkbox" class="checkbox" name="Categories"
        value="<%= item.Value%>"
        <%= item.Selected ? "checked=\"checked\"" : "" %> />
    <%= Html.Label(item.Text)%>
    <%} %>
</div>

In order to simplify the markup in the view, I created a simple HtmlHelper. The view code then becomes as follows:

<%= Html.CheckBoxList(Model.CategoryList, "Categories", new { Class = "checkbox" }, null)%>

The HtmlHelper is as follows:

public static string CheckBoxList(this HtmlHelper helper, List<SelectListItem> selectList,
    string name, object checkboxHtmlAttributes, object labelHtmlAttributes)
{
    StringBuilder stringBuilder = new StringBuilder();

    foreach (SelectListItem item in selectList)
    {
        TagBuilder tagBuilder = new TagBuilder("input");
        tagBuilder.MergeAttribute("type", "checkbox");
        tagBuilder.MergeAttribute("name", name);
        tagBuilder.MergeAttribute("value", item.Value);
        tagBuilder.MergeAttributes(new RouteValueDictionary(checkboxHtmlAttributes));

        if (item.Selected)
            tagBuilder.MergeAttribute("checked", "checked");

        // add checkbox
        stringBuilder.AppendLine(tagBuilder.ToString(TagRenderMode.SelfClosing));

        tagBuilder = new TagBuilder("label");
        tagBuilder.SetInnerText(item.Text);
        tagBuilder.MergeAttributes(new RouteValueDictionary(labelHtmlAttributes));

        // add label
        stringBuilder.AppendLine(tagBuilder.ToString());
    }

    return stringBuilder.ToString();
}

The model must include both the SelectList to pass the list of categories to the view, and an array of strings to post the selected values back to the controller, as follows:

public class MyModel
{
    ...
    public List<SelectListItem> CategoryList { get; set; }
    public string[] Categories { get; set; }
}

Regardless of which approach is used, the controller code looks like the following:

[HttpPost]
public ActionResult MyAction(MyModel model)
{
    if (model.Categories != null)
    {
        foreach (string item in model.Categories)
        {
            ...
        }
    }
    ...
}

Note that in both cases, only the checkboxes that are checked will have their values submitted to the controller, which is fine for this approach, as I (re)consruct the list of selected categories on each action.

Valid Comments

By
Dave
Project
Published
26 Jun 2010 23:02
Last Modified
8 Dec 2012 16:44

One of the trickier things I had to do for my blog engine was to support comment submission. Comments are only shown for a post when viewing the "Detail" view of a single blog post, and I wanted to show a form to submit a new comment at the bottom of this page.

In order to do this I had to use a specific ViewModel class to pass both the blog post, and a skeleton comment to the view. In this way, when the form is posted to the server, the default ModelBinder will present the controller with the comment. However, since the blog post is not on the HTML form (it only has the comment), I have to maintain a reference to the blog post using an HTML hidden form field:

<%= Html.HiddenFor(model => model.Comment.Post.Id) %>

The normal controller pattern to use when validating and saving data is as follows:

public ActionResult MyAction(...)
{
    ...
    Post post = myServiceLayer.GetPost(...);
    return View(post);
}

[HttpPost]
public ActionResult MyAction(...)
{
    ...
    if (myServiceLayer.AddComment(comment));
    {
        return RedirectToAction("MyAction");
    }
    else
    {
        // rebuild ViewModel and return view
        return View("MyView", new MyViewModel { Post = myServiceLayer.GetPost(...), Comment = comment });
    }
}

This pattern is used such that a successful post returns a client-side redirect to a page which re-displays the view. If the user refreshes their browser page, since we've done a re-direct there will be no prompts to re-submit the HTML form. This is called Post-Redirect-Get (PRG).

What happens if the comment fails validation? In this case, I need to re-display the form with the relevant errors and submitted values. I also need to ensure that the view is scrolled to the correct location so that the Comment form is displayed. Firstly I rebuild the ViewModel using the the submitted comment and blog post id from the hidden HTML field. Next, in order to return the ViewModel and ModelState, which contains the validation errors, I need to return a view. The only way I can find to specify a HTML anchor tag for this view is to include a HTML Action property on the HTML form in the view itself:

<div id="MyAnchor">
    MyTitle</div>
<%= Html.ValidationSummary(true, "...") %>
<% using(Html.BeginForm("MyAction", "MyController", FormMethod.Post,
    new { Action = string.Format("{0}#MyAnchor", Request.Url.AbsoluteUri) })) %>

A screenshot is shown below in Figure 1.

Blog comment validation

Figure 1. Blog comment validation.

I could have used AJAX to do this and avoid the problem of having to scroll the form, however I would still have to provide downlevel support for browsers with Javascript disabled.

A New Blog

By
Dave
Project
Published
21 Jun 2010 23:04
Last Modified
13 Jan 2013 18:21

I finally decided to switch to using my own blog engine. There were several reasons for this:

  1. I was getting too much spam with my existing engine
  2. I wanted complete control over rendering
  3. But really...I needed an excuse to learn the ASP.NET MVC2 Framework! :)

I didn't need anything particularly fancy, and the following requirements would initially suffice:

  1. Single author, single blog
  2. View posts, with filtering by category, tag, year, month, day, and name
  3. Paging on post views
  4. Submit comments
  5. View comments with post detail
  6. Author sign-in to create & edit posts, edit & delete comments
  7. Syndication
  8. Recent posts, archive list, tag cloud, blogroll

Going forward, the following were good candidate next-steps:

  1. Live-writer support
  2. SEO
  3. Further feed-format support for ATOM
  4. Mobile support

Storage

I decided to stick with XML files for the time-being, mainly since my previous engine used them and it would save having to migrate the content to a database. Going forward, a database would clearly be a more scalable option, but it will be a while before I generate enough content for this to be an issue.

Syndication

I used the WCF SyndicationFeed and SyndicationItem classes to build an RSS 2.0 feed, and returned the feed from my controller as a derived ActionResult, using an approach outlined in this post on Joe Wardell's blog, and shown below in Listing 1.

public class RssResult : ActionResult
{
    public SyndicationFeed Feed { get; set; }

    public RssResult() { }

    public RssResult(SyndicationFeed feed)
    {
        this.Feed = feed;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        context.HttpContext.Response.ContentType = "application/rss+xml";
        Rss20FeedFormatter formatter = new Rss20FeedFormatter(this.Feed);
        using (XmlWriter writer = XmlWriter.Create(context.HttpContext.Response.Output))
        {
            formatter.WriteTo(writer);
        }
    }
}

Listing 1. Returning an RSS feed as a derived ActionResult

URL Structure

Backwards-compatibility with any links to the existing blog were something I needed to consider. An appropriate route specification should ensure compatibility, but at the expense of having to use .aspx extensions.

Looking at popular blog engines such as WordPress and MSDN, I decided to use the following scheme:

  1. http://{domain}/blog/ for all posts
  2. http://{domain}/blog/arhive/{year} for all posts in a given year>
  3. http://{domain}/blog/arhive/{year}/{month} for all posts in a given month
  4. http://{domain}/blog/arhive/{year}/{month}/{day} for all posts in a given day
  5. http://{domain}/blog/arhive/{year}/{month}/{day}/{name} for a specific post
  6. http://{domain}/blog/category/{name} for posts in a given category
  7. http://{domain}/blog/tag/{name} for posts with a given tag
  8. http://{domain}/blog/feed for an RSS 2.0 feed

Posts are paged and ordered chronologically with newest posts first.

I'm hoping to switch in a couple of days, at which point any feed subscriptions will need updating. Apologies for any inconvenience.

Page