Build a Simple Markdown Blog with F# and Falco

Date: 2025-02-12 | build | create | falco | fsharp | markdown |

DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)

I've run my markdown blog with F# + Giraffe for the past few years and been very happy with it. However I've recently started building my webapps with Falco instead and thought it would be nice to migrate my blog to Falco as well.

So in this post we'll dive into building a markdown blog with F# + Falco.

HAMINIONS Members get access to the full source code (github) along with dozens of other example projects.

Blog Overview

The blog has a few routes:

  • / - Index route
  • /blog - List all posts
  • /blog/SLUG - Access blog post with SLUG file name
  • /tags/TAG - List posts with the associated tag

F# + Falco Markdown Blog

Here's how the blog works from a high level:

  • Blog posts as Markdown files - Markdown is a well-supported format that is easy to write. This allows me to write, store, and deploy my posts alongside my codebase which I find efficient. I use YAML frontmatter for metadata.
  • Markdown to HTML - I preprocess my blog post metadata for fast lookups and do the markdown post to HTML conversion on hit using Markdig for Markdown to HTML and Yamldotnet for Frontmatter parsing.
  • Serve with Falco - Falco is the webapp so use that to map endpoints to functionality. I am also using Falco.Markup as an HTML DSL for building the surrounding site.

Markdown to HTML

So the core of the blog is really converting the markdown files to something we can use - here an internal representation that can be turned into HTML.

  • Metadata - Things like title, date, and tags that we need for list and sort operations. This is stored in YAML frontmatter.
  • Blog Post - The rest of the post in Markdown.

To parse these, I'm using two libraries:

On app startup, I create a PostService which preprocesses all of the posts in my file system into a lookup table and makes it available via a few repo functions:

  • GetAllPostNames
  • GetPost (postId: string): Post option
  • GetOrderedPosts (pageNumber: int) (pageSize: int): PostList
  • GetPostsForTag (postTag: string) (pageNumber: int) (pageSize: int): PostList

For types I have:

  • Post - The post metadata and body
  • PostList - A list of posts, including pagination properties
type Post = 
    {
        Id : string
        Title : string 
        Date : DateOnly 
        Tags : string list
        Body : string
    }

type PostList = 
    {
        Posts: Post list 
        TotalCount: int 
        PageNumber: int 
        PageSize: int
    }

For reading a post from file to frontmatter, markdown to html:

  • Create a Markdig markdown pipeline telling it how to parse the incoming file
let markdownPipeline = 
    (new MarkdownPipelineBuilder())
        .UseAdvancedExtensions()
        .UseYamlFrontMatter()
        .Build()
  • Create a Yaml Frontmatter parsing pipeline using YamlDotnet
  • Create a function separating out the FrontMatter from the rest of the Markdown post
let yamlDeserializer = 
    (new DeserializerBuilder())
        .WithNamingConvention(LowerCaseNamingConvention.Instance)
        .IgnoreUnmatchedProperties()
        .Build()

let getPostFrontMatterFromMarkdownDocument 
    (markdownDocument: MarkdownDocument)
    : {|
        FrontMatter: PostFrontMatter
        RemainingMarkdown: MarkdownDocument 
        |}
    = 
    let yamlBlock = 
        markdownDocument
            .Descendants<YamlFrontMatterBlock>()
            .FirstOrDefault()
    
    let yaml =
        yamlBlock
            .Lines
            .ToString()

    let frontMatter = yamlDeserializer.Deserialize<PostFrontMatter>(yaml)
    
    markdownDocument.Remove(yamlBlock)
    |> ignore

    {|
        FrontMatter = frontMatter
        RemainingMarkdown = markdownDocument
    |}
  • Create a method to make it easy to do the post parsing based on the post id (its slug)
let getPost 
    (postId: string)
    : Post option 
    =
    let postString = getPostRawString postId 

    if Option.isNone postString
    then None 
    else 

    try
        let markdownDocument = 
            Markdown.Parse(postString.Value, markdownPipeline)

        let parsedDocument = 
            getPostFrontMatterFromMarkdownDocument 
                markdownDocument

        let metadata = parsedDocument.FrontMatter

        Some 
            {
                Id = postId
                Title = metadata.title
                Date = DateOnly.FromDateTime(metadata.date.Date) 
                Tags = 
                    metadata.tags
                    |> Array.toList
                Body = parsedDocument.RemainingMarkdown.ToHtml()
            }
    with e -> 
        printfn "Failed to parse postId: %A" postId
        printfn "Error: %A" (e.ToString())

        None
  • Then I put all these together to cache common post lookup patterns and make it available to callers

