Colin’s ALM Corner – Updated Blog Engine

news
49054

I have been using Blogger ever since I started my blog back in 2010. Once you get the template right (and set up a domain) it’s not a bad hosting platform. It works nicely with Windows Live Writer (as every self-respecting blog engine should). However, I felt it was time for a change – I wanted to take charge of my own blogging platform.

A couple of week’s ago I read a post by Scott Hanselman about Mad’s Krisensen’s MiniBlog engine. I had a look and liked it instantly – but there was no way to port from Blogger to MiniBlog. So I left it to stew in the back of my mind (*ominous chuckle - BWAHAHAHA*).

Porting to MiniBlog from Blogger

I finally had another look a few days ago to see if I could port my existing blog posts over. While there was no native way to do this, I found a util (BloggerBackup) that let me export my blog posts (in ATOM format). I promptly exported all my posts.

The next trick was to import them into MiniBlog format. Fortunately there’s a little util that converts from BlogEngine.NET (or WordPress) to MiniBlog called MiniBlogFormatter. I cloned the repo and wrote my own formatter. This wasn’t too hard – using some Linq-to-XML I had something going pretty quickly. Here’s the code:

public class BloggerATOMFormatter
{
    public void Format(string originalFolderPath, string targetFolderPath)
    {
        FormatPosts(originalFolderPath, targetFolderPath);
    }

    private void FormatPosts(string originalFolderPath, string targetFolderPath)
    {
        var oldPostList = new Dictionary<string, string>();
        foreach (string file in Directory.GetFiles(originalFolderPath, "*.xml").Where(s => !s.EndsWith("comments.xml")))
        {
            var originalDoc = LoadDocument(file);
            XNamespace atomNS = @"http://www.w3.org/2005/Atom";

            var entry = originalDoc.Element(atomNS + "entry");

            var title = entry.Element(atomNS + "title").Value;
            var oldUrl = (from link in entry.Elements(atomNS + "link")
                          where link.Attributes().ToList().Any(a => a.Name == "rel" && a.Value == "alternate")
                          select link).First().Attribute("href").Value.Replace("http://www.colinsalmcorner.com", "");
            
            var content = FixContent(entry.Element(atomNS + "content").Value);
            var publishDate = DateTime.Parse(entry.Element(atomNS + "published").Value);
            var lastModDate = DateTime.Parse(entry.Element(atomNS + "updated").Value);
            var slug = FormatterHelpers.FormatSlug(title);
            var categories = from cat in entry.Elements(atomNS + "category")
                             select cat.Attribute("term").Value;

            var post = new Post();
            post.Author = "Colin Dembovsky";
            post.Categories = categories.ToArray();
            post.Content = content;
            post.IsPublished = true;
            post.PubDate = publishDate;
            post.Title = title;
            post.Slug = slug;
            post.LastModified = lastModDate;
            post.Comments = GetCommentsForPost(file);

            var newId = Guid.NewGuid().ToString();
            Storage.Save(post, Path.Combine(targetFolderPath, newId + ".xml"));
            oldPostList[oldUrl] = newId;
        }
        SaveOldPostMap(targetFolderPath, oldPostList);
    }

    private void SaveOldPostMap(string targetFolderPath, Dictionary<string, string> oldPostList)
    {
        var mapElement = new XElement("OldPostMap");
        foreach(var key in oldPostList.Keys)
        {
            mapElement.Add(
                new XElement("OldPost",
                    new XAttribute("oldUrl", key),
                    new XAttribute("postId", oldPostList[key])
                )
            );
        }
        var doc = new XDocument(mapElement);
        doc.Save(Path.Combine(targetFolderPath, "oldPosts.map"));
    }

    private List<Comment> GetCommentsForPost(string file)
    {
        var commentsFile = file.Replace(".xml", ".comments.xml");
        if (!File.Exists(commentsFile))
        {
            return new List<Comment>();  
        }

        var commentsDoc = LoadDocument(commentsFile);
        XNamespace atomNS = @"http://www.w3.org/2005/Atom";

        var list = new List<Comment>();
        foreach (var originalComment in commentsDoc.Descendants(atomNS + "entry"))
        {
            var authorElement = originalComment.Element(atomNS + "author");
            var name = authorElement.Element(atomNS + "name").Value;
            var email = authorElement.Element(atomNS + "email").Value;
            var uriElement = authorElement.Element(atomNS + "uri");
            string website = null;
            if (uriElement != null)
            {
                website = uriElement.Value;
            }

            var content = originalComment.Element(atomNS + "content").Value;
            var publishDate = DateTime.Parse(originalComment.Element(atomNS + "published").Value);

            var comment = new Comment();
            comment.Author = name;
            comment.Email = email;
            comment.PubDate = publishDate;
            comment.Content = content;
            comment.IsAdmin = false;
            comment.Website = website;
            list.Add(comment);
        }

        return list.OrderBy(c => c.PubDate).ToList();
    }

