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
- Use of
pushState
navigation using forward slash “/” as path separator - List of pages in the configuration, easy to add or remove them
- Implementation of localStorage through ports
- Example of Ajax request
- Update of the Title and Meta Description of the page for Search Engine Optimisation (SEO)
- Webpack environment based on
halfzebra/create-elm-app
(Note that a previous version was based elm-community/elm-webpack-starter on but I moved to create-elm-app thank to a recommendation of Ricardo García Vega) - Experimental Built-in Living Style Guide generator base on stateless components. Read more here.
- Automatically generated Sitemap
- Transition between pages
- Progressive Web App
- Styles implemented using
mdgriffith/stylish-elephants
, new version ofmdgriffith/style-elements
(it is in alpha state, not ready for production)
There are three versions of the code
- Implemented with elm-webpack-starter
- Implemented with create-elm-app and Sass
- 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
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"
}
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.