Elmstatic: an Elm-to-HTML static site generator

Elmstatic is in an early stage of development but I've been using it to generate this site for a while.

This is the current set of features:

  • Pages generated from Elm code (you can use style-elements, html or any other package that generates Html msg values)
  • CSS generated from elm-css stylesheets in Elm code (but you can use plain old stylesheets if you like)
  • Page templates for posts and lists of posts (fully customisable)
  • Optional subsections with their own posts (eg /postgres and /elm on this site)
  • Posts are written in Markdown
  • Posts can have multiple tags
  • A page with a list of posts is generated for each tag
  • RSS is generated for the posts
  • Code highlighting via Highlight.js (but you can set up whatever you want)

How to use it

Please note that Elmstatic will continue to rely on Elm 0.18 until its dependencies support Elm 0.19. If you've already migrated to Elm 0.19, you can install Elm 0.18 into your site's directory and direct Elmstatic to use it (see config.json section below).

The basic workflow is like this:

  • Install the package from NPM:
$ npm install -g elmstatic
  • Create a directory for your site:
$ mkdir mysite
$ cd mysite
  • Run the init command in your site directory to generate a scaffold:
$ elmstatic init
  • Change the generated files to your liking, then generate the output:
$ elmstatic
  • Run an HTTP server in the output directory to test out the site:
$ cd output
$ http-server
  • Publish to your host of choice (I use GitHub Pages)

How things are organised

This is the file structure of the scaffold:

|- Pages
  |- About.elm
  |- Post.elm
  |- Posts.elm
|- Posts
  |- 2018-01-01-using-elmstatic.md
|- Resources
  |- img
    |- logo.png
|- config.json
|- elm-package.json
|- Page.elm
|- Styles.elm
|- Tags.elm
|- template.html

config.json

config.json contains the site-specific parameters which Elmstatic inserts into appropriate places. The default config looks like this:

{
    "siteTitle": "Author's fancy blog",
    "outputDir": "output",
    "copy": {
        "/posts": "/"
    },
    "elm": "node_modules/.bin/elm-make",
    "feed": {
        "title": "Author's fancy blog",
        "description": "Author's blog posts",
        "id": "https://example.com",
        "link": "https://example.com/posts",
        "image": "https://example.com/img/logo.png",
        "copyright": "Copyright: Author",
        "language": "en",
        "generator": "Elmstatic",
        "author": {
          "name": "Author",
          "link": "https://example.com/about"
        }
    }
}

siteTitle goes into the <title> tag in HTML.

outputDir is a relative path from the site's directory.

copy is used to give aliases to pages. The scaffold specifies that /posts will also be available as the root level index page. This aliasing is done as a postprocessing step, by copying files.

elm is an optional key that specifies the path to elm-make relative to the site's directory. It's useful if you don't have elm-make available in your PATH. You can install Elm 0.18 locally into your site directory with npm install [email protected], which will place elm-make into node_modules/.bin/elm-make.

The object under the feed key specifies parameters for the RSS feed.

elm-package.json

elm-package.json is a standard issue Elm file. If you need additional Elm packages in your code, add them here.

template.html

template.html is the HTML template used for every page:

<!doctype html>
<html>
<head>
    <title>${title}</title>
    <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/languages/elm.min.js"></script>
    <script>hljs.initHighlightingOnLoad();</script>
    <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/default.min.css">
    <link href="//fonts.googleapis.com/css?family=Open+Sans|Proza+Libre|Inconsolata" rel="stylesheet" type="text/css">
</head>
<body>
${content}
<link rel="stylesheet" href="/css/default.css">
</body>
</html>

${content} gets replaced with the HTML for a given page. This is where you can add custom styles, custom JavaScript, analytics or Google Fonts, for example. I toyed with the idea of moving this template to Elm as well, but I figured there wouldn't be much value in it.

Styles.elm

The scaffold file looks like this:

module Styles exposing (..)

import Css exposing (..)
import Css.Foreign exposing (..)
import Css.Colors exposing (..)
import Html
import Html.Styled
import Json.Decode

codeStyle =
    [ fontFamily [ "Inconsolata", monospace ]
    , fontSize (rem 1)
    ]

styles : Html.Html msg
styles =
    global
        , class "markdown"
            [ descendants
                [ a [ color <| hex "348aa7" ] ]
            , each [ h1, h2, h3, h4, h5, h6 ] [ fontFamily [ "Proza Libre", "Helvetica", sansSerif ] ]
            , code codeStyle
            , pre
                [ descendants [ code codeStyle ] ]
            ]
        ]
        |> Html.Styled.toUnstyled

It's an elm-css stylesheet. The generator looks specifically for the Styles.styles function.

It's needed to style Markdown (within nodes with the .markdown class) and any other external DOM nodes which you aren't able to style via Elm.

This stylesheet gets converted into /css/default.css which is linked in template.html.

Tags.elm

The scaffold is set up with a couple of default tags which you can change as you see fit:

module Tags exposing (..)

import String exposing (toLower)


type Tag
    = Books
    | Personal
    | Software
    | UnknownTag -- This is required


fromString : String -> Tag
fromString s =
    case toLower s of
        "books" ->
            Books

        "personal" ->
            Personal

        "software" ->
            Software

        _ ->
            UnknownTag

