Single Page Application Boilerplate for Elm

Lucamug
9 min readJan 18, 2018

--

Demo Code

Installation of the PWA on Android Mobile

Yet another boilerplate for a Single Page Applications (SPA) with Elm.

In its latest implementation using style-elements, this app is now HTML/CSS/Javascript free.

Characteristics

There are three versions of the code

  1. Implemented with elm-webpack-starter
  2. Implemented with create-elm-app and Sass
  3. Implemented with create-elm-app and Stylish Elephant

Performances

This is the Lighthouse report for the boilerplate deployed using netlify.com.

This is an overview of the generated Living Style Guide

Automatically generated Living Style Guide

Usage

If you don’t have Elm yet:

$ npm install -g elm

If you don’t have create-elm-app yet:

$ npm install -g create-elm-app

then

$ git clone https://github.com/lucamug/elm-spa-boilerplate2 myproject
$ cd myproject
$ rm -rf .git # To remove the boilerplate repo, on Windows:
# rmdir .git /s /q
$ elm-app start # To start the local server

Then access http://localhost:3000/, and everything should work.

Open the code, in src/, and poke around!

To build the production version:

$ elm-app build

The production version is built in build/

Deploy the app in Surge

Github doesn’t support SPA yet. If you want to check your app in production you can use Surge.

If you don’t have Surge yet

$ npm install -g surge

then, from myproject folder

$ elm-app build
$ mv build/index.html build/200.html
$ surge build myproject.surge.sh

Other useful Surge commands

$ surge list
$ surge teardown myproject.surge.sh

To create a new repository

From myproject foder:

$ git init
$ git add .
$ git commit -m 'first commit'

Characteristics

Use of push state navigation

The app use Navigation.newUrl that add a new entry in the browser history using pushState every time the user browse to a new page.

Push state and forward slash are the recommended practice also by Google for SEO. See [SPA and SEO: Google (Googlebot) properly renders Single Page Application and execute Ajax calls] for more insight about it.

To run this boilerplate we need server that support push state navigation. elm-live support it (just add —-pushstate as parameter) but this instance is implemented with Webpack.

We use the option historyApiFallback to tell Webpack to load index.html when page is not found. This allow us to load addresses such as http://localhost:8080/page2/subpage1 and have the app pointing to the index.html in the root folder instead having the server answering with 404 type or error.

To deploy the app we also need to find a server that support push state. Git hub pages don’t support it yet. We are more lucky with Surge that has an optional 200.html. It is like a 404.html page in a sense that is used in case there is a page not found error, but the server answer with state 200 instead of 404.

So we can rename index.html to 200.html and deploy to Surge. To do so we need first to install Surge

$ npm install — global surge

Then

$ npm run build
$ cd dist
$ mv index.html 200.html
$ surge

The app should be now up and running in Surge, at a subdomain that Surge chose randomly. If you want to set up your subdomain use this command instead:

$ surge — domain you-subdomain.surge.sh

These other useful commands:

$ surge list
$ surge teardown elm-spa-boilerplate.surge.sh

List of pages in the configuration

In Main.elm there is a configuration section that allow for easy adding and removing of pages. The configurations is

routeData : Route -> RouteData
routeData route =
case route of
Top ->
{ name = "Intro"
, path = []
, view = viewTop
}
Page1 ->
{ name = "Page one"
, path = [ "page1" ]
, view = Pages.Page1.view
}
Page2 ->
{ name = "Page two"
, path = [ "page2" ]
, view = viewPage2
}
...

To add a new page is enough to add a new entry here and also to modify these two lists

routes : List Route
routes =
[ Top
, Page1
, Page2
, Page2_1
, Styleguide
, Sitemap
]
type Route
= Top
| Page1
| Page2
| Page2_1
| Styleguide
| Sitemap
| NotFound

The app will take care of everything else. This system support until two level of nested path, as you can note from this function that create the matchers for the Url parser:

