I've overhauled my blogDid this involve writing any substantive new content? Of course not! Fiddling with styling and deployment systems is the most import part of creating a blog.. It's still static, and I'm still using Haskell and Nix, but nearly everything else has changed. I have:
It was a totallyFun isn’t necessary, but that doesn’t mean it’s not worthwhile. unnecessary project, but it gave me a welcome chance to play around with some new tools over the holidays, and I'm very happy with how it turned out. The following is a write-up of the major features of the new implementation.
Markdown might be the more obvious choice as a markup language, but if you've already invested in becoming productive with org mode then the latter is easily the better option. It's also best to reduce friction to creating content, and org mode is what I naturally reach for when I want to capture and expand on an idea.
Hakyll has served me well for years, but so far I'm happy with the switch to the simpler Slick. Hakyll's "DSL" makes it easy to get up-and-running if you follow established patterns, but it's implemented in a sufficiently mystical fashion to make changing or adding functionality a real headache. Slick, on the other hand, is much easier to grok; it essentially consists of some utilities to glue together well-established libraries and tools:
A slick blog requires a few more lines and bit more up-front understanding on the part of the author, but in my view it is easier and more transparent in the long run.
As of now Slick only has built-in support for markdown. Thankfully
it's trivial to switch out the default markdown parsing functions with
custom code to handle org mode. The orgToHTML
function
below can be used in place of slick's markdownToHTML
:
import Data.Aeson as A
import qualified Data.Text as T
import Development.Shake
import Text.Pandoc
import Slick.Pandoc
defaultOrgOptions :: ReaderOptions
=
defaultOrgOptions = exts }
def { readerExtensions where
= mconcat
exts
[ extensionsFromListExt_fenced_code_attributes
[ Ext_auto_identifiers
,
]
]
orgToHTMLWithOpts :: ReaderOptions -> WriterOptions -> T.Text -> Action Value
=
orgToHTMLWithOpts rops wops txt
loadUsing (readOrg rops) (writeHtml5String wops) txt
orgToHTML :: T.Text -> Action Value
= orgToHTMLWithOpts defaultOrgOptions defaultHtml5Options txt orgToHTML txt
The downside of using org mode is the limited support for metadata parsing. Unlike the markdown parser, the org mode parser doesn't support yaml style metadata like the following:
---
title: my article
tags: [writing, blogging]
---
It does support the standard org mode metadata format, where lines of
the format #+key: value
are placed at the beginning of the
file. However, only a few standard keys are recognized–the rest are
ignored. Thankfully the Pandoc
datatype can be easily be
re-parsed to capture un-parsed metadata lines. See below:
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson as A
import Data.Char (toLower)
import Data.List as L
import Data.Map as M
import qualified Data.Text as T
import Development.Shake
import Slick.Pandoc
import Text.Pandoc
import Text.Pandoc.Builder
import Text.Pandoc.Parsing
-- | Parse a RawBlock as an org metadata key-value pair.
orgMetaKV :: Block -> (T.Text, MetaValue)
RawBlock _ txt) =
orgMetaKV (case (parse parser "" txt) of
Left err -> error $ show err
Right xs -> xs
where
= do
parser <- string "#+"
_ <- manyTill anyChar $ string ": "
key <- colonList <|> remainder
value return (T.pack $ L.map toLower key, toMetaValue value)
= toMetaValue <$> do
colonList <- char ':'
_ ':')
endBy (many alphaNum) (char = toMetaValue <$> many anyChar
remainder = error "Invalid block type for org metadata"
orgMetaKV _
-- | Parse unparsed org metadata from pandoc blocks and move to Meta.
-- This is useful because Pandoc's org parser ignores all but a few
-- metadata keys by default.
orgAllMeta :: Pandoc -> Pandoc
Pandoc (Meta meta) blocks) = Pandoc expandedMeta remainderBlocks
orgAllMeta (where
= Meta $ M.union meta newMeta
expandedMeta = M.fromList $ L.map orgMetaKV rawMeta
newMeta = span rawOrgBlock blocks
(rawMeta, remainderBlocks)
rawOrgBlock b| RawBlock (Format "org") _ <- b = True
| otherwise = False
We can plug this into the previous code by making the following change:
orgToHTMLWithOpts :: ReaderOptions -> WriterOptions -> T.Text -> Action Value
=
orgToHTMLWithOpts rops wops txt
loadUsingfmap orgAllMeta <$> readOrg rops) -- <$> is over partially applied func
(
(writeHtml5String wops) txt
The project derivations are defined in a
release.nix
:
{ pkgs }:
rec {
generator = pkgs.haskellPackages.developPackage {
root = ./.;
modifier = drv: pkgs.haskell.lib.overrideCabal drv (attrs: {
buildTools = with pkgs; (attrs.buildTools or []) ++ [
haskellPackages.cabal-install
haskellPackages.hpack
haskellPackages.ghcid
zlib];
configureFlags = [
"--extra-lib-dirs=${pkgs.zlib}/lib"
];
}) ;
};
files = pkgs.stdenv.mkDerivation {
name = "ftzm-blog";
src = ./.;
phases = "unpackPhase buildPhase";
version = "0.1";
buildInputs = [ generator ];
buildPhase = ''
mkdir $out
export LOCALE_ARCHIVE="${pkgs.glibcLocales}/lib/locale/locale-archive";
export LANG=en_US.UTF-8
build-site
cp -r docs/* $out
'';
};
}
The first derivation is a simple haskell build of the blog generator.
It relies on a standard project.yaml
in the root directory
which defines the details of the haskell build. Defining the haskell
build in a project.yaml rather than in nix itself allows us to use cabal
in a nix shell for local development, which is considerably more
convenient. The second derivation simply uses the binary from the first
derivation to build the site's static assets.
I don't build the derivation in this file directly; I call it from a flake file like the following:
{
description = "blog";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/19b5ddfbb951013461d39352bf05e6248369d580";
outputs = { self, nixpkgs }:
let
packages = with import nixpkgs { system = "x86_64-linux"; };
./release.nix {};
callPackage
in
{
packages.x86_64-linux.generator = packages.generator;
packages.x86_64-linux.files = packages.files;
defaultPackage.x86_64-linux = packages.generator;
};
}
While still experimental, I've found flakes to be very ergonomic, and
I really appreciate the first-class support for dependency pinning.
Using this flake, I can build the (default package) generator with
nix build
or target the files attribute with
nix build .#files
.
Using flakes, we can run nix develop
to start a
development shell for the default package of the flake (in this case the
generator). This will make cabal available and other build tools
available. We can serve up the statically generated files via serve, and use entr to ensure that the
files are re-generated on changes to the generator. Here's the script
I'm using for this site:
#!/usr/bin/env bash
# Rebuild on template/content change
find site/ | entr -p sh -c 'cabal run' &
# Rebuild on generator change
find app/ | entr -p sh -c 'rm -r .shake; rm -r docs; cabal run' &
# Clean up the terminal on exit
trap "reset" EXIT
# Serve static files
serve docs
The blog files are served by Nginx on a tiny server running NixOS. To deploy I'm using deploy-rs, a new nix deployment tool by the folks at Serokell. So far I prefer it to other nix deployment tools I've used in the past, mainly because:
Like other tools of this nature, it relies on a NixOS configuration
for the server and some extra configuration governing building and
deployment details. The my full server.nix
contains many
incidental details beyond the scope of this article, but the key portion
defining the nginx service serving the blog is as follows:
{
services = nginx = {
enable = true;
virtualHosts."ftzm.org" = {
enableACME = true;
forceSSL = true;
root = "${nginxWebRoot}";
locations = {
"/" = {
extraConfig = ''
# hide .html ending
if ($request_uri ~ ^/(.*)\.html$) {
return 302 $scheme://$http_host/$1;
}
try_files $uri $uri.html $uri/ =404;
'';
};
};
extraConfig = ''
error_page 404 /404.html;
'';
};
};
};
Nix makes it easy to get Nginx running in just a few lines. Really
the only essential line in virtualhosts."ftzm.org"
is
setting root = ${nginxWebRoot}
where nginxWebRoot points to
the blog files package defined above. The NixOS configuration is in turn
imported as blog-system
in the top level flake:
-system = nixpkgs.lib.nixosSystem {
nixosConfigurations.blogsystem = "x86_64-linux";
modules = [ (import ./server.nix { nginxWebRoot = packages.files;})];
};
Up until this point everything has been vanilla Nix. The final piece
of the puzzle is to specify deploy.nodes
, which will tell
deploy-rs what to deploy. Each node represents a target server to deploy
to. We define a node also named blog-system
, within which
we specify the server's hostname, the nix profile to deploy to, and
within that the path
of the deployment, which is
essentially the deployment command. The deployment command in this case
is to activate the blog-system nixos configuration.
-system = {
deploy.nodes.bloghostname = "ftzm.org";
profiles.system = {
sshUser = "root";
user = "root";
path = deploy-rs.lib.x86_64-linux.activate.nixos self.nixosConfigurations.blog-system;
};
# This is highly advised, and will prevent many possible mistakes
checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib;
};
All that's necessary to deploy to the defined nodes is to run the
following command:
nix run github:serokell/deploy-rs ./. -- -- --impure
.