Tags are applied to blog posts. Since tags are specified in Markdown files, there is no compile time enforcement. UnknownTag is there to catch any misspelled or nonexistent tags - and then you can go to the /tags/unknowntag page to check if there are any posts with invalid tags.

In retrospect, I'm not sure if I even need the union type - maybe just a list of valid tags would be sufficient.

For each tag that has posts, the generator creates a page with a list of the posts under /tags/<tag name>.

Page.elm

Page.elm contains the bulk of the code which describes the layout and styling. It exports functions which are then used by specific pages. For example, this is the view function:

view : Header Variations m -> List (Element PageStyles Variations m) -> Html.Html m
view header contentElems =
    viewport styleSheet <|
        column Main
            [ center, width (percent 100) ]
            [ header
            , column Main
                [ width <| px 800, paddingTop 10, paddingBottom 50, spacingXY 0 10, alignLeft ]
                contentElems
            , footer

And here is the view function from About.elm which uses functions from Page.elm:

pageContent =
    """
You can write something about *yourself* here using `Markdown`.
"""


view : a -> Html.Html msg
view _ =
    Page.view Page.topLevelHeader
        [ Page.title "About the Author"
        , Page.markdown pageContent
        ]

Each of the modules in the Pages directory gets converted into a corresponding HTML page by Elmstatic.

About.elm

This is a plain page. You can add as many of them as you like. If you name the page file Index.elm, it will turn into an index page, both at the root level and at subsection level. For example, Pages/Index.elm becomes the page at /, in contrast to Pages/Projects.elm which becomes /projects. At subsection level, Pages/Elm/Index.elm becomes the page at /elm, while Pages/Elm/Projects.elm becomes /elm/projects.

Post.elm and Posts.elm

These functions provide templates for a single post and for a list of posts.

For example, the generator will take the Markdown from Posts/2018-01-01-using-elmstatic.md, pass it to the Pages.Post.view function and use its result to generate HTML.

Note that the Elm naming conventions for modules apply here, so each module name has to be prefixed with Pages, eg Pages.Post.

Resources

Everything in the Resources directory gets copied verbatim to the root level of the output directory. So, for example, Resources/img/logo.png becomes available as myblogdomain.com/img/logo.png on the published site. This is where you can add additional stylesheets, scripts and images.

Markdown files in the Posts directory

Posts are handled very similarly to Jekyll.

The post files have a particular naming scheme: YYYY-MM-DD-kebab-case-post-url-slug.md. Elmstatic extracts the date of the post and the URL for it from the file name.

Additionally, each post file starts with a preamble that looks like this:

---
title: "Introducing Elmstatic"
tags: software elm
---

The title has to be quoted. It appears both at the top of the post as well as on post list pages.

The post has to have one or more tags. For each tag which has associated posts, Elmstatic generates a post list page.

The body of the post is written in Markdown and appears after the preamble.

Subsections

In addition to the root level pages like /contact and /about, this site has a couple of subsections: /postgres and /elm. These are defined via subdirectories of Pages, and Elmstatic handles them in a particular way.

The structure of Pages for this site looks like this:

Pages
├── Elm
│   ├── About.elm
│   ├── Book.elm
│   ├── Contact.elm
│   ├── Index.elm
│   ├── Post.elm
│   └── Posts.elm
|
├── Postgres
│   ├── About.elm
│   ├── Book.elm
│   ├── Contact.elm
│   ├── Post.elm
│   └── Posts.elm
|
├── About.elm
├── Contact.elm
├── Index.elm
├── Post.elm
├── Posts.elm
└── Projects.elm

Correspondingly, under Posts I have:

Posts
├── Elm
│   ├── 2018-01-23-decoding-json-to-nested-record-fields-in-elm.md
│   ├── 2018-03-15-how-to-read-elm-types-like-html-msg.md
|   └── ...
└── Postgres
|   ├── 2013-11-11-installing-postgis-with-homebrew.md
|   ├── 2014-11-18-visualising-postgis-data-from-shell.md
|   └── ...
├── 2009-12-06-add-case-insensitive-finders-by-extending-activerecord.md
├── 2010-03-04-delete-expired-activerecord-based-sessions-regularly.md
└── ...

Each subsection must have Post.elm and Posts.elm, everything else is optional. In my case, I set up different "About" pages at the root level and in each subsection (mainly because I wanted to have different headers in each subscection), so I have /about, /elm/about and /postgres/about.

Most of the special handling is to do with posts. /posts/elm only lists posts from the Posts/Elm directory, and /posts/postgres only lists posts from the Posts/Elm directory. However, /posts aggregates all of the posts into a single list, meaning it lists posts from the root level as well as posts from each subsection.

Further, /posts is generated using Pages/Posts.elm, and root level posts are generated using Pages/Post.elm. Each subsection has its own templates, so, for example, /posts/elm is generated using Pages/Elm/Posts.elm, and the posts under the Elm subsections are generated using Pages/Elm/Post.elm.

Each section is treated as an additional tag for posts in that section, so Elmstatic also generates /tags/elm and /tags/postgres pages.

Directory structure of the output

This structure is modelled on the output of Jekyll. For the scaffold described above, the output structure will look like this:

|- about
  |- index.html
|- css
  |- defaults.css
|- img
  |- logo.png
|- posts
  |- 2018-01-01-using-elmstatic
    |- index.html
  |- index.html
|- tags
  |- personal
    |- index.html
  |- software
    |- index.html
|- index.html
|- rss.xml

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