Build a Simple Markdown Blog with F# and Falco
Date: 2025-02-12 | build | create | falco | fsharp | markdown |
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
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:
- Markdig for Markdown
- YamlDotNet for YAML frontmatter
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 callsrenderPostTypographyComponent
renderPostTypographyComponent
creates post typography and callsrender_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.