Here's the full code for reference:

module PostPersistence =

    type PostFrontMatter() = 
        member val title: string = "NO_NAME_FOUND" with get, set
        member val date: DateTimeOffset = DateTimeOffset.Parse("2020-01-01") with get, set
        member val tags: string array = [||] with get, set

    type PostService
        (filePath: string) =

        let allPostsPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), filePath)
        
        let allPostPaths = Directory.GetFiles(allPostsPath)

        let allPostIdsToPathLookup = 
            allPostPaths 
            |> Array.map (
                fun path -> Path.GetFileNameWithoutExtension(path), path 
            )
            |> Map.ofArray

        let getPostPath 
            (postId: string)
            : string option 
            =
            match (
                    allPostIdsToPathLookup
                    |> Map.containsKey postId
                ) with
                | true -> Some allPostIdsToPathLookup[postId]
                | false -> None

        let getPostRawString
            (postId: string)
            : string option
            =
            option {
                let! postFilePath = getPostPath postId

                let rawText = File.ReadAllText(postFilePath)
                return rawText
            }

        let yamlDeserializer = 
            (new DeserializerBuilder())
                .WithNamingConvention(LowerCaseNamingConvention.Instance)
                .IgnoreUnmatchedProperties()
                .Build()

        let getPostFrontMatterFromMarkdownDocument 
            (markdownDocument: MarkdownDocument)
            : {|
                FrontMatter: PostFrontMatter
                RemainingMarkdown: MarkdownDocument 
                |}
            = 
            let yamlBlock = 
                markdownDocument
                    .Descendants<YamlFrontMatterBlock>()
                    .FirstOrDefault()
            
            let yaml =
                yamlBlock
                    .Lines
                    .ToString()

            let frontMatter = yamlDeserializer.Deserialize<PostFrontMatter>(yaml)
            
            markdownDocument.Remove(yamlBlock)
            |> ignore

            {|
                FrontMatter = frontMatter
                RemainingMarkdown = markdownDocument
            |}

        let markdownPipeline = 
            (new MarkdownPipelineBuilder())
                .UseAdvancedExtensions()
                .UseYamlFrontMatter()
                .Build()

        let getPost 
            (postId: string)
            : Post option 
            =
            let postString = getPostRawString postId 

            if Option.isNone postString
            then None 
            else 

            try
                let markdownDocument = 
                    Markdown.Parse(postString.Value, markdownPipeline)

                let parsedDocument = 
                    getPostFrontMatterFromMarkdownDocument 
                        markdownDocument

                let metadata = parsedDocument.FrontMatter

                Some 
                    {
                        Id = postId
                        Title = metadata.title
                        Date = DateOnly.FromDateTime(metadata.date.Date) 
                        Tags = 
                            metadata.tags
                            |> Array.toList
                        Body = parsedDocument.RemainingMarkdown.ToHtml()
                    }
            with e -> 
                printfn "Failed to parse postId: %A" postId
                printfn "Error: %A" (e.ToString())

                None

        let getAllPosts : unit -> Post list = 
            fun () ->
                allPostIdsToPathLookup
                |> Map.keys 
                |> Seq.map (
                    fun postId -> getPost postId
                )
                |> Seq.choose id
                |> Seq.toList

        let dateOrderedPostsIds : string list =
            getAllPosts()
            |> Seq.sortByDescending (fun p -> p.Date)
            |> Seq.map (fun p -> p.Id)
            |> Seq.toList
        
        let tagToPostsIdsLookup : System.Collections.Generic.IDictionary<string, string list> = 
            getAllPosts()
            |> List.map (
                fun post -> 
                    post.Tags
                    |> List.map (
                        fun tag -> 
                            tag, post
                    )
            )
            |> List.collect id
            |> List.groupBy (fun tagToPostId -> fst tagToPostId)
            // Flatten to (tag, post list), ordered by date descending
            |> List.map (
                fun (tag, tagToPost) ->
                    (tag, (
                        tagToPost
                        |> List.map (fun tuple -> snd tuple)
                        |> List.sortByDescending (fun p -> p.Date)
                        |> List.map (fun p -> p.Id)
                    ))
            )
            |> dict

        let getPostsForPostIds 
            (postIds: string list)
            (pageNumber: int)
            (pageSize: int)
            : PostList 
            = 
            let skip = pageNumber * pageSize

            if skip >= postIds.Length
            then 
                // hamy - this isn't really correct but small edge case so idc
                {
                    Posts = []
                    TotalCount = 0
                    PageNumber = pageNumber
                    PageSize = pageSize
                } 
            else

            let matching_posts = 
                postIds
                |> List.filter (
                    fun postId -> 
                        allPostIdsToPathLookup
                        |> Map.containsKey postId
                )

            let paginated_posts =
                matching_posts
                |> List.skip skip
                |> List.truncate pageSize
                |> List.map (fun id -> getPost id)
                |> List.map (fun p -> p.Value)

            {
                Posts = paginated_posts
                TotalCount = matching_posts.Length 
                PageNumber = pageNumber 
                PageSize = pageSize
            }

        let getOrderedPosts 
            (pageNumber: int)
            (pageSize: int)
            : PostList 
            =
            getPostsForPostIds
                dateOrderedPostsIds
                pageNumber
                pageSize

        let getPostsForTag 
            (postTag: string)
            (pageNumber: int)
            (pageSize: int)
            : PostList 
            = 
            let tagExists = 
                tagToPostsIdsLookup.ContainsKey(postTag)

            if not tagExists 
            then 
                {
                    Posts = []
                    TotalCount = 0
                    PageNumber = pageNumber
                    PageSize = pageSize
                } 
            else 

            getPostsForPostIds
                tagToPostsIdsLookup[postTag]
                pageNumber
                pageSize

        member __.GetAllPostNames() = 
            allPostIdsToPathLookup
            |> Map.keys

        member __.GetPost 
            (postId: string) 
            : Post option 
            =
            getPost postId

        member __.GetOrderedPosts 
            (pageNumber: int)
            (pageSize: int)
            : PostList 
            = 
            getOrderedPosts pageNumber pageSize

        member __.GetPostsForTag
            (postTag: string)
            (pageNumber: int)
            (pageSize: int)
            : PostList
            =
            getPostsForTag
                postTag 
                pageNumber 
                pageSize

