How I generate static HTML from Elm code in Elmstatic
2019-04-23

Elmstatic is a static site generator that I made to be able to use Elm’s html and elm-css instead of plain HTML and CSS. It is a Node.js NPM package.

I made it back in the olden days of Elm 0.18, and back then I relied on elm-static-html-lib to generate HTML. However, this library never made it through the transition to Elm 0.19, so I had to find another solution.

Inspired by the rogeriochavez/spades framework for Elm SPAs, I decided to use the jsdom project to generate the HTML.

The steps of generating an HTML file with with a given page layout are as follows:

  • Build elm.js containing the layout
  • Create a script that initializes a layout and execute it
  • Serialize the body of the resulting page to a file

Build

Each of the different page layouts used on the site is represented by an Elm module that exports a main function.

The Elm compiler can compile multiple modules into a single elm.js, so I just need to do this step once for all layouts, running something like:

$ elm make _layouts/Page.elm _layouts/Post.elm --output elm.js

These layouts are then available in JavaScript as Elm.Page and Elm.Post.

Create a script and execute layout code

jsdom has the ability to execute arbitrary JavaScript with a given DOM, so this is quite straightforward:

function generateHtml(pageOrPost) {
    const elmJs = Fs.readFileSync("elm.js").toString()

    const script = new Script(`
    ${elmJs}; let app = Elm.${pageOrPost.layout}.init({flags: ${JSON.stringify(pageOrPost)}})
    `)

    const dom = new JsDom(`<!DOCTYPE html><html><body></body></html>`, {
        runScripts: "outside-only"
    })

    try {
        dom.runVMScript(script)
        return Promise.delay(1).then(() => ({
            outputPath: pageOrPost.outputPath,
            html: "<!doctype html>" + R.replace(/script/g, "script", dom.window.document.body.innerHTML)
        }))
    }
    catch (err) {
        return Promise.reject(err)
    }
}

In the above function, I read in elm.js from disk, then put it into a big long string with a bit of initialisation code tacked onto the end. This string is used to construct a Script object, followed by a JsDom object. Then I get jsdom to run this script on this particular DOM object, and I get the HTML string I want out of document.body.innerHTML.

I pass the metadata (such as post tags) and the Markdown content into the Elm program via flags.

Note that I have to do a weird replacement on the innerHTML string, changing “script” to “script”. That’s because Elm’s html package prevents me from generating <script> nodes (it replaces them with <p>).

Layout

Let’s consider the code of a simple Page module/layout:

module Page exposing (main)

... 

main : Elmstatic.Layout
main =
    Elmstatic.layout Elmstatic.decodePage <|
        \content ->
            header
                ++ [ div [ class "sidebar" ]
                        []
                   , div [ class "sidebar2" ]
                        []
                   , div [ class "content" ]
                        [ h1 [] [ text content.title ], markdown content.markdown ]
                   , footer
                   , Styles.styles
                   ]

Elmstatic.layout decodes the flags, generates the initial view and calls Browser.document:

layout : Json.Decode.Decoder (Content content) -> (Content content -> List (Html Never)) -> Layout
layout decoder view =
    Browser.document
        { init = \contentJson -> ( contentJson, Cmd.none )
        , view =
            \contentJson ->
                case Json.Decode.decodeValue decoder contentJson of
                    Err error ->
                        { title = ""
                        , body = [ htmlTemplate "Error" [ Html.text <| Json.Decode.errorToString error ] ]
                        }

                    Ok content ->
                        { title = ""
                        , body = [ htmlTemplate content.siteTitle <| view content ]
                        }
        , update = \msg contentJson -> ( contentJson, Cmd.none )
        , subscriptions = \_ -> Sub.none
        }

Different types of pages can receive different flags (for example a list of posts for the tag pages), so we need to supply a JSON decoder when calling this function.

The view is generated with the help of htmlTemplate. htmlTemplate is interesting in that it generates all of the HTML tags starting with html:

