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. .elm-static-html
subdirectory; this application refers to the original source files 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:
elm-package.json
with some path adjustments, native modules enabled, and a new dependency on eeue56/elm-html-in-elm
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:
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.