Essay - Published: 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.
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:
Falco.Markup as an HTML DSL for building the surrounding site.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.
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:
GetAllPostNamesGetPost (postId: string): Post optionGetOrderedPosts (pageNumber: int) (pageSize: int): PostListGetPostsForTag (postTag: string) (pageNumber: int) (pageSize: int): PostListFor types I have:
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:
let markdownPipeline =
(new MarkdownPipelineBuilder())
.UseAdvancedExtensions()
.UseYamlFrontMatter()
.Build()
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 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
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
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:
Falco.Markup HTML DSL)getPageNumberFromContext - Gets the page number from the query params if existslet 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;
]
]
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
}
renderPostPageAsyncrenderPostPageAsync gets data from repo, renders base layout, and calls renderPostTypographyComponentrenderPostTypographyComponent creates post typography and calls render_tag_links for uniform tag link renderinglet 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
}
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
}
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:
The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.