htmlTemplate : String -> List (Html Never) -> Html Never
htmlTemplate title contentNodes =
    node "html"
        []
        [ node "head"
            []
            [ node "title" [] [ text title ]
            , node "meta" [ attribute "charset" "utf-8" ] []
            , script "//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.1/highlight.min.js"
            , script "//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.1/languages/elm.min.js"
            , inlineScript "hljs.initHighlightingOnLoad();"
            , stylesheet "//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.1/styles/default.min.css"
            , stylesheet "//fonts.googleapis.com/css?family=Open+Sans|Proza+Libre|Inconsolata"
            ]
        , node "body" [] contentNodes
        ]

So I have a nested set of html/head/body tags within the body tag of the DOM I use to generate the static HTML. Definitely a hack, but very convenient, as I don’t have to have a separate template.html with this stuff (as was the case before).

Back in the Page module, I used the markdown function to convert Markdown content to HTML. This function relies on the elm-explorations/markdown package (which in turn uses a JS library called Marked):

markdown : String -> Html Never
markdown s =
    let
        mdOptions : Markdown.Options
        mdOptions =
            { defaultHighlighting = Just "elm"
            , githubFlavored = Just { tables = False, breaks = False }
            , sanitize = False
            , smartypants = True
            }
    in
    Markdown.toHtmlWith mdOptions [ attribute "class" "markdown" ] s

CSS

It’s possible to generate most stylesheets in Elm as well. You may have noticed that Page.main refers to Styles.styles:

main : Elmstatic.Layout
main =
    Elmstatic.layout Elmstatic.decodePage <|
        \content ->
            header
                ++ [ div [ class "sidebar" ]
                        []
                   , div [ class "sidebar2" ]
                        []
                   , div [ class "content" ]
                        [ h1 [] [ text content.title ], markdown content.markdown ]
                   , footer
                   , Styles.styles
                   ]

This value comes from the Styles module:

module Styles exposing (styles)

import Css exposing (..)
import Css.Global exposing (..)
import Css.Media as Media exposing (..)
import Html exposing (Html)
import Html.Styled


styles : Html msg
styles =
    global
        [ body
            [ padding <| px 0
            , margin <| px 0
            , backgroundColor <| hex "ffffff"
            , Css.color <| hex "363636"
            , fontFamilies [ "Open Sans", "Arial", .value sansSerif ]
            , fontSize <| px 18
            , lineHeight <| Css.em 1.4
            ]
        , a
            [ Css.color <| hex "348aa7"
            , textDecoration none
            ]
        ]

Here I’m using the Global module from the rtfeldman/elm-css package to generate a stylesheet in Elm. The Css.Global.global function returns an Html msg value which is the same type as the values returned by Html.div and the like, but global produces a <style> tag containing the CSS styles.

elm-css supports most of CSS, one notable exception being CSS Grid. Unsupported CSS is not a major problem, however, because you can easily add in a regular .css stylesheet when you need to.

The upshot

One advantage of the new approach is that since I’m able to execute arbitrary JavaScript, I no longer need to use the pure Elm pablohirafuji/elm-markdown package to convert Markdown to HTML, and can instead use JS-backed elm-explorations/markdown which supports more Markdown features. Another advantage is that it’s now possible to specify all of the HTML starting from <html> tags in Elm.

I also expect this approach to be more robust in the future, as new versions of Elm come out, as jsdom is an actively developed project.

Interestingly, I was initially expecting to see performance improvements from the upgrade to Elm 0.19, but it turned out that while compilation times for layouts are certainly much better, running elm.js through jsdom for each page and post takes longer than the old approach. So in the end the speed of generating a site is unchanged (and can be described as unhurried). However, there are plenty of options for performance improvements, like only regenerating stuff that’s changed.

Overall, I think Elmstatic has benefitted from this upgrade.