Miroslav Holec
Premium

Markdown blog za 60 minut

Miroslav Holec   27. září 2014  update 29. ledna 2016

Tento článek je již zastaralý. Článek nemusí popisovat aktuální stav technologie, ideální řešení a můj současný pohled na dané téma.

Po spuštění nového webu miroslavholec.cz jsem si všiml některých příjemných skutečností, souvisejících s návštěvností, SEO optimalizací a obsahem webu. Došlo mi, že v Google držím první místo na klíčová slova "školení resharper" a uvědomil jsem si, že bych chtěl takto získat traffic.

Jako nejsnazší cesta se nabízela mít blog s dobře cílenými články ale nechtěl jsem ztrácet čas vývojem obskurního cms nebo nastavováním Wordpressu. A tak jsem udělal minimalistický systém blogování založený na syntaxi markdown. Napsat článek nyní znamená nahrát MD soubor + json na patřičné místo v Azure Storage. Celé řešení popíšu v tomto postu.

Výhody celého řešení

  • velmi rychlé psaní článků bez nutnosti práce s pomalým a obskurním CMS
  • psaní článku znamená otevřít MD soubor (v podstatě txt s jinou příponou)
  • editace a live view ve skvělém editoru MarkdownPad2 (free) - i pro iPhone a iPad (paid)
  • dobře řešené zálohování (soubory mám v Dropboxu a po napsání je jen nahraju do Azure Storage) pomocí CloudBerry Exploreru

1. Nastavení routování

Základem je pěkná URL, o to mi šlo především. Proto jsem chtěl docílit následujícího:

http://www.miroslavholec.cz/blog
http://www.miroslavholec.cz/blog/rss
http://www.miroslavholec.cz/blog/sitemap
http://www.miroslavholec.cz/blog/markdown-blog-za-60-minut
http://www.miroslavholec.cz/blog/1

Můj blog ještě umí štítky, což je defacto jen QueryString parametr a podle toho upravené Actions. O tom ale psát nebudu.

routes.MapRoute(
    name: RouteNames.Blog,
    url: "blog",
    defaults: new { controller = "Blog", action = "Index" }
);

routes.MapRoute(
    name: RouteNames.BlogSitemap,
    url: "blog/sitemap",
    defaults: new { controller = "Blog", action = "Sitemap" }
);

routes.MapRoute(
    name: RouteNames.BlogRss,
    url: "blog/rss/{tag}",
    defaults: new { controller = "Blog", action = "Rss", tag = UrlParameter.Optional }
);

routes.MapRoute(
    name: RouteNames.BlogPage,
    url: "blog/{page}",
    defaults: new { controller = "Blog", action = "Index" },
    constraints: new { page = @"\d+" }
);

routes.MapRoute(
    name: RouteNames.BlogDetail,
    url: "blog/{id}",
    defaults: new { controller = "Blog", action = "Detail" }
);

Hezčí by to asi bylo celé jako samostatná area, ale vzhledem k tomu, jak mám malý web jsem nechtěl věci komplikovat.

Ještě poznamenám, že {id} je unikátní název článku bez diakritiky a mezer. Tedy to, co se objevuje v URL. Zároveň je to unikátní název pro vytváření souborů ve formátu json a md. V případě článku, který právě čtete, existují tedy dva soubory:

markdown-blog-za-60-minut.md
markdown-blog-za-60-minut.json

2. Article.cs + json

Informace o článku ukládám do json souboru, který se následně deserializuje na do objektu Article.

JSON

{
	"Title":"Markdown blog za 60 minut",
	"Id":"markdown-blog-za-60-minut",
	"Body":"Nějaký body pro RSS, zároveň HTML description",
	"IsCommentable":true,
	"Created":"2014-09-26 22:00:00",
	"Keywords":["mvc", "off topic"]
}

Article.cs

Tak nějak předvídatelný :)

public class Article
{
    public string Title { get; set; }
    public string Id { get; set; }
    public string Body { get; set; }
    public string Html { get; set; }
    public bool IsCommentable { get; set; }
    public DateTime Created { get; set; }
    public List<string> Keywords { get; set; }
}

3. BlogController.cs

Všechno je vlastně založeno na dvou základních akcích. Za prvé načtení dávky článků ze storage a za druhé načtení detailů článku. Když umíme načíst dávku článků, můžeme ji zobrazit jako seznam článků, rss, sitemapu atd.

Action Index(int page)

[OutputCache(Duration = CacheDuration, VaryByParam = "page")]
public ActionResult Index(int page = 1)
{
    ViewBag.CurrentPage = page;

    return View(GetArticles(page));
}