Serving with Falco

So now we have a way to preprocess all of the markdown files into something easy to access, now we need to connect our endpoints to produce HTML from them.

As a reminder the blog has a few primary routes:

  • / - Index route
  • /blog - List all posts
  • /blog/SLUG - Access blog post with SLUG file name
  • /tags/TAG - List posts with the associated tag

(the example project also has an /about page to be more ~real but we won't touch on that.)

My endpoints are configured like this:

let constructEndpoints 
    (blogServiceTree : PostServiceTree) 
    = 
    [
        get "" (
            render_view (
                fun (ctx : HttpContext) -> 
                    async { return IndexPage.renderIndexPage }
            )
        )
        get "/about" (
            render_view (
                fun (ctx : HttpContext) -> 
                    async { return IndexPage.renderAboutPage }
            )
        )
        get "/blog" (
            fun (ctx : HttpContext) -> task {
                let pageNumber = getPageNumberFromContext ctx

                return
                    render_view (
                        PostListView.postListPageHandler 
                            blogServiceTree
                            {
                                Title = "Blog Posts"
                                PageNumber = pageNumber 
                                PageSize = PAGE_SIZE
                            }
                    ) ctx
            }
        )
        get "/blog/{slug}" (
            fun (ctx: HttpContext) -> task {
                let route = Request.getRoute ctx 
                let slug = route.GetString "slug"

                return
                    render_view (
                        PostPageView.postPageHandler 
                            blogServiceTree
                            { 
                                PostId = slug
                                HasAds = true
                            }
                    ) ctx
            }
        )
        get "/blog/tags/{tag}" (
            fun (ctx : HttpContext) -> task {
                let pageNumber = getPageNumberFromContext ctx
                let route = Request.getRoute ctx 
                let tag = route.GetString "tag"
                return 
                    render_view (
                        PostListView.tagPostListPageHandler 
                            blogServiceTree
                            {
                                Tag = tag
                                PageNumber = pageNumber
                                PageSize = PAGE_SIZE
                            }
                    ) ctx
            }
        )
    ]

I have two helper functions for common operations:

  • render_view - Converts XmlNode HttpHandler to HTML (XmlNode is the building block of the Falco.Markup HTML DSL)
  • getPageNumberFromContext - Gets the page number from the query params if exists
let render_view
    (handler : HttpContext -> Async<Falco.Markup.XmlNode>)
    : HttpHandler 
    =
    fun (ctx: HttpContext) -> task {
        let! result = handler ctx
        return Response.ofHtml result ctx
    }

let getPageNumberFromContext
    (ctx : HttpContext)
    : int 
    =
    let query = Request.getQuery ctx 

    query.TryGetInt "page"
    |> Option.defaultValue 0

I also have a helper to render the base layout of my site so each individual renderer can just focus on what's in it's page.

let render_post_layout 
    (headSlot :  Falco.Markup.XmlNode list)
    (bodySlot :  Falco.Markup.XmlNode list)
    (footerSlot :  Falco.Markup.XmlNode list)
    : Falco.Markup.XmlNode =
    Elem.html [ Attr.create "data-theme" "hamyblack" ] [
        Elem.head [] [
            Elem.meta [
                Attr.charset "UTF-8"
                Attr.name "viewport"
                Attr.content "width=device-width, initial-scale=1"
            ];
            Elem.link [
                Attr.rel "stylesheet"
                Attr.href "/css/tailwind.css"
            ];
            Elem.link [
                Attr.rel "stylesheet"
                Attr.href "/css/app.css"
            ];
            Elem.script [
                Attr.src "https://cdn.jsdelivr.net/npm/anchor-js/anchor.min.js"
            ] []
            yield! headSlot;
        ]
        Elem.body [] [
            (
                render_navigation 
                    {
                        Name = "Blog"
                        Href = "/"
                    }
                    allNavigationLinks
            );
            Elem.main [ Attr.class' "p-6"; ] [
                yield! bodySlot;
            ]
        ]
        Elem.footer [] [
            yield! footerSlot;
        ]
    ]

Listing Posts

  • HttpHandler calls the renderer
  • Renderer gets ordered posts from data repo and creates HTML with DSL
type PostListViewProps = 
    {
        Title : string
        PageNumber : int 
        PageSize : int
    }

let renderPostListPageAsync
    (serviceTree : PostServiceTree)
    (props : PostListViewProps)
    : Async<Falco.Markup.XmlNode> 
    = async {
        let post_list = 
            serviceTree
                .PostService()
                .GetOrderedPosts
                    props.PageNumber
                    props.PageSize
        
        return 
            render_post_layout
                [
                    Elem.title [] [
                        Text.enc props.Title
                    ]
                ]
                [
                    renderPostListComponent 
                        { 
                            UrlPrefix = serviceTree.UrlPrefix
                            PaginationUrlPrefix = serviceTree.UrlPrefix
                            Title = props.Title
                            Posts = post_list.Posts 
                            TotalMatchingPosts = post_list.TotalCount
                            PageNumber = props.PageNumber
                            HasNext = post_list.Posts.Length >= props.PageSize
                        }
                ]
                []
    }

let postListPageHandler 
    (serviceTree : PostServiceTree) 
    (props : PostListViewProps)
    = 
    fun (ctx : HttpContext) -> async {
        return! 
            renderPostListPageAsync
                serviceTree
                props
    }

Rendering a Single Post

  • HttpHandler calls renderPostPageAsync
  • renderPostPageAsync gets data from repo, renders base layout, and calls renderPostTypographyComponent
  • renderPostTypographyComponent creates post typography and calls render_tag_links for uniform tag link rendering
let render_tag_links 
    (urlPrefix : string)
    (post : Post)
    : Falco.Markup.XmlNode =
    Elem.span [] (
        post.Tags
        |> List.sort
        |> List.collect (
            fun tag ->
                [
                    Elem.a [
                        Attr.href $"{urlPrefix}/tags/{tag}"
                        Attr.class' "underline text-neutral-400"
                    ] [
                        Text.enc tag
                    ];
                    Elem.span [] [ Text.enc " | "];
                ]
        )
    )

type PostTypographyComponentProps =
    {
        UrlPrefix : string
        Post : Post option
        HasAds : bool
    }

let renderPostTypographyComponent
    (props : PostTypographyComponentProps)
    : Falco.Markup.XmlNode =
    Elem.article [ Attr.class' "p-4 max-w-prose mx-auto" ] [
        // Post
        yield! (
            match props.Post with 
            | None -> [Text.enc "No Post found!"]
            | Some post -> 
                [
                    Elem.h1 [ Attr.class' "text-4xl font-bold" ] [
                        Text.enc post.Title
                    ];
                    Elem.p [ Attr.class' "pt-3" ] [
                        Elem.span [] [
                            Text.enc $"Date: {(formatDate post.Date)} | ";
                        ];
                        (
                            render_tag_links 
                                props.UrlPrefix
                                post);
                    ];
                    Elem.div [ Attr.class' "border-t-2 pt-2 mt-2" ] []
                    Elem.div [ Attr.class' "post-area prose prose-lg pt-4 pb-8" ] [
                        Text.raw 
                            post.Body
                    ]
                    // Add Anchor Links to FE
                    Elem.script [] [ Text.raw(
                        """
                        anchors.options = {
                            visible: "always"
                        }
                        anchors.add(".post-area h1, .post-area h2, .post-area h3")
                        """
                    )]
                ]
        );
    ]

type PostPageProps = 
    {
        PostId : string
        HasAds : bool
    }

let renderPostPageAsync
    (serviceTree : PostServiceTree)
    (props : PostPageProps)
    : Async<Falco.Markup.XmlNode> = async {
        let post = 
            serviceTree
                .PostService()
                .GetPost 
                    props.PostId
        
        return 
            PostPresentation.render_post_layout
                [
                    Elem.title [] [
                        (
                            match post with 
                            | Some p -> Text.enc p.Title 
                            | None -> Text.enc "No Post Found" 
                        );
                    ]
                ]
                [
                    renderPostTypographyComponent 
                        { 
                            UrlPrefix = serviceTree.UrlPrefix
                            Post = post 
                            HasAds = props.HasAds
                        }
                ]
                []
    }

let postPageHandler 
    (serviceTree : PostServiceTree) 
    (props : PostPageProps) 
    = 
    fun (ctx : HttpContext) -> async {
        return! 
            renderPostPageAsync
                serviceTree
                props
    }

Rendering Tag Lists

Very similar to the blog list except we get posts by tag from the data repo.

let renderTagPostListPageAsync
    (serviceTree : PostServiceTree)
    (props : TagPostListPageProps)
    : Async<Falco.Markup.XmlNode> 
    = async {
        let post_list = 
            serviceTree
                .PostService()
                .GetPostsForTag
                    props.Tag
                    props.PageNumber
                    props.PageSize
        
        let tagString = $"Tag: {props.Tag}"
        
        return  
            PostPresentation.render_post_layout
                [
                    Elem.title [] [
                        Text.enc tagString
                    ]
                ]
                [
                    renderPostListComponent 
                        { 
                            UrlPrefix = $"{serviceTree.UrlPrefix}"
                            PaginationUrlPrefix = $"{serviceTree.UrlPrefix}/tags/{props.Tag}"
                            Title = tagString
                            Posts = post_list.Posts 
                            TotalMatchingPosts = post_list.TotalCount
                            PageNumber = props.PageNumber
                            HasNext = post_list.Posts.Length >= props.PageSize
                        }
                ]
                []
    }

let tagPostListPageHandler 
    (serviceTree : PostServiceTree) 
    (props : TagPostListPageProps)
    = 
    fun (ctx : HttpContext) -> async {
        return! 
            renderTagPostListPageAsync
                serviceTree
                props
    }

Next

So that's how I run my blog with Markdown posts served with F# + Falco!

It's def not the most scalable / robust thing but it's served me well. I'm currently serving ~900 posts to ~300 visits a day with request latencies in ~10ms, CPU ~1% (0.01 of a vCPU), and memory around 30% (~150 mb) on Google Cloud Run's smallest box (1 vCPU, 512 MiB RAM) so can probably scale to quite a lot more.

Reminder: HAMINIONS Members get access to the full source code (github) along with dozens of other example projects.

If you liked this post you might also like:

Want more like this?

The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.