sampu
Originally posted: 2024-02-26
My first post
I've spent many hours reading and learning from other people's blogs. I don't know that I have anything to teach you, but I'll try to share some interesting things here on the off chance that I do.
I think it's only appropriate for my first blog post using the sampu blog engine to be a blog post about creating it. That's right: the website you're viewing right now is powered by a simple custom "noCMS" blogging system called sampu. sampu is written in Haskell and is one of the simplest implementations of a blog platform I could think up.
Motivations
sampu is a word in Lojban with the English definition:
"x1 is simple/unmixed/uncomplicated in property x2"
I've used some popular Content Management Systems in the past but the workflow of editing in web browsers never clicked for me. There are many simpler implementations that provide management via flat files, but they were often missing features that I consider mandatory for a blog such as an automatically updating Atom/RSS feed. I recently discovered the Twain micro web framework, the Lucid HTML DSL, and the Clay CSS DSL. I knew I wanted to work on a small project to experiment with them all, so here's sampu. I'll go into more details about all three of these, but I'll spoil the surprise: they're all great to work with.
sampu 0.7.0 basics
Architecture Overview
sampu is a Haskell binary which leverages the high performance Warp web server. sampu targets Warp via WAI (Web Application Interface) via the Twain web framework. HTML and CSS for the application are written in Lucid and Clay respectively.
Content Management is sampu
sampu content is abstracted as individual "posts" and is managed by flat files whose names are indexed in memory when the application is initialized. This includes the content for the site's global footer, home page, and contact pages; the content for each of these components is a standard "post" entry.
You add, edit, or remove posts by creating new Markdown files in the data/posts
directory. Only unhidden (no leading .) files with the file suffix/extension of .md are included as posts. If you create or remove a post entirely, the application must be restarted to pick up these changes. A new post won't have a route, be displayed in the "posts index", or be included the Atom feed. A deleted post will cause a 500 ISE when visited in the event that you don't restart sampu.
I'm undecided whether I will have the routes built on every request but with caching or if I will simply add better error handling to the above scenario. I do know one of the two will happen before a 1.0 release.
Routes
Routes for the application are built as a simple Haskell list data type of WAI Middleware
. They contain a function name denoting the request method that route can handle, the path for the route, and a function that can handle the request to return a response. The first entry whose request method and path matches the request is executed. Here's an example route:
get "/" Handle.index :: Middleware
Since the routes are modelled as a regular Haskell list, I was able to intuitively generate routes for each post like this:
mdFileToRoute :: FilePath -> Middleware
mdFileToRoute postName = get
(fromString $ "/posts/" ++ postName)
(Handle.posts postName)
This function takes the name of a post and constructs a WAI Middleware
that we can put in our Routes list. If you map over this function with the full list of pages that are indexed in memory at the application's init, you now have a list of routes representing all of your posts that can be concatenated to the application's routes. This is identical to how I'd think about working with more concrete values like strings or integers; I didn't have to learn a single framework-specific function to accomplish any of this!
You compose actual external Middleware
, like a request logger, in the same way: instead of mutating state to track which Middleware are enabled like in Scotty, you throw your Middleware
in at the beginning or end of your routes depending on when you want them to execute.
Lucid DSL for HTML
I've used JSX, Vue3, PHP, and Hamlet (part of Yesod's ecosystem) for HTML templating in the past. Working with the first two had the standard fare kinds of problems you'd expect in a poorly typed language. I'll wax poetic on that topic sometime in the future.
Hamlet is implemented via Template Haskell. What you write using it looks a lot like regular HTML without closing tags and integrates some nice Haskell-y features like a "maybe" construct on top of regular HTML templating features. It also looks terrible with HTML's traditional markup syntax but zero closing tags; I tried all sorts of different formatting styles and ended up settling on letting ormolou mangle it instead because no matter what I did, I found Hamlet to be harder to read than regular HTML. All of that said, Hamlet is mature and supports the features you'd expect, so if you want something that looks and feels like HTML it works!
Enter Lucid. We throw out the Template Haskell metaprogramming and instead work with regular functions. When I found this library and read through its Hackage documentation, I spent several hours thinking up crazy ways to abstract over it because it's all functions!
I didn't get to do anything super cool with Lucid because this blog is, and should be, pretty simple. Over-engineering the front-end of a text-first blog was not on the menu. I did compose template fragments together via regular function call syntax to create the resulting pages and I didn't find myself having to look up library functions. I love the idea of the "HOWL" (Hypermedia on Whatever you'd Like) "stack" using libraries like HTMX or Datastar and this makes it way easier to integrate with those technologies. It's easy and fast to compose template fragments the same way that Haskell makes it easy and fast to compose functions, so it encourages more modularity than I found with previous templating languages. I'm sure that any interactive projects I do in the future will benefit from this.
Clay DSL for CSS
I'm really bad at CSS. That's likely because I haven't spent enough time learning it and have always done just enough to get by. Clay didn't/can't fix that, but it works in a similar way to Lucid and provides the same kinds of benefits. I'm able to use regular Haskell functions (refer to meme above) and idioms to operate on Clay's CSS data structures with very little library-specific overhead. It feels like I'm working with Haskell, not working with a library. I'm still working on improving the default "theme" of sampu and I'm sure the small and composable pieces of styling that both Clay and CSS encourage will emerge as I improve things!
Atom Feed
Generating the Atom feed is very similar to the routes. Instead of generating route functions, we use the list of post names to generate Atom post entries in XML. I didn't do anything fancy here so check out the source
Wrapping up
I just realized that I wrote this blog post before I updated the README with build/deployment instructions. Uh... Coming soon™.