This was written for fornax 0.11.0 - the binary file problem has a neater solution now detailed here: better binary file handling
I like F#, I want a static site generator - so as I write this there is an obvious answer to address my needs: Fornax - easy right? Well... maybe and then again maybe not.
Why not?
I think that its reasonable to be opinionated, I think its reasonable to provide a good example. I'm rather less convinced by the nature of "starter" apps that I've been seeing for a while (and this goes back to file | new project... for ASP.NET MVC applications)
Fornax is interesting - the latest incarnation makes some choices that mean you're going to need some guidance in making it work (you're going to want to start with the generated site) but equally there's too much in there if you just want a bare-bones setup to build the way you choose. Specifically the template uses bulma, pulls in a hero image, etc, etc where I don't want to use bulma (at least not in the first instance), I haven't even thought about complex aesthetics, but I do want to push content.
So I'm going to try this a different way - I've got the templated site, I can rebuild from the ground up by pulling the pieces I need from that site as I need them - hopefully learning as I go.
Where to start
First install the tooling.
Assuming the .NET Core 3.1 SDK is available:
dotnet new tool-manifest
dotnet tool install fornax
That gives us a .config
folder, and a dotnet-tools.json
therein and an ability to run fornax e.g.
dotnet fornax version
Create a new site and throw it almost all away...
Run:
dotnet fornax new
Now delete everything except the _lib
folder.
Finally re-create config.fsx
to look like the following:
#r "_lib/Fornax.Core.dll"
open Config
open System.IO
let config = {
Generators = [
{Script = "index.fsx"; Trigger = Once; OutputFile = NewFileName "index.html" }
]
}
Which throws away almost everything... (we're going to want it back later, but that will do for now)
Lets run that...
dotnet fornax watch
And it will fail gloriously because it can find a loader. So lets add one of those.
create a folder loaders
and in that create a file pageloader.fsx
containing the following
#r "../_lib/Fornax.Core.dll"
type Page = {
title: string
link: string
}
let loader (projectRoot: string) (siteContent: SiteContents) =
siteContent.Add({title = "Home"; link = "/"})
siteContent
And generation will fail because there's no index generator, so we create a new folder generators
and in that we need a file index.fsx
#r "../_lib/Fornax.Core.dll"
open Html
let render (ctx : SiteContents) content =
content
|> HtmlElement.ToString
let generate' (ctx : SiteContents) (_: string) =
html [] [
head [] [
meta [CharSet "utf-8"]
title [] [!! "Almost heading for a blog"]
]
body [] [
header [] [!! "My Blog has a title!!!"]
section [] []
]
]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> render ctx
And, if we run dotnet fornax watch
, magic happens, files get built, a webserver starts and end up with a single web page at http://127.0.0.1:8080
At this point dotnet fornax clean
may be useful!
Its a blog... where are the posts?
Lets add a couple of posts, create a folder posts
and add something like the following two files:
post-01.md
:
---
layout: post
title: First Post
published: 2020-01-01
author: @recumbent
---
This is the first post
post-02.md
:
---
layout: post
title: Second Post
published: 2020-02-01
author: @recumbent
---
This is the second post
It has two paragraphs
So the next trick is to find those post files and list their titles in the index page.
Loading posts
We need a post loader, at this point we want just enough to be able to create a list of posts - so we need to add a file postloader.fsx
in the loaders
folder with content as follows:
#r "../_lib/Fornax.Core.dll"
type PostConfig = {
disableLiveRefresh: bool
}
type Post = {
title: string
}
let trimString (str : string) =
str.Trim().TrimEnd('"').TrimStart('"')
let loadFile n =
let text = System.IO.File.ReadAllText n
let lines = text.Split( '\n') |> List.ofArray
let title = lines |> List.find (fun n -> n.ToLower().StartsWith "title" ) |> fun n -> n.Split(':').[1] |> trimString
{ title = title }
let loader (projectRoot: string) (siteContent: SiteContents) =
let postsPath = System.IO.Path.Combine(projectRoot, "posts")
System.IO.Directory.GetFiles postsPath
|> Array.filter (fun n -> n.EndsWith ".md")
|> Array.map loadFile
|> Array.iter (fun p -> siteContent.Add p)
siteContent.Add({disableLiveRefresh = false})
siteContent
There's a bit going on here:
- First we define a type
posts
to contain the title - we'll expand this later - Then we have a utility function
trimString
- Then we have a very basic
loadFile
function that reads all the text, splits it by line, and goes to find the title (defined in the front matter of the post) - Finally we have the loader itself, which goes looking for
.md
files in the "posts" folder, loads them, and then adds them to the site content (side effects, that's not very functional!)
This is sufficient to see the mechanics.
Now we need to update the generator to create a list from the pages, so change the generate'
function in generators\index.fsx
to:
let generate' (ctx : SiteContents) (_: string) =
let posts = ctx.TryGetValues<Postloader.Post> () |> Option.defaultValue Seq.empty
let postList =
posts
|> Seq.toList
|> List.map (fun post ->
li [] [!! post.title]
)
html [] [
head [] [
meta [CharSet "utf-8"]
title [] [!! "Almost heading for a blog"]
]
body [] [
h1 [] [!! "My Blog has a title!!!"]
section [] [
ul [] postList
]
]
]
Now when the site is built we'll have an index page similar to the following (but probably less pretty as it will be using your browser's default styles):
My Blog has a title!!!
- First Post
- Second Post
Adding a page per post
The next step is to generate a page for every post and to link from the list in the index page to those pages.
We want to have a consistent layout, so lets add layout.fsx
in the generators
folder.
#r "../_lib/Fornax.Core.dll"
open Html
let injectWebsocketCode (webpage:string) =
let websocketScript =
"""
<script type="text/javascript">
var wsUri = "ws://localhost:8080/websocket";
function init()
{
websocket = new WebSocket(wsUri);
websocket.onclose = function(evt) { onClose(evt) };
}
function onClose(evt)
{
console.log('closing');
websocket.close();
document.location.reload();
}
window.addEventListener("load", init, false);
</script>
"""
let head = "<head>"
let index = webpage.IndexOf head
webpage.Insert ( (index + head.Length + 1),websocketScript)
let layout (ctx : SiteContents) active bodyContent =
html [] [
head [] [
meta [CharSet "utf-8"]
title [] [!! "Almost heading for a blog"]
]
body [] [
header [] [
h1 [] [!! "Fornax Generated Blog!"]
a [Href "/"][!! "Home"]
]
yield! bodyContent
]
]
let render (ctx : SiteContents) content =
let disableLiveRefresh = ctx.TryGetValue<Postloader.PostConfig> () |> Option.map (fun n -> n.disableLiveRefresh) |> Option.defaultValue false
content
|> HtmlElement.ToString
|> fun n -> if disableLiveRefresh then n else injectWebsocketCode n
In the above we've taken the bits that we want to be common from the index.fsx page and moved them into their own file, we'll update index in a moment, but first lets load some additional information about posts - for this we need to update the postloader.fsx
First add some more fields to the post type:
type Post = {
file: string
link : string
layout: string
title: string
author: string option
published: System.DateTime option
tags: string list
content: string
}
Next add the folllowing after the post type:
let isSeparator (input : string) =
input.StartsWith "---"
///`fileContent` - content of page to parse. Usually whole content of `.md` file
///returns front matter configuration
let getConfig (fileContent : string) =
let fileContent = fileContent.Split '\n'
let fileContent = fileContent |> Array.skip 1 //First line must be ---
let indexOfSeperator = fileContent |> Array.findIndex isSeparator
fileContent
|> Array.splitAt indexOfSeperator
|> fst
|> String.concat "\n"
///`fileContent` - content of page to parse. Usually whole content of `.md` file
///returns body of content for the page
let getContent (fileContent : string) =
let fileContent = fileContent.Split '\n'
let fileContent = fileContent |> Array.skip 1 //First line must be ---
let indexOfSeperator = fileContent |> Array.findIndex isSeparator
let _, content = fileContent |> Array.splitAt indexOfSeperator
content |> Array.skip 1 |> String.concat "\n"
n.b. I'm fighting the urge to refactor the above, but it will work well as is, so that's for another day.
Finally we change the loadFile
function to the following:
let loadFile n =
let text = System.IO.File.ReadAllText n
let config = (getConfig text).Split( '\n') |> List.ofArray
let content = getContent text
let file = System.IO.Path.Combine("posts", (n |> System.IO.Path.GetFileNameWithoutExtension) + ".md").Replace("\\", "/")
let link = "/" + System.IO.Path.Combine("posts", (n |> System.IO.Path.GetFileNameWithoutExtension) + ".html").Replace("\\", "/")
let title = config |> List.find (fun n -> n.ToLower().StartsWith "title" ) |> fun n -> n.Split(':').[1] |> trimString
let layout = config |> List.tryFind (fun n -> n.ToLower().StartsWith "layout") |> Option.defaultValue "unknown"
let published =
try
config |> List.tryFind (fun n -> n.ToLower().StartsWith "published" ) |> Option.map (fun n -> n.Split(':').[1] |> trimString |> System.DateTime.Parse)
with
| _ -> None
{ file = file
link = link
layout = layout
title = title
author = None
published = published
tags = []
content = content }
The changes above start to add configuration read from the header block in the post files.
Now change index.fsx to look like this:
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let generate' (ctx : SiteContents) (page: string) =
let posts = ctx.TryGetValues<Postloader.Post> () |> Option.defaultValue Seq.empty
let published (post: Postloader.Post) =
post.published
|> Option.defaultValue System.DateTime.Now
|> fun n -> n.ToString("yyyy-MM-dd")
let postList =
posts
|> Seq.sortByDescending published
|> Seq.toList
|> List.map (fun post ->
li [] [
a [Href post.link] [!! (sprintf "%s - %s" (published post) post.title)]
]
)
Layout.layout ctx "Home" [
section [] [
h2 [] [!! "Posts:"]
ul [] postList
]
]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> Layout.render ctx
This pushed the common layout out, adds links to the files for the posts, and generally drifts towards something a bit more real (strictly we want to render at least some of the posts on this page, but for now this lets us show that we're generating all the things)
We next need something to render a blog post, so we'll a new file to the generators
folder post.fsx
with the following content:
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let generate' (ctx : SiteContents) (page: string) =
let post =
ctx.TryGetValues<Postloader.Post> ()
|> Option.defaultValue Seq.empty
|> Seq.find (fun n -> n.file = page)
Layout.layout ctx post.title [
article [] [!! post.content]
]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> Layout.render ctx
This uses the shared layout to drop the content loaded for the post into a page.
Finally add posts to the list of generators in config.fsx
so it looks like this:
#r "_lib/Fornax.Core.dll"
open Config
open System.IO
let postPredicate (projectRoot: string, page: string) =
let fileName = Path.Combine(projectRoot, page)
let ext = Path.GetExtension page
if ext = ".md" then
let ctn = File.ReadAllText fileName
ctn.Contains("layout: post")
else
false
let config = {
Generators = [
{Script = "post.fsx"; Trigger = OnFilePredicate postPredicate; OutputFile = ChangeExtension "html" }
{Script = "index.fsx"; Trigger = Once; OutputFile = NewFileName "index.html" }
]
}
The postPredicate
function lets us find files that we want to render as a post, magic happens here to sweep those files up before we get to the index. In getting to this point I've had some entertainment - but mostly because I had other things in my repo...
So where are we? At this point we can:
- Load content from files
- Parse configuration information from those files
- Generate pages using a script - either based on the content of a file or statically but using information loaded from the files.
- Share layout and other behaviour.
What have I forgotten
Having got this far I almost have a clue and we almost have something useful... but there are at least a couple more things to do.
Lets start by adding post-03.md as follows:
---
layout: post
title: Third Post
published: 2020-03-01
author: @recumbent
---
# This is the 3rd Post
In which we write markdown with _formatting_ including things like this list:
1. One
2. Two
3. Three
And a link: [Fornax on ionide site](http://ionide.io/Tools/fornax.html)
When we run dotnet fornax watch
now we'll get a page - but when we go visit the page we won't see nicely formatted html, but rather something like:
# This is the 3rd Post In which we write markdown with _formatting_ including things like this list: 1. One 2. Two 3. Three And a link: [Fornax on ionide site](http://ionide.io/Tools/fornax.html)
So we need to add something to convert our markdown into HTML. We'll do that in the loader.
That turns out to be quite straightforward
In postloader.fsx
first add the following below #r "../_lib/Fornax.Core.dll"
#r "../_lib/Markdig.dll"
open Markdig
Next add the following after the declaration of the Post
type:
let markdownPipeline =
MarkdownPipelineBuilder()
.UsePipeTables()
.UseGridTables()
.Build()
And finally change the getContent
function to:
let getContent (fileContent : string) =
let fileContent = fileContent.Split '\n'
let fileContent = fileContent |> Array.skip 1 //First line must be ---
let indexOfSeperator = fileContent |> Array.findIndex isSeparator
let _, content = fileContent |> Array.splitAt indexOfSeperator
let content = content |> Array.skip 1 |> String.concat "\n"
Markdown.ToHtml(content, markdownPipeline)
The change is in the last two lines where instead of returning the content as read from the file we return the result translated to html by Markdig (if you wanted to use an alternative renderer you'd put it here or hereabouts)
Now if you save the content from post-03 will render html - which is what we want.
It could do with a bit more style
Well any style really, serif fonts are not really what one expects any more.
In the defaut site the styling is done with bulma, but lets just run with a seriously minimal improvement
create a folder css
and add a file styles.css
body {
font-family: sans-serif;
}
While we're playing lets add a favicon - you can make one here: FontIcon 💙 Font Awesome Favicon Generator 🔥
Create an images
folder and save it there
Modify the layout
function in layout.fsx
adding the to links to the header so that it now looks like this:
let layout (ctx : SiteContents) active bodyContent =
html [] [
head [] [
meta [CharSet "utf-8"]
title [] [!! "Almost heading for a blog"]
link [Rel "icon"; Type "image/png"; Sizes "32x32"; Href "/images/favicon.png"]
link [Rel "stylesheet"; Type "text/css"; Href "/css/styles.css"]
]
body [] [
header [] [
h1 [] [!! "Fornax Generated Blog!"]
a [Href "/"][!! "Home"]
]
yield! bodyContent
]
]
We have the files, we're using them, now we need to copy these static files into the published website (i.e. the _public
folder). The generator this is fairly minimal - create a new file in generators
called staticfile.fsx
:
#r "../_lib/Fornax.Core.dll"
open System.IO
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
let inputPath = Path.Combine(projectRoot, page)
File.ReadAllText inputPath
This done we need to add the staticfile generator to config.fsx
but we don't want to copy everything below the root as a static item so we also need need a filter to exclude the various things that are just used to create the site and not as part of it. Making these changes - to add the staticPredicate
function and to add static files to the list of generators - results in config.fsx
looking like the following. If we wanted to ignore more folders or files we can add them to the if
condition.
#r "_lib/Fornax.Core.dll"
open Config
open System.IO
let postPredicate (projectRoot: string, page: string) =
let fileName = Path.Combine(projectRoot, page)
let ext = Path.GetExtension page
if ext = ".md" then
let ctn = File.ReadAllText fileName
ctn.Contains("layout: post")
else
false
let staticPredicate (projectRoot: string, page: string) =
let ext = Path.GetExtension page
if page.Contains ".DS_Store" ||
page.Contains "_public" ||
page.Contains ".config" ||
page.Contains "_lib" ||
page.Contains ".git" ||
page.Contains ".ionide" ||
ext = ".fsx"
then
false
else
true
let config = {
Generators = [
{Script = "post.fsx"; Trigger = OnFilePredicate postPredicate; OutputFile = ChangeExtension "html" }
{Script = "staticfile.fsx"; Trigger = OnFilePredicate staticPredicate; OutputFile = SameFileName }
{Script = "index.fsx"; Trigger = Once; OutputFile = NewFileName "index.html" }
]
}
Now when we generate the site (run dotnet fornax watch
) and navigate to the site... we get a page that looks a bit better.
But the code is ugly...
Just for fun... the site I'm aiming to put up is going to have a lot of code, so code highlighting would be nice.
The solution for this is highlight.js.
At this point I'm not even going to try and embed the source, so there's a gist post-04.md - save this into posts/post-04.md
Now go to hightlight.js download, add F# to the list of styles.
To make life easy, extract it all to a highlight
folder in the root (you might then want to delete everything except default.css
from thehighlight/styles
folder).
Then we need to add a the following to the head section generators/layout.js
link [Rel "stylesheet"; Href "/highlight/styles/default.css"]
script [Src "/highlight/highlight.pack.js"] []
script [] [!! "hljs.initHighlightingOnLoad();"]
One more thing
I thought at this point I'd finished... but if you go look at _public/images/favicon.png
you'll see its not a valid .png
This is because we're attempting to treat a binary file as text and, erm, that doesn't end well.
So how do we fix this? Well if in doubt cheat (in a programming context, sometimes). In this case there are two bits of cheating
Firstly me - I went to look at the ionide site and found the source that copied binary assets.
Secondly actually solving the problem... add a new file to loaders
copyloader.fsx
#r "../_lib/Fornax.Core.dll"
open System.IO
let loader (projectRoot: string) (siteContent: SiteContents) =
let intputPath = Path.Combine(projectRoot, "images")
let outputPath = Path.Combine(projectRoot, "_public", "images")
if Directory.Exists outputPath then Directory.Delete(outputPath, true)
Directory.CreateDirectory outputPath |> ignore
for dirPath in Directory.GetDirectories(intputPath, "*", SearchOption.AllDirectories) do
Directory.CreateDirectory(dirPath.Replace(intputPath, outputPath)) |> ignore
for filePath in Directory.GetFiles(intputPath, "*.*", SearchOption.AllDirectories) do
File.Copy(filePath, filePath.Replace(intputPath, outputPath), true)
siteContent
Then go to config.fsx and add page.Contains "images" ||
to the list of filters in staticPredicate
Now when the site is generated we should end up with a working favicon!
What next
If you've made it this far, and it all works then I suggest you start again with a clean, empty folder, init git, do the setup, run dotnet fornax new
and work from there - removing/changing/replacing the pieces as needed to fit your use case.
If it doesn't all work - I'm on twitter @recumbent and will attempt to help (and then to fix up this blog post...)
The finished source will be, erm, somewhere, at some point, maybe (the first iteration of this is as part of an internal blog the source of which I can't publish!).
Murph