Generating static HTML from Elm code with elm-static-html-lib

I've written a static site generator to produce my site (yaks have to be shaved, after all). Unlike thousands of other static site generators out there, mine allows me to write pages in Elm, specifically because I wanted to be able to use style-elements and get away from CSS.

I'm using eeue56/elm-static-html to convert Elm to plain HTML, and I'm about to switch to eeue56/elm-static-html-lib (thanks to Noah for his many contributions to Elm!).

But how does it work? Normally, our views produce Html msg which drops into the depths of the Elm runtime, and sometime later we hear the plop! of a message dropping into the update function. There are no strings of HTML in this picture.

However, elm-static-html-lib somehow manages to produce strings of HTML from Elm code. Very curious!

Here is a high level view of what it does (optimisations aside):

  • elm-static-html-lib is given the path to elm-package.json and the fully qualified name of the view along with config options, if needed.
  • It creates another Elm application in the .elm-static-html subdirectory; this application refers to the original source files
  • It installs the required packages for this new application
  • It compiles the application
  • It runs the application
  • It returns a string of HTML to the caller via a promise

OK, so there's a new application behind the scenes, but it doesn't clarify that much. Let's look at these steps more closely.

The new Elm application has a number of files created:

  • A copy of the original elm-package.json with some path adjustments, native modules enabled, and a new dependency on eeue56/elm-html-in-elm
  • PrivateMain.elm
  • Native/Jsonify.js

In order to compile the application, the library uses an NPM package called node-elm-compiler which allows the Elm compiler to be used from Node.

Then, running the application is a matter of requiring the elm.js file produced by the compiler and creating a worker, as you would do in an HTML file if it was your own app:

const Elm = require(path.join(dirPath, "elm.js"));
// ...         
const elmApp = Elm[privateName].worker(filenamesAndModels);
elmApp.ports[`htmlOut${moduleHash}`].subscribe(resolve);

Once the worker is created, the library subscribes to the htmlOut port whence the strings of HTML pour forth.

To find out how these strings are generated, we need to take a look at what goes on in the generated Elm code in PrivateMainZZZ.elm (assuming ZZZ is the hash; the hash is needed when handling multiple views). Here is what it looks like:

port module PrivateMainZZZ exposing (..)

import Platform
import Html exposing (Html)
import ElmHtml.InternalTypes exposing (decodeElmHtml)
import ElmHtml.ToString exposing (FormatOptions, nodeToStringWithOptions, defaultFormatOptions)
import Json.Decode as Json
import Native.Jsonify

import MyModule

decode : FormatOptions -> Html msg -> String
decode options view =
    case Json.decodeValue decodeElmHtml (asJsonView view) of
        Err str -> "ERROR:" ++ str
        Ok node -> nodeToStringWithOptions options node
            
renderZZZ : Json.Value -> String
renderZZZ _ =
    let
        options = { defaultFormatOptions | newLines = True, indent = 4 }
    in
        (decode options) <| MyModule.view
        
renderers : List (Json.Value -> String)
renderers = [ render{hash} ]

init : List (String, Json.Value) -> ((), Cmd msg)
init models =
    let
        mapper renderer (fileOutputName, model) =
            { generatedHtml = renderer model
            , fileOutputName = fileOutputName
            }
        command =
            List.map2 mapper renderers models
                |> htmlOutZZZ
    in
        ( (), command )

asJsonView : Html msg -> Json.Value
asJsonView = Native.Jsonify.stringify

port htmlOutZZZ : List { generatedHtml : String, fileOutputName: String } -> Cmd msg

main = Platform.programWithFlags
    { init = init
    , update = (\\_ b -> (b, Cmd.none))
    , subscriptions = (\\_ -> Sub.none)
    }

The init function returns a command which sends the HTML string out through the port. The key part of this file is this case expression:

case Json.decodeValue decodeElmHtml (asJsonView MyModule.view) of
    Err str -> "ERROR:" ++ str
    Ok node -> nodeToStringWithOptions options node

To understand what's going on here, a few function signatures are useful:

MyModule.view : Html msg

asJsonView : Html msg -> Json.Value

decodeValue : Decoder a -> Value -> Result String a

decodeElmHtml : Json.Decode.Decoder ElmHtml

nodeToStringWithOptions : FormatOptions -> ElmHtml -> String

So the view is first converted into a Json.Value which is then turned into ElmHtml, which is in turn deconstructed into a string.

decodeElmHtml, nodeToStringWithOptions and ElmHtml are provided by the eeue56/elm-html-in-elm package. ElmHtml describes various HTML node types as records, so the process of conversion from here is straightforward:

type ElmHtml
    = TextTag TextTagRecord
    | NodeEntry NodeRecord
    | CustomNode CustomNodeRecord
    | MarkdownNode MarkdownNodeRecord
    | NoOp
    
type alias NodeRecord =
    { tag : String
    , children : List ElmHtml
    , facts : Facts
    , descendantsCount : Int
    }
    
...    

The not-so-straightforward part is the conversion done by asJsonView. asJsonView is an alias for Native.Jsonify.stringify, which is defined like this:

function forceThunks(vNode) {
    if (typeof vNode !== "undefined" && vNode.ctor === "_Tuple2" && !vNode.node) {
        vNode._1 = forceThunks(vNode._1);
    }
    if (typeof vNode !== 'undefined' && vNode.type === 'thunk' && !vNode.node) {
        vNode.node = vNode.thunk.apply(vNode.thunk, vNode.args);
    }
    if (typeof vNode !== 'undefined' && typeof vNode.children !== 'undefined') {
        vNode.children = vNode.children.map(forceThunks);
    }
    return vNode;
}
var _${fixedProjectName}$Native_Jsonify = {
    stringify: function(thing) { return forceThunks(thing) }
};

Without digging into the Elm runtime, it's not clear to me how stringify converts its argument into a string. This code was introduced to handle lazy views, which would involve functions (hence references to thunks). However, in earlier versions of the library, it used to be defined like this instead:

var _${fixedProjectName}$Native_Jsonify = {
    stringify: function(thing) { return JSON.stringify(thing); }
};

In other words, it used to take an object and simply convert it into a JSON string.

In a sense, it's immaterial how this bit of code works because native modules are going away as a user accessible feature in Elm 0.19, and my understanding is that elm-static-html-lib is going to use a different technique to convert views when that happens.

So that's the essential process! I put together a diagram of it:

Title: Converting an Elm view to an HTML string "elm-static-html-lib JS"-->Elm: Create worker "elm-static-html-lib JS"-->Elm: Subscribe to htmlOut Note over Elm: view Elm->'Native' JS: Html msg Note over 'Native' JS: stringify 'Native' JS->Elm: Json.Value Note over Elm: decodeValue Elm->Elm: ElmHtml Note over Elm: nodeToStringWithOptions Elm->Elm: String Note over Elm: htmlOut port Elm->>"elm-static-html-lib JS": String Converting an Elm view to an HTML stringConverting an Elm view to an HTML stringelm-static-html-lib JSelm-static-html-lib JSElmElm'Native' JS'Native' JSCreate workerSubscribe to htmlOutviewHtml msgstringifyJson.ValuedecodeValueElmHtmlnodeToStringWithOptionsStringhtmlOut portString

In addition, there is also a way to pass options into a view, and a way to generate HTML for multiple views with a single call, but these are just embellishments of the main mechanism.

And with this, I'm able to write this site in a mix of Markdown and Elm, with very little HTML and CSS in the mix.

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