    private string FixContent(string originalContent)
    {
        var regex = new Regex("<pre class=\"brush: \\w*;\">(.*?)</pre>", RegexOptions.IgnoreCase);
        foreach(Match match in regex.Matches(originalContent))
        {
            var formatted = match.Groups[1].Value.Replace("<br />", Environment.NewLine);
            originalContent = originalContent.Replace(match.Groups[1].Value, formatted);
        }
        return originalContent.Replace("<p></p><br />", "").Replace("<p></p>", "").Replace("<h3>", "<h2>").Replace("</h3>", "</h2>");
    }

    private XDocument LoadDocument(string file)
    {
        return XDocument.Parse(File.ReadAllText(file));
    }
}

There is a bit of “colinsALMcorner” specific code here, but if you’re looking to move from Blogger to MiniBlog you should be able to use most of this code. I had some issues with the formatting of the <pre> sections for Syntax Highlighter – once I had that sorted, the formatter worked flawlessly.

Redirecting Existing Posts

One of the challenges I had was what about search engines that already reference existing posts? Since I wanted to host MiniBlog on Azure and point my domain to the new site, I wanted to preserve any existing reference. However, the naming scheme for posts in Blogger is different from that in MiniBlog.

What I ended up doing was creating a map file as part of my convert-from-blogger-file-to-MiniBlog-file in the MiniBlogFormatter. I then created a simple HttpHandler that can server a “301 Moved Permanently” redirect when you hit an old post. Here’s the code:

public class OldPostHandler : IHttpHandler
{
    public bool IsReusable
    {
        get { return false; }
    }

    public void ProcessRequest(HttpContext context)
    {
        var oldUrl = context.Request.RawUrl;
        var oldPost = Storage.GetOldPost(oldUrl);

        if (oldPost == null)
        {
            throw new HttpException(404, "The post does not exist");
        }

        var newUrl = "/post/" + oldPost.Slug;
        context.Response.Status = "301 Moved Permanently";
        context.Response.AddHeader("Location", newUrl);
    }
}

It’s small, neat and quick – keeping in line with the MiniBlog philosophy. Here’s the Storage.GetOldPost() method:

public static Post GetOldPost(string url)
{
    var map = GetOldPostMap();
    if (map.ContainsKey(url))
    {
        return GetAllPosts().SingleOrDefault(p => p.ID == map[url]);
    }
    return null;
}

public static Dictionary<string, string> GetOldPostMap()
{
    GetAllPosts();

    if (HttpRuntime.Cache["oldPostMap"] != null)
    {
        return (Dictionary<string, string>)HttpRuntime.Cache["oldPostMap"];
    }
    return new Dictionary<string, string>();
}

private static void LoadOldPostMap()
{
    var map = new Dictionary<string, string>();
    var mapFile = Path.Combine(_folder, "oldPosts.map");
    if (File.Exists(mapFile))
    {
        var doc = XDocument.Load(mapFile);
        foreach (var mapping in doc.Descendants("OldPost"))
        {
            var oldUrl = mapping.Attribute("oldUrl").Value;
            var newId = mapping.Attribute("postId").Value;
            map[oldUrl] = newId;
        }
    }
    HttpRuntime.Cache.Insert("oldPostMap", map);
}

GetAllPosts() add’s a call to LoadOldPostMap() which finds the map file and reads it into memory. I only have 87 posts, so it’s not too heavy.

Here’s the code to invoke the handler in web.config:

<handlers>
  <remove name="CommentHandler"/>
  <add name="CommentHandler" verb="*" type="CommentHandler" path="/comment.ashx"/>
  <remove name="PostHandler"/>
  <add name="PostHandler" verb="POST" type="PostHandler" path="/post.ashx"/>
  <remove name="MetaWebLogHandler"/>
  <add name="MetaWebLogHandler" verb="POST,GET" type="MetaWeblogHandler" path="/metaweblog"/>
  <remove name="FeedHandler"/>
  <add name="FeedHandler" verb="GET" type="FeedHandler" path="/feed/*"/>
  <remove name="FeedsHandler"/>
  <add name="FeedsHandler" verb="GET" type="FeedHandler" path="/feeds/*"/>
  <remove name="CssHandler"/>
  <add name="CssHandler" verb="GET" type="MinifyHandler" path="*.css"/>
  <remove name="JsHandler"/>
  <add name="JsHandler" verb="GET" type="MinifyHandler" path="*.js"/>
  <remove name="OldPostHandler"/>
  <add name="OldPostHandler" verb="GET" type="OldPostHandler" path="*.html"/>
</handlers>

You’ll see that I also added a “FeedsHandler” as well to work with the blogger feeds format, so that existing subscribers wouldn’t be affected by the switch (hopefully).

I then styled the site (since it’s based on bootstrap that wasn’t a problem). I also added a tag-cloud function and a search function. Both turned out to be really simple.

Tag Cloud

I needed a method that would return all the categories and their frequency for the tag cloud. Here’s the code in the backend:

