Having just made a static site generator the first post on it might as well be about how I made it since I don't have any plans for what I'm going to write about.
A static site generator simply creates the necessary HTML to show some web pages, there's no database or REST API, just HTML (maybe some simple javascript). The world does not need another static site generator, there are probably thousands and I would guess nearly all of them are better than this. But I'm stuck at my parents house over Christmas and writing one in nim sounded like a quick, easy project to amuse myself with.
I have used jekyll in the past which is my only experience
with these sort of things, so YASSS copies the basics from what I remember:
write some blog-style posts in markdown with a yaml header, run a command and
out pops your html files which can be rendered with something like github pages.
So for this I'm using soasme's
nim-markdown library which
converts markdown to html and then sticking this directly in the body of a html
template using nim-mustache
(again from soasme). I then link to these posts from a main index.html
template.
First we need to find all the markdown files in a given directory, in this case ./posts/
.
This should be fairly easy in most languages, in nim it's a case of:
import std/[os, sequtils, strformat]
proc getAllPosts(fpath: string = "./posts"): seq[string] =
return walkFiles(fmt"{fpath}/*.md").toSeq().map(readFile)
This uses nim's walkFiles()
with a wildcard to get only the markdown (.md) filenames
and then uses readFile
to read the file into memory, returning a sequence where each
element is a post as a raw string.
The frontmatter is used as a way to store some basic metadata about the post such as
title, date, and whether it should be public. This is copied from jekyll where
they use a yaml header at the top of the markdown post between ---
lines like so:
---
title: my first post
date: 2022-12-25
public: false
---
# heading
## subheading
Content goes here.
In jekyll there's a whole load customisations you can put in the frontmatter, but for this I'll stick with the title (as appears in the index), the date which is used to sort the posts in the index, and public as a boolean flag whether the posts should be made public or not. I've also added a description value which is parsed, but this isn't used anywhere yet.
Now we could just fetch those lines between the ---
lines and parse them with
a yaml parser (or probably any other key-value parser), but I think there's already
enough dependencies in this project so I wrote my own bug-ridden key-value parser.
proc parseFrontMatter(text: string): FrontMatter =
## parse frontmatter from raw markdown text
var
isFrontMatter = false
frontMatterLines: seq[string]
title, formattedTitle, description, date: string
public: bool = true
for line in text.split("\n"):
if line.startswith("---"):
isFrontMatter = not isFrontMatter
continue
if isFrontMatter:
frontMatterLines.add(line)
# parse out key-value pairs from frontMatterLines
for line in frontMatterLines:
if line.startswith("title"):
title = line.split(":")[^1].strip()
formattedTitle = formatTitle(title)
if line.startswith("description"):
description = line.split(":")[^1].strip()
if line.startswith("date"):
date = line.split(":")[^1].strip()
if line.startswith("public"):
public = line.split(":")[^1].strip().toLower().startswith("t")
return FrontMatter(
title: title,
formattedtitle: formattedTitle,
description: description,
date: date,
public: public
)
This has 2 steps, first get the lines between the two ---
delimiter lines:
var
isFrontMatter = false
frontMatterLines: seq[string]
for line in text.split("\n"):
if line.startswith("---"):
isFrontMatter = not isFrontMatter
continue
if isFrontMatter:
frontMatterLines.add(line)
then the second step is to get the key-value pairs from those lines and
return it has a FrontMatter
object:
for line in frontMatterLines:
if line.startswith("title"):
title = line.split(":")[^1].strip()
formattedTitle = formatTitle(title)
if line.startswith("description"):
description = line.split(":")[^1].strip()
if line.startswith("date"):
date = line.split(":")[^1].strip()
if line.startswith("public"):
public = line.split(":")[^1].strip().toLower().startswith("t")
return FrontMatter(
title: title,
formattedtitle: formattedTitle,
description: description,
date: date,
public: public
)
This is pretty ugly, it just checks the start of the line for the key value, and splits on :
to find the value, and I can already see how it would break if someone has a :
character in their title. So maybe it will get tidied up at some point.
Parsing the markdown body is even easier, just loop through lines in the files
which aren't in the frontmatter, join them together into a single string and
use nim-markdown
to convert the markdown contents to html.
import markdown
proc parseBody(text: string): string =
## get body from raw markdown text that includes frontmatter
var
body: seq[string]
isFrontMatter = false
for line in text.split("\n"):
if line.startswith("---"):
isFrontMatter = not isFrontMatter
continue
if not isFrontMatter:
body.add(line)
return markdown(body.join("\n"))
The aim now is to stick the html (converted from markdown) into the body
of a simple html template. For this I'm using nim-mustache
which implements
mustache
as a templating language.
<html lang="en">
<head>
<title>{{ title }}</title>
<meta charset="UTF-8">
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<p><a href="index.html">Back to posts</a></p>
<hr>
{{{ body }}}
</body>
</html>
Here we insert a title into {{ title }}
, and the body of the post in {{{ body }}}
.
The body is within three curlies as we want to insert the raw html, otherwise
mustache escapes it for us which we don't want.
I've removed it from the above example, but there's also some highlight.js scripts I'm pulling from a CDN so I can have syntax highlighting and mathjax to render $\LaTeX$.
The index page is just a list which links to each post in date order, so we're just looping through the posts in a template.
The nim code:
proc createIndex(posts: seq[Post]) =
## create context for index template
var frontMatters: seq[FrontMatter]
for post in posts:
frontMatters.add(post.frontMatter)
frontMatters = frontMatters.filter(x => x.public == true)
frontMatters.sort(cmpDate, Descending)
let context = newContext(searchDirs=TemplateDir)
context["posts"] = %frontMatters
var
index_html = "{{ >index }}".render(context)
save_path = fmt"{PublicDir}/index.html"
writeFile(save_path, index_html)
Which is made from the template:
<html lang="en">
<head>
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<h1>Posts</h1>
<hr>
<ul>
{{ #posts }}
<li><a href="{{ formattedTitle }}.html">{{ date }}: {{ title }}</a></li>
{{ /posts }}
</ul>
</body>
</html>
I've yet to figure this out. I guess if you can read this then I did figure it out but forgot to update this post.
My plan is to push posts in markdown to a github repository, and then have github actions use YASSS to generate the HTML which is then accessible with github pages. This should be simple in theory, there's plenty of tutorials out there about using custom github actions with github pages.