To je celé. Parametr page je volitelný. Ukládám si ho do ViewBagu. To hlavní je tedy načtení článků ze storage. To řeší metoda GetArticles(int page). Algoritmus je následující:

  • načti všechny soubory z blob containeru, které mají příponu json
  • pomocí Newtonsoft.Json deserializuj všechny json do objektů typu Article a naplň kolekci articles
  • nastav aktuální datum (na localhostu mám +1 year abych viděl i naplánované články)
  • pomocí linq vyber jen články dosud vydané, seřaď je dle data a nastránkuj jen ty potřebné
  • teď načti všechny reference na bloby z blob containeru s přípnou md
  • ke každému článku (teď už jen zbývajících max. 10) donačti (markdown) obsah těchto blobů
  • a tento obsah zkompiluj z markdown syntaxe do HTML  

    private List<Article> GetArticles(int page)
    {
        var articles = new List<Article>();
        var files = AzureStorage.GetBlobs("articles", "json");
    
    
    
    foreach (var file in files)
    {
        try
        {
            var settings = new JsonSerializerSettings { Formatting = Formatting.None };
            var article = JsonConvert.DeserializeObject&lt;Article>(file.Content, settings);
    
    
    
        articles.Add(article);
    
    
    }
    catch (Exception ex)
    {
        Log.Error(ex);
    }
    
    } DateTime now = DateTime.UtcNow; if (Request.IsLocal) now = now.AddYears(1); articles = articles .Where(x=> x.Created < now) .OrderByDescending(x => x.Created) .Skip((page*PageSize) - PageSize) .Take(PageSize) .ToList(); var contents = AzureStorage.GetBlobs("articles", "md"); foreach (var article in articles) { BlobFile contentFile = contents.FirstOrDefault(x => x.Name == string.Concat(article.Id, ".md"));
    if (contentFile != null)
    {
        var compiler = new Markdown();
        var html = compiler.Transform(contentFile.Content);
        article.Html = html;
    }
    
    } return articles;

    }

Nakonec tu ještě máme třídu Markdown a metodu Transform(string text). To už není moje práce a originální kód ke stažení compileru najdete na aspnetresources.com.

Action Detail(int id)

[OutputCache(Duration = CacheDuration, VaryByParam = "id")]
public ActionResult Detail(string id)
{
    Article article;

    try
    {
        var file = AzureStorage.GetBlob("articles", string.Concat(id, ".json"));
        var content = AzureStorage.GetBlob("articles", string.Concat(id, ".md"));

        article = JsonConvert.DeserializeObject&lt;Article>(file.Content);

        var compiler = new Markdown();
        var html = compiler.Transform(content.Content);

        article.Html = html;
    }
    catch (Exception ex)
    {
       return RedirectToRoute(RouteNames.Blog);
    }

    return View("~/Views/Blog/Detail.cshtml", article);
}

Tady už je to celkem jednoduché. Na základě id článku (tedy názvu souboru bez přípony) se načte json a md soubor. JSON se deserializuje do objektu Article a md se zkompiluje do html, které se uloží do property article.html. Když všechno dopadne dobře, předá se article do view. V případě chyby jen suše uživatele přesměruji na homepage.

Rss() a Sitemap()

Akce Rss() a Sitemap() už jsou prakticky stejné jako Index. Pouze se zavolá GetArticles() metoda, ta vrátí několik článků a ty se následně zobrazí podle potřeby.

Drobnosti na závěr

PageSize ještě udává počet příspěvků na stránku. Je to konstanta přímo v BlogController.cs. CacheDuration je konstanta nastavená někde na BaseControlleru a udává dobu, po kterou zůstává stránka nacachovaná.

AzureStorage.GetBlob() je metoda, která vrací seznam blobů. Stejně tak na tomto místě může být metoda, která vrátí data z lokálního úložiště.

Poznámky k řešení a optimalizace

  • U většího množství článků není ideální vytvářet a načítat json pro každý článek (nakonec se jich stejně vezmě jen 10) a bylo by lepší mít to vše v jednom souboru. Ten deserializovat. Zapracuju na tom, až budu mít na webu alespoň 20 článků a zveřejním update tohoto článku.
  • Když se nenajde detail článku, bylo by vhodné uživatele lépe navést (pokud skutečně neexistuje ukázat 404, pokud například selhala kompilace, ukázat vysvětlení na 500 page).
  • Pro stránkování by bylo vhodnější nevracet List<Article> ale raději nějaký PagedList<Article>, který by obsahoval i informace ke stránkování (aktuální stránka, počet záznamů atd...)

Obrázky k článkům

Obrázky je nutné nejprve připravit, nahrát do Azure Storage a pak teprve je nalinkovat. Uznávám, že tohle je trochu přes ruku ale pokud obrázky stejně nejprve přeipravujete, jediný krok navíc je tam ten upload do storage.

Zdrojáky

Na zobrazení zdrojáků používám SyntaxHighlighter. Ten mám pevně nastavený na csharp (i když mám ukázky i v jiných jazycích, v 90% je to csharp). Tento kus kódu projde všechny elementy pre a přidá jim potřebnou třídu csharp.

<script type="text/javascript">
    $(document).ready(function () {
        $("pre").addClass("brush: csharp");
        SyntaxHighlighter.all();
    });
</script>

Pokud by to někomu nestačilo, dá se i v markdownu použít html a vše si nastavit ručně. Mně to přišlo zbytečné.

Závěr

Celé řešení je skutečně primitivní. Jak jsem napsal na začátku, jsem nadšený hlavně z toho, že můžu článek psát v klidu bez nutnosti používat nějaký CMS. Článku nastavím ručně datum publikace, nahraju do Azure Storage a mám hotovo. Toto řešení není určitě pro každého, ale výsledek čistého HTML a snadná správa článků stojí za to.