Introducing Elmstatic: an Elm-to-HTML static site generator

I've been using Elm to generate this site for a few months. It's still highly experimental and incomplete but I hope that other Elm enthusiasts may find it useful.

At the moment, it works for my needs and allows me to generate this site with a single command, but it requires manual intervention if I'm doing things like deleting pages or adding new Elm dependencies, and I'm sure there are many use cases it doesn't address.

I think I have something barebones but useful so I published it on NPM under the name of Elmstatic.

This is the current set of features:

  • Pages generated from Elm code, possibly arranged in some kind of a hierarchy (you can use style-elements, html or any other package that generates Html msg values)
  • CSS generated from elm-css stylesheets in Elm code (but you can use plain old stylesheets if you like)
  • Any page can be designated to be the index page
  • Page templates for posts and lists of posts (fully customisable)
  • Posts are written in Markdown
  • Posts can have multiple tags
  • A page with a list of posts is generated for each tag
  • RSS is generated for the posts
  • Code highlighting via Highlight.js (but you can set up whatever you want)

How to use it

The basic workflow is like this:

  • Install the package from NPM:
$ npm install -g elmstatic
  • Create a directory for your site:
$ mkdir mysite
$ cd mysite
  • Run the init command in your site directory to generate a scaffold:
$ elmstatic init
  • Change the generated files to your liking, then generate the output:
$ elmstatic
  • Run an HTTP server in the output directory to test out the site:
$ cd output
$ http-server
  • Publish to your host of choice (I use GitHub Pages)

How things are organised

This is the file structure of the scaffold:

|- Pages
  |- About.elm
  |- Post.elm
  |- Posts.elm
|- Posts
  |- 2018-01-01-using-elmstatic.md
|- Resources
  |- img
    |- logo.png
|- config.json
|- elm-package.json
|- Page.elm
|- Styles.elm
|- Tags.elm
|- template.html

config.json

config.json contains the site-specific parameters which Elmstatic inserts into appropriate places. The default config looks like this:

{
    "siteTitle": "Author's fancy blog",
    "outputDir": "output",
    "index": "posts",
    "feed": {
        "title": "Author's fancy blog",
        "description": "Author's blog posts",
        "id": "https://example.com",
        "link": "https://example.com/posts",
        "image": "https://example.com/img/logo.png",
        "copyright": "Copyright: Author",
        "language": "en",
        "generator": "Elmstatic", 
        "author": {
          "name": "Author",
          "link": "https://example.com/about"
        }        
    }
}

siteTitle goes into the </code> tag in HTML.</p><p><code>outputDir</code> is a relative path from the site's directory.</p><p><code>index</code> names a page which is going to become <code>index.html</code> at root level. In the example above, it's set to <code>posts</code>, which means that Elmstatic will use <code>Pages/Posts.elm</code> to generate two pages: <code>/posts/index.html</code> and <code>/index.html</code>.</p><p>The object under the <code>feed</code> key specifies parameters for the RSS feed.</p><h3>elm-package.json</h3><p><code>elm-package.json</code> is a standard issue Elm file. If you need additional Elm packages in your code, add them here.</p><h3>template.html</h3><p><code>template.html</code> is the HTML template used for every page:</p><pre><code><!doctype html> <html> <head> <title>${title} ${content}

${content} gets replaced with the HTML for a given page. This is where you can add custom styles, custom JavaScript, analytics or Google Fonts, for example. I toyed with the idea of moving this template to Elm as well, but I figured there wouldn't be much value in it.

Styles.elm

The scaffold file looks like this:

module Styles exposing (..)

import Css exposing (..)
import Css.Foreign exposing (..)
import Css.Colors exposing (..)
import Html
import Html.Styled
import Json.Decode

codeStyle = 
    [ fontFamily [ "Inconsolata", monospace ]
    , fontSize (rem 1)
    ]

styles : Html.Html msg
styles =
    global
        , class "markdown"
            [ descendants
                [ a [ color <| hex "348aa7" ] ]
            , each [ h1, h2, h3, h4, h5, h6 ] [ fontFamily [ "Proza Libre", "Helvetica", sansSerif ] ]
            , code codeStyle 
            , pre 
                [ descendants [ code codeStyle ] ]
            ]
        ]
        |> Html.Styled.toUnstyled

It's an elm-css stylesheet. The generator looks specifically for the Styles.styles function.

It's needed to style Markdown (within nodes with the .markdown class) and any other external DOM nodes which you aren't able to style via Elm.

This stylesheet gets converted into /css/default.css which is linked in template.html.

Tags.elm

The scaffold is set up with a couple of default tags which you can change as you see fit:

module Tags exposing (..)

import String exposing (toLower)


type Tag
    = Books
    | Personal
    | Software
    | UnknownTag -- This is required


fromString : String -> Tag
fromString s =
    case toLower s of
        "books" ->
            Books

        "personal" ->
            Personal

        "software" ->
            Software

        _ ->
            UnknownTag

Tags are applied to blog posts. Since tags are specified in Markdown files, there is no compile time enforcement. UnknownTag is there to catch any misspelled or nonexistent tags - and then you can go to the /tags/unknowntag page to check if there are any posts with invalid tags.

In retrospect, I'm not sure if I even need the union type - maybe just a list of valid tags would be sufficient.

For each tag that has posts, the generator creates a page with a list of the posts under /tags/.

Page.elm

Page.elm contains the bulk of the code which describes the layout and styling. It exports functions which are then used by specific pages. For example, this is the view function:

