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.

is given the path to 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

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

with some path adjustments, native modules enabled, and a new dependency on PrivateMain<hash>.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:

Converting an Elm view to an HTML string Converting an Elm view to an HTML string elm-static-html-lib JS elm-static-html-lib JS Elm Elm ‘Native’ JS ‘Native’ JS Create worker Subscribe to htmlOut view Html msg stringify Json.Value decodeValue ElmHtml nodeToStringWithOptions String htmlOut port String

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.