public static Dictionary<string, int> GetTags()
{
    var categories = Storage.GetAllPosts().SelectMany(p => p.Categories).Distinct();
    var tags = new Dictionary<string, int>();
    foreach(var cat in categories)
    {
        var count = Storage.GetAllPosts().Where(p => p.Categories.Any(c => c.Equals(cat, StringComparison.OrdinalIgnoreCase))).Count();
        tags[cat] = count;
    }
    return tags;
}

Next I had to find a way to present a tag cloud on the page using javascript. There are lots of ways of doing this – I ended up using this jQuery tagcloud script. Here’s the html for my tag cloud:

<div id="tagcloud">
    @{
        var tags = Blog.GetTags();
        foreach (var tag in tags.Keys)
        {
            <a href="/category/@tag" rel="@tags[tag]">@tag</a>
        }
    }
</div>

<script type="text/javascript">
    // tag cloud script
    $("#tagcloud a").tagcloud({
        size: {
            start: 0.8,
            end: 1.75,
            unit: 'em'
        },
        color: {
            start: "#7cc0f4",
            end: "#266ca2"
        }
    });
</script>

Search

I regularly search my own blog – it’s a “working journal” of sorts. Having a search function was pretty important to me. Again the solution was really simple. Here’s the search code:

public static List<Post> Search(string term)
{
    term = term.ToLower();
    return (from p in Storage.GetAllPosts()
            where p.Title.ToLower().Contains(term) || p.Content.ToLower().Contains(term) || p.Comments.Any(c => c.Content.ToLower().Contains(term))
            select p).ToList();
}

Once I had the results, I created a new search.cshtml page that shows just the first few lines of the blog post:

@{
    var term = Request.QueryString["term"];

    Page.Title = Blog.Title;
    Layout = "~/themes/" + Blog.Theme + "/_Layout.cshtml";
    
    if (string.IsNullOrEmpty(term))
    {
        <h1>Oops!</h1>
        <p>Something went wrong with your search. Try again...</p>
    }
    else
    {
        <h1>Results for search: '@term'</h1>
        
        var list = Blog.Search(term);
        if (list.Count == 0)
        {
            <p>No matches...</p>
        }
        else
        {
            foreach(var p in list)
            {
                @RenderPage("~/themes/" + Blog.Theme + "/PostSummary.cshtml", p);
            }
        }
    }
}

The final bit was to get a search control. I ended up doing one entirely in css:

input {
    outline: none;
}
input[type=search] {
    -webkit-appearance: textfield;
    -webkit-box-sizing: content-box;
    font-family: inherit;
    font-size: 80% !important;
}
input::-webkit-search-decoration,
input::-webkit-search-cancel-button {
    display: none; /* remove the search and cancel icon */
}

/* search input field */
input[type=search] {
    background: #ededed url(images/search-icon.png) no-repeat 9px center;
    border: solid 1px #ccc;
    padding: 5px 5px 5px 10px;
    width: 130px;
    
    -webkit-border-radius: 10em;
    -moz-border-radius: 10em;
    border-radius: 10em;
    
    -webkit-transition: all .5s;
    -moz-transition: all .5s;
    transition: all .5s;
}
input[type=search]:focus {
    width: 100%;
    background-color: #fff;
    border-color: #6dcff6;
    
    -webkit-box-shadow: 0 0 5px rgba(109,207,246,.5);
    -moz-box-shadow: 0 0 5px rgba(109,207,246,.5);
    box-shadow: 0 0 5px rgba(109,207,246,.5);
}

/* placeholder */
input:-moz-placeholder {
    color: #999;
}
input::-webkit-input-placeholder {
    color: #999;
}

And here’s the search control in my side-bar:

<section>
    <br />
    <form action="/search" method="get" role="form" id="searchForm">
        <fieldset>
            <input type="search" placeholder="Search this blog" name="term">
        </fieldset>
    </form>
    <hr />
</section>

Approve or Delete Comments from the Alert Mail

When someone writes a comment on a post, MiniBlog sends you an email. I like to moderate comments, so that’s how I’ve configured MiniBlog. In the mail there are 2 links – one to approve and one to delete the comment. However, I kept getting 403 “unauthorized” then clicking the links if I wasn’t logged in on the site. I made a small tweak to the CommentHandler Accept and Delete methods to redirect me to the login page instead of throwing a 403:

if (!context.User.Identity.IsAuthenticated)
{
    // was throwing 403 here
    FormsAuthentication.RedirectToLoginPage();
    return;
}

Now when I hit the link from my mail, I get redirected to the login screen. Once logged in, the comment is approved/deleted and all’s well.

Publishing to Azure

After testing posting from Windows Live Writer (no issues there) I then published the site to Azure. I changed my DNS records from Blogger to Azure and hey presto – new site is up!

Conclusion

I’m really happy with the new look & feel and with the other modern web benefits (like SEO optimization and of course, speed) that MiniBlog brings. Thanks Mads!

I expect there may be a glitch or two for the switch over, but hopefully everything works well. Let me know in the comments if you experience any issues.

Happy reading!