view : Header Variations m -> List (Element PageStyles Variations m) -> Html.Html m
view header contentElems =
    viewport styleSheet <|
        column Main
            [ center, width (percent 100) ]
            [ header
            , column Main
                [ width <| px 800, paddingTop 10, paddingBottom 50, spacingXY 0 10, alignLeft ]
                contentElems
            , footer

And here is the view function from About.elm which uses functions from Page.elm:

pageContent =
    """
You can write something about *yourself* here using `Markdown`.
"""


view : a -> Html.Html msg
view _ =
    Page.view Page.topLevelHeader
        [ Page.title "About the Author"
        , Page.markdown pageContent
        ]

Each of the modules in the Pages directory gets converted into a corresponding HTML page by Elmstatic.

Post.elm and Posts.elm

These functions provide templates for a single post and for a list of posts.

For example, the generator will take the Markdown from Posts/2018-01-01-using-elmstatic.md, pass it to the Pages.Post.view function and use its result to generate HTML.

Note that the Elm naming conventions for modules apply here, so each module name has to be prefixed with Pages, eg Pages.Post.

Resources directory

Everything in the Resources directory gets copied verbatim to the root level of the output directory. So, for example, Resources/img/logo.png becomes available as myblogdomain.com/img/logo.png on the published site. This is where you can add additional stylesheets, scripts and images.

Markdown files in Posts directory

Posts are handled very similarly to Jekyll.

The post files have a particular naming scheme: YYYY-MM-DD-kebab-case-post-url-slug.md. Elmstatic extracts the date of the post and the URL for it from the file name.

Additionally, each post file starts with a preamble that looks like this:

---
title: "Introducing Elmstatic"
tags: software elm
---

The title has to be quoted. It appears both at the top of the post as well as on post list pages.

The post has to have one or more tags. For each tag which has associated posts, Elmstatic generates a post list page.

The body of the post is written in Markdown and appears after the preamble.

Directory structure of the output

This structure is modelled on the output of Jekyll. For the scaffold described above, the output structure will look like this:

|- about
  |- index.html
|- css
  |- defaults.css
|- img
  |- logo.png
|- posts
  |- 2018-01-01-using-elmstatic
    |- index.html
  |- index.html
|- tags
  |- personal
    |- index.html
  |- software
    |- index.html
|- index.html
|- rss.xml

How it came to be

I used Jekyll for my site for a long time. While Jekyll is convenient enough for a blog, adding and managing various pages in addition to blog posts was always a bit of a pain.

At one point I also discovered the style-elements library for Elm, and it was far more pleasant to use for layouts than CSS. Things made sense in a way they never did with CSS.

This gave me the idea that I could whip up my own static site generator which would allow me to use Elm for generating pages. After all, I'm not doing anything fancy, I don't even have comments on my blog posts!

Following the great tradition of yak shaving, I set out to experiment and find out how much Elm I can use and how it could work. Naturally, I thought it would only take a couple of days, and naturally, I was wrong. It took quite a few days to get even a barebones version working.

First attempt: elm-static-html

First of all, I had to find a way to convert Elm code into HTML.

It turned out that there is an NPM package called elm-static-html which relies on another package called node-elm-compiler to invoke the Elm compiler and generate HTML output from Html msg values returned by view functions. I could borrow this approach with slight modifications for my purposes. Since style-elements functions produce Html msg values in the end, I could use style-elements without problems.

I quickly figured out that it makes no sense to write posts in Elm, both because I already had an archive of posts and because Markdown is simply much more convenient. Here, I ran into a problem: the default Elm package for generating Markdown, elm-markdown, does it at runtime using JavaScript. That's far too late for me! I wanted my posts converted before I published them.

Luckily, I found another package, pablohirafuji/elm-markdown, which is a pure Elm implementation of Markdown parsing. It allows me to produce Html msg values from Markdown by calling Markdown.toHtml. Great, it's just what I needed!

I could plug Markdown into my style-elements layouts like this:

markdown : String -> Element PageStyles v msg
markdown content =
    Element.html <|
        Html.div [ Html.Attributes.class "markdown" ] (Markdown.toHtml Nothing content)

-- ...

viewport styleSheet <|
    column Main
        [ center, width (percent 100) ]
        [ header
        , column Main
            [ width <| px 800, paddingTop 10, paddingBottom 50, spacingXY 0 10, alignLeft ]
            [ markdown "Here comes the **Markdown**" ]
        , footer
        ]

The only problem was that because I had all this HTML generated by another package, I couldn't apply the style-elements styling to it. I settled on styling Markdown via a CSS file (hence the extra div with markdown class in the markdown function).

It was also important for me to have syntax highlighting for code snippets, and I used Highlight.js for that rather than trying to do something in Elm. Highlight.js can highlight Elm code.

Finally, I used the feed package from NPM to generate RSS feeds, and I had a working site generated from Elm.

Improvements: elm-static-html-lib and elm-css

The downside of elm-static-html was that it used native modules which are going to become unavailable in Elm 0.19. I asked about the native module issue on Slack, and Noah told me that after Elm 0.19 is out, only elm-static-html-lib will be compatible, using some new approach that doesn't involve native modules.

I decided to convert my generator to elm-static-html-lib (I wrote a post about elm-static-html-lib).

It was fairly straightforward, and along with a bit of future-proofing, I also cleaned up the generator code quite a lot and even got better performance.

I also noticed that the Css.Foreign module of rtfeldman/elm-css produces Html msg values via its global function (it creates a script node containing CSS). This meant that I could easily adapt my generator to produce a CSS file from Elm code!

elm-static-html-lib is unable to handle some scenarios:

  • If I change the package dependencies in my elm-package.json, it updates its own copy but doesn't install new dependencies
  • If I delete one of my .elm files, it produces an error because it still has a generated module for it.

These things are on my list of future improvements.

Comments or questions? I'm @alexkorban on Twitter.