matchers : UrlParser.Parser (Route -> a) a
matchers =
UrlParser.oneOf
(List.map
(\route ->
let
listPath =
routePath route
in
if List.length listPath == 0 then
UrlParser.map route UrlParser.top
else if List.length listPath == 1 then
UrlParser.map route (UrlParser.s <| firstElement
listPath)
else if List.length listPath == 2 then
UrlParser.map route
((UrlParser.s <| firstElement listPath)
</> (UrlParser.s <| secondElement
listPath)
)
else
UrlParser.map route UrlParser.top
)
routes
)

Implementation of localStorage

Elm doesn’t yet support natively localStorage but it is trivial to implement it using ports. We define two ports, one for Elm → Javascript communication and one for Javascript → Elm communication.

port storeLocalStorage : Maybe String -> Cmd msgport onLocalStorageChange : (Decode.Value -> msg) -> Sub msg

Let’s add onLocalStorageChange to our subscription so that the messages SetLocalStorage will be sent to our update function when the localStorage is changing from the Javascript side.

subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Sub.map SetLocalStorage <| onLocalStorageChange
(Decode.decodeValue Decode.string)
]

The update function will simply save the content of the localStorage into the model with

SetLocalStorage result ->
case result of
Ok value ->
( { model | localStorage = value }, Cmd.none )
Err value ->
( model, Cmd.none )

When instead the changes of the localStorage are happening from the Elm side, we need to tell Javascript so that it will actually store the value.

Again is the update function that has the logic implemented:

UpdateLocalStorage value ->
( { model | localStorage = value }
, storeLocalStorage <| Just value
)

So now, for example, if we want to have an input field that save in the local storage, we just need to add in our view:

input [ onInput UpdateLocalStorage ] []

This is all from the Elm side.

From the Javascript side we need to implement these two ports:

elmSpa.ports.storeLocalStorage.subscribe(function(value) {
localStorage.setItem("spa", value);
});
window.addEventListener("storage", function(event) {
if (event.storageArea === localStorage && event.key === "spa") {
elmSpa.ports.onLocalStorageChange.send(event.newValue);
}
}, false);

The first part subscribe to the port storeLocalStorage so that when Elm want to store a value we execute the command localStorage.setItem.

The second part subscribe to the event storage so that when the localStorage changes from the Javascript side, for example by the app open in another tab, we send the new value to Elm using onLocalStorageChange.send command.

Example of Ajax request

A trivial example of Ajax request is also implemented in the boilerplate.

It is querying the test service https://httpbin.org/delay/1 that replay after one second with some test data. We implemented it using three tiers:

type ApiData
= NoData
| Fetching
| Fetched String

Let’s follow the history of the http request. Let’ start with a button:

Components.Button.component
[ onClick <| FetchApiData “https://httpbin.org/delay/1" ]
“My IP is…” Components.Button.Large_Important

Let’s ignore the Components.Button.component for the moment. Let’s consider the same as the usual Html.button.

FetchApiData will change the status to Fetching and will tell the Elm process to start the http request and send the message NewApiData once an answer is coming back:

FetchApiData url ->
( { model | apiData = Fetching }
, Http.send NewApiData (Http.get url apiDecoder)
)

NewApiData is:

NewApiData result ->
case result of
Ok data ->
( { model | apiData = Fetched data.origin }, Cmd.none )
Err data ->
( { model | apiData = NoData }, Cmd.none )

The status now goes from Fetching to Fetched or from Fetching to NoData.

The cycle is finished.

To be fancy, we can display a spinner on the button while the app is waiting for an answer and disable it. Something like

case model.apiData of
Fetching ->
Components.Button.component [] "My IP is..."
Components.Button.Large_Important_With_Spinner
_ ->
Components.Button.component [ onClick <| FetchApiData
"https://httpbin.org/delay/1" ] "My IP is..."
Components.Button.Large_Important

Update of the Title and Meta Description

Elm, with its virtual DOM, doesn’t have control of the <head> part of the page. But for SEO it would be better to have this area to reflect the content of the page. We can achieve this with the use of one port:

port urlChange : String -> Cmd msg

We send data through the port whenever there is a page change:

UrlChange location ->
let
newRoute =
locationToRoute location
newTitle =
routeName newRoute ++ " - " ++ model.title
in
( { model | route = newRoute, location = location }
, urlChange newTitle
)

In the Javascript we retrieve this data and update the head of the page:

elmSpa.ports.urlChange.subscribe(function(title) {
window.requestAnimationFrame(function() {
document.title = title;
document.querySelector(
'meta[name="description"]'
).setAttribute("content", title);
});
});

Googlebot can now happily process our page.

Webpack environment

This boilerplate is built on top of https://github.com/halfzebra/create-elm-app. Check it for more information.

There is also a previous version of the boilerplate built with https://github.com/elm-community/elm-webpack-starter, you can find it at https://github.com/lucamug/elm-spa-boilerplate

Experimental Built-in Living Style Guide generator

What if the app could automatically generate Living Style Guide? We can try to define components with standard API so that a simple process can then generate these guidelines.

For this topic please check the post Zero-maintenance Always-up-to-date Living Style Guide in Elm!

Sitemap

Having the pages listed in the code, is possible to automatically generate a list of pages: http://elm-spa-boilerplate.surge.sh/sitemap

This list, converted to a text file, could be used to instruct Googlebot about the structure of the website.

Transition between pages

The transition between pages in SPA is immediate and it may not clear to users that a page is actually changed or not.

We can add a simple way to create a fading effect to reinforce the concept that the pages has been updated.

We can leverage the already existing port urlChange and when an event is coming through that port we fade-out and fade-in the body toggling for a short period of time a the class urlChange in the <body> element.

elmSpa.ports.urlChange.subscribe(function(title) {
document.body.classList.add("urlChange");
setTimeout(function() {
document.body.classList.remove("urlChange");
}, 100)
});

Then in Sass:

body
transition: opacity 0.2s
&.urlChange
opacity: .5
transition: opacity 0s

So now page will change with a quick white flash.

Progressive Web App

Using halfzebra/create-elm-app tool is the trivial to create a PWA. Just edit the public/manifest.json and our app can be installed on mobile and used offline. This is manifest.json:

{
"short_name": "Elm Spa Boilerplate",
"name": "A boilerplate to easily create Spa in Elm",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png"
}
],
"start_url": "./",
"display": "standalone",
"theme_color": "#f0ad00",
"background_color": "#f0ad00"
}
Installation of the PWA on Android Mobile

As we can see from the animation above, the large image at the top was not cached. create-elm-app create a build/service-worker.js file that contain

var precacheConfig = [
["/index.html", "a99a1c99da143d6c9d7166258a260293"],
["/static/css/main.39f47230.css", "39f47230462fcd2768fc4b20..."],
["/static/js/main.5e3b7425.js", "f0e6d125b02e56922c370d285c..."]
]

To add the image among the cacheable file is enough to tell webpack to import it with (in index.js)

import bannerSrc from “./Images/skyline.jpg”

Webpack is generating an unique hash that is attached to the image. To get this new file name from Elm we can recycle the already existing port:

const elmSpa = Main.fullscreen({
localStorage: (localStorage.getItem("spa") || ""),
packVersion: pack.version,
packElmVersion: packElm.version,
bannerSrc: bannerSrc,
width: window.innerWidth,
height: window.innerHeight
});

This is all…

We semantically moved away from HTML/CSS/Javascript. This is the result of Count Lines Of Code in src folder

$ cloc src > loc.txt-------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------
Elm 9 374 46 1274
JavaScript 2 15 27 107
-------------------------------------------------------------------
SUM: 11 389 73 1381
-------------------------------------------------------------------

Well, we still have few lines of Javascript and also some CSS here and there.

Find the code at https://github.com/lucamug/elm-spa-boilerplate

Thank you for reading.

--

--

Responses (3)