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:
elm.js
containing the layoutEach 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
.
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>
).
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
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.
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.