Merge branch 'next' into main

This commit is contained in:
Ryan Haskell-Glatz 2021-04-27 02:16:14 -05:00
commit 5210dab5d3
276 changed files with 24207 additions and 10455 deletions

View File

@ -1,20 +0,0 @@
---
name: New Example
about: Create an example for the official site!
title: 'Featured Example: [name]'
labels: examples
assignees: ryannhg
---
### Name
### Short Description (Less than 10 words)
### Github Username
### Screenshot (will be 360px wide)
### Demo URL
### Source Code URL

View File

@ -1,28 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run test:ci

4
.gitignore vendored
View File

@ -1,8 +1,8 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist
Generated
elm-spa-*.tgz
# Local Netlify folder
.netlify

View File

@ -1,10 +0,0 @@
elm-stuff
/src
/tests
/elm.json
/examples
!.gitignore
.github
.netlify
*.tgz
/dist/elm.js

View File

@ -1,12 +1,40 @@
# elm-spa
# [![elm-spa](https://v6.elm-spa.dev/images/rounded-logo-bg.png)](https://elm-spa.dev)
![Build](https://github.com/ryannhg/elm-spa/workflows/Build/badge.svg?branch=master)
# **Installation**
## single page apps made easy
```
```bash
npm install -g elm-spa@latest
```
Learn more at [the offical guide](https://elm-spa.dev/guide)!
# **Quick start**
## **1. Create a new project**
```bash
npx elm-spa new
```
## **2. Check out the new files**
```bash
your-new-project/
- elm.json
- src/Pages/Home_.elm
- public/index.html
```
## **3. Run it in your browser**
```bash
npx elm-spa server # Ready at http://localhost:1234
```
# **Learn more**
__Visit the official site__ at [elm-spa.dev](https://elm-spa.dev) for more examples, guides, and other documentation.
### **Do I need the Elm package?**
If you are using elm-spa, there's no need to read the [ryannhg/elm-spa](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest/) package documentation. The package only exists to constrain the CLI, and provides a few basic internal helper functions.
Check out [the official website](https://elm-spa.dev) instead!

31
docs/README.md Normal file
View File

@ -0,0 +1,31 @@
# elm-spa.dev
> 🌳 built with [elm-spa](https://elm-spa.dev)
![Screenshot of the site](./public/images/screenshot.png)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm-spa
```
## running locally
```bash
elm-spa server # starts this app at http:/localhost:1234
```
### other commands
```bash
elm-spa add # add a new page to the application
elm-spa build # one-time production build
elm-spa watch # builds code as you go (without the server)
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

View File

@ -1,34 +1,34 @@
{
"type": "application",
"source-directories": [
"src"
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"dillonkearns/elm-markdown": "5.1.1",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0",
"elm-explorations/markdown": "1.0.0"
"elm/url": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/parser": "1.1.0",
"elm/regex": "1.0.0",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
"elm/virtual-dom": "1.0.2",
"rtfeldman/elm-hex": "1.0.0"
}
},
"test-dependencies": {
"direct": {
"avh4/elm-program-test": "3.2.0",
"elm-explorations/test": "1.2.2"
},
"indirect": {
"avh4/elm-fifo": "1.0.4",
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/random": "1.0.0"
}
"direct": {},
"indirect": {}
}
}

15
docs/netlify.toml Normal file
View File

@ -0,0 +1,15 @@
[build]
publish = "public"
command = "cd ../src/cli && npm i && npm run build && npm link && npm i -g elm && cd ../../docs && node scripts/generate-index.js && elm-spa build"
# Prevents missing markdown files from redirecting to index.html
[[redirects]]
from = "/content/*"
to = "/content/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -0,0 +1,61 @@
# Examples
Prefer to learn by example? Wonderful! The source code for all of the examples on this site can be found in the GitHub repo's [examples](https://github.com/ryannhg/elm-spa/tree/main/examples) folder.
### Hello, world!
Get an introduction to the framework with a simple app.
[![Example 1 screenshot](/content/images/01-hello-world.png)](/examples/01-hello-world)
### Pages
Learn how pages and URL routing work together.
[![Example 2 screenshot](/content/images/02-pages.png)](/examples/02-pages)
### Local storage
Use ports and local storage to persist data on refresh.
[![Example 3 screenshot](/content/images/03-storage.png)](/examples/03-storage)
### User authentication
Explore the elm-spa's user authentication API.
[![Example 4 screenshot](/content/images/04-authentication.png)](/examples/04-authentication)
## Real world examples
### RealWorld Conduit App
Implements the [RealWorld app](https://github.com/gothinkster/realworld), inspired by Richard Feldman's "elm-spa-example" project.
[![Realworld app screenshot](/content/images/realworld.png)](https://realworld.elm-spa.dev)
Source code: [GitHub](https://github.com/ryannhg/elm-spa-realworld)
### This website
The website you are looking at _right now_ was built with __elm-spa__. Mindbending, right?
[![Realworld app screenshot](/content/images/this-site.png)](https://elm-spa.dev)
Source code: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/docs)
## More examples
There are more examples available on the official repo:
__[Working with NPM 🔗](https://github.com/ryannhg/elm-spa/tree/main/examples/05-vite)__
Use [Vite](https://vitejs.dev/) instead of the default __elm-spa server__ command. This gives you access to NPM, reading environment variables, and fancier JS ecosystem stuff.
__[Testing 🔗](https://github.com/ryannhg/elm-spa/tree/main/examples/06-testing)__
Use [elm-test](https://github.com/elm-explorations/test) and [elm-program-test](https://elm-program-test.netlify.app/) to write unit and end-to-end tests for your single page application.
__[Using Elm UI 🔗](https://github.com/ryannhg/elm-spa/tree/main/examples/07-elm-ui)__
Use the wonderful [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest) package to create web UIs without the need for HTML or CSS. This example can also be applied to [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest/) or any other custom UI of your choice.

View File

@ -0,0 +1,139 @@
# Hello, world!
__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/01-hello-world)
Welcome to __elm-spa__! This guide is a breakdown of the simplest project you can make: the "Hello, world!" example.
### Installation
In case you are starting from scratch, you can install __elm-spa__ via NPM:
```terminal
npm install -g elm-spa@latest
```
### Creating a project
This will allow you to create a new project using the following commands:
```terminal
elm-spa new
```
When we ran `elm-spa new`, only __three__ files were created:
- __public/index.html__ - the entrypoint for our web app.
- __src/Pages/Home\_.elm__ - the homepage.
- __elm.json__ - our project dependencies.
### Running the server
With only these files, we can get an application up-and-running:
```terminal
elm-spa server
```
This runs a server at [http://localhost:1234](http://localhost:1234). If everything worked, you should see this in your browser:
![A page that reads "Hello World"](/content/images/01-hello-world.png)
### The entrypoint
Earlier, I mentioned that `public/index.html` was the "entrypoint" to our web app. Let's take a look at that file:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>
```
This HTML file defines some standard tags, and then runs our Elm application. Because our Elm compiles to JavaScript, the `elm-spa server` command generates a `/dist/elm.js` file anytime we make changes.
Once we import that with a `<script>` tag, we can call `Elm.Main.init()` to startup our Elm application.
### The homepage
Next, let's look at `src/Pages/Home_.elm`:
```elm
module Pages.Home_ exposing (view)
import Html
import View exposing (View)
view : View msg
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
```
This `view` function has two parts:
- `title` - the tab title
- `body` - the HTML we render, with [elm/html](https://package.elm-lang.org/packages/elm/html/latest/)
Try changing `"Hello, world!"` to something else it should replace what you see in the browser.
The `elm-spa server` you ran is designed to __automatically refresh__ when your Elm code changes, but you _may_ need to refresh manually to see the change.
### The dependencies
The `elm.json` tracks all our project dependencies. Elm packages are available at [package.elm-lang.org](https://package.elm-lang.org/). Here's our initial file:
```js
{
"type": "application",
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated"
],
"elm-version": "0.19.1",
"dependencies": { /* ... */ },
"test-dependencies": { /* ... */ }
}
```
Normally, `source-directories` in Elm projects only contain the `"src"` folder, but __elm-spa__ projects automatically generate code and provide some default files.
When we start getting into more advanced guides, we can move files from `.elm-spa/defaults` into our `src` folder. That will track them in git, and let us make changes.
The files in `.elm-spa/generated` should not be changed, so they are stored in a separate folder. Feel free to browse these if you are curious, they are just normal Elm code.
### The .gitignore
By default, a `.gitignore` file is generated to promote best practices when working with __elm-spa__ and your git repo:
```
.elm-spa
elm-stuff
dist
```
Notice that the `.elm-spa` folder is __ignored from git__. You shouldn't push any generated __elm-spa__ code to your repo. Instead, use commands like `elm-spa build` to reliably regenerate these files during deployments.
```terminal
elm-spa build
```
This command will also minify your `/dist/elm.js` file so it's production ready.
---
__Next up:__ [Pages](./02-pages)

View File

@ -0,0 +1,295 @@
# Pages & routing
__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/02-pages)
This next guide will show you how pages, routing, and the `elm-spa add` command work together to automatically handle URLs in your __elm-spa__ application.
### The setup
Just like with the last guide, we can use `elm-spa new` and `elm-spa server` to get a brand new __elm-spa__ project up and running:
```terminal
elm-spa new
```
This generates the "Hello, world!" homepage from before:
```terminal
elm-spa server
```
![A browser displaying "Hello world"](/content/images/01-hello-world.png)
### Adding a static page
```terminal
elm-spa add /static static
```
This command adds a page at [http://localhost:1234/static](http://localhost:1234/static) with the `static` template. This is similar to `Home_.elm`, but it has access to `Shared.Model` and `Request` in case we need data from either of those.
Here is the complete `Static.elm` file:
```elm
module Pages.Static exposing (page)
import Page exposing (Page)
import Request exposing (Request)
import Shared
import View exposing (View)
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view
}
view : View msg
view =
View.placeholder "Static"
```
The `View.placeholder` function just stubs out the `view` function with an empty page that only renders "Static" in the browser.
Visit [http://localhost:1234/static](http://localhost:1234/static) to see it in action!
### Making a layout
Before we continue, I want to make a layout with a navbar so that we can easily navigate between pages without manually editing the URL.
I'll create a file at `src/UI.elm` that looks like this:
```elm
module UI exposing (layout)
import Html exposing (Html)
import Html.Attributes as Attr
layout : List (Html msg) -> List (Html msg)
layout children =
let
viewLink : String -> String -> Html msg
viewLink label url =
Html.a [ Attr.href url ] [ Html.text label ]
in
[ Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ viewLink "Home" "/"
, viewLink "Static" "/static"
]
, Html.main_ [] children
]
]
```
### Using the layout in a page
Because it works from one `List (Html msg)` to another, we can add `UI.layout` in front of the `body` list on both pages:
```elm
-- src/Pages/Home_.elm
view : View msg
view =
{ title = "Homepage"
, body = UI.layout [ Html.text "Homepage" ]
}
```
```elm
-- src/Pages/Static.elm
view : View msg
view =
{ title = "Static"
, body = UI.layout [ Html.text "Static" ]
}
```
### Use routes, not strings
In `src/UI.elm`, we had a function for rendering our navbar links that looked like this:
```elm
viewLink : String -> String -> Html msg
viewLink label url =
Html.a [ Attr.href url ] [ Html.text label ]
```
This function works great but it's possible to provide a URL that our application doesn't have!
```elm
[ viewLink "Home" "/"
, viewLink "Static" "/satic"
]
```
Here, I mistyped the URL `/satic`, but the compiler didn't warn me about it! Let's use the `Route` values generated by __elm-spa__ to improve this experience:
```elm
import Gen.Route as Route exposing (Route)
viewLink : String -> Route -> Html msg
viewLink label route =
Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ]
```
By using the `Gen.Route` module from `.elm-spa/generated`, we can pass in a `Route` instead of a `String`:
```elm
[ viewLink "Home" Route.Home_
, viewLink "Static" Route.Static
]
```
This will prevent typos, but __more importantly__ it allows the Elm compiler to remind us to update the navbar in case we remove `Home_.elm` or `Static.elm` in the future.
Deleting either of those pages changes the generated `Gen.Route` module, so the compiler can let us know that our `UI.layout` function has a broken link before our users do!
### Adding CSS
In `UI.layout`, we used `Attr.class` to provide our HTML with some CSS classes:
```elm
Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ viewLink "Home" Route.Home_
, viewLink "Static" Route.Static
]
]
```
The `container` and `navbar` classes are used in our code, but not defined in a CSS file. Let's fix that by creating a new CSS file at `public/style.css`:
```css
.container {
max-width: 960px;
margin: 1rem auto;
}
.navbar {
display: flex;
align-items: center;
}
.navbar a {
margin-right: 16px;
}
```
After creating `style.css`, we can import the file in our `public/index.html` entrypoint:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- import our new CSS file -->
<link rel="stylesheet" href="/style.css">
</head>
<body>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>
```
Using the `<link>` tag as shown above (with the leading slash!) imports our CSS file. All files in the `public` folder are available at the root of our web application. That means a file stored at `public/images/dog.png` would be at `http://localhost:1234/images/dog`, without including `public` in the URL at all.
### Adding more page types
```terminal
elm-spa add /sandbox sandbox
elm-spa add /element element
elm-spa add /advanced advanced
```
These commands add in the other three page types described in the [pages guide](/guides/03-pages).
For each page, the `View.placeholder` function stubs out the `view` functions so you can visit them in the browser.
For example, [http://localhost:1234/element](http://localhost:1234/element) should display "Element" on the screen.
### Adding some dynamic routes
To add in dynamic routes, we can use `elm-spa add` again:
```terminal
elm-spa add /dynamic/:name static
```
With this command, we just created a page at `src/Pages/Dynamic/Name_.elm`. When a user visits a URL like `/dynamic/ryan` or `dynamic/123`, we'll be taken to this page.
Let's tweak the default `view` function to render the dynamic `name` parameter from the URL:
```elm
-- src/Pages/Dynamic/Name_.elm
view : Params -> View msg
view params =
{ title = "Dynamic: " ++ params.name
, body =
UI.layout
[ UI.h1 "Dynamic Page"
, Html.h2 [] [ Html.text params.name ]
]
}
```
We can provide in the `req.params` to the `view` function by telling our `page` function to pass it along:
```elm
page : Shared.Model -> Request.With Params -> Page
page _ req =
Page.static -- 👇 we pass in params here
{ view = view req.params
}
```
### Updating the navbar
Once we wire up these pages to use `UI.layout`, we can add links to the navbar:
```elm
-- src/UI.elm
import Gen.Route as Route
layout : List (Html msg) -> List (Html msg)
layout children =
let
viewLink : String -> Route -> Html msg
viewLink label route =
Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ]
in
[ Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ Html.strong [ Attr.class "brand" ] [ viewLink "Home" Route.Home_ ]
, viewLink "Static" Route.Static
, viewLink "Sandbox" Route.Sandbox
, viewLink "Element" Route.Element
, viewLink "Advanced" Route.Advanced
, Html.div [ Attr.class "splitter" ] []
, viewLink "Dynamic: Apple" (Route.Dynamic__Name_ { name = "apple" })
, viewLink "Dynamic: Banana" (Route.Dynamic__Name_ { name = "banana" })
]
, Html.main_ [] children
]
]
```
#### That's it!
Feel free to play around with the `elm-spa add` command to mix-and-match different pages.
As always, the source code for this example is available on [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/02-pages)
---
__Next up:__ [Storage](./03-storage)

View File

@ -0,0 +1,394 @@
# Storage
__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/03-storage)
Let's start by creating a new project with the __elm-spa__ CLI:
```terminal
elm-spa new
```
## Creating a stateful page
Let's create a simple interactive app, based on the official Elm [counter example](https://elm-lang.org/examples/buttons). The `elm-spa add` command will make this a breeze:
```terminal
elm-spa add / sandbox
```
This will stub out the `init`, `update`, and `view` function for us, and wire them together with `Page.static` like this:
```elm
-- src/Pages/Home_.elm
page : Shared.Model -> Request -> Page.With Model Msg
page =
Page.static
{ init = init
, update = update
, view = view
}
```
Let's add in the implementation from the counter example to get a working app!
### init
```elm
-- src/Pages/Home_.elm
type alias Model =
{ counter : Int
}
init : Model
init =
{ counter = 0
}
```
### update
```elm
-- src/Pages/Home_.elm
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | counter = model.counter + 1 }
Decrement ->
{ model | counter = model.counter - 1 }
```
### view
```elm
-- src/Pages/Home_.elm
view : Model -> View Msg
view model =
{ title = "Homepage"
, body =
[ Html.h1 [] [ Html.text "Local storage" ]
, Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ]
, Html.p [] [ Html.text ("Count: " ++ String.fromInt model.counter) ]
, Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ]
]
}
```
After these functions are in place, we can spin up our server with the __elm-spa__ CLI:
```terminal
elm-spa server
```
And this is what we should see at [http://localhost:1234](http://localhost:1234):
![counter app](/content/images/03-storage.png)
### Playing with the counter
As we click the "+" and "-" buttons, the counter value should be working great. When we __refresh__ the page, the counter value is 0 again.
Let's use local storage to keep the counter value around!
## The JS side
To do this, we'll be using [flags and ports](https://guide.elm-lang.org/interop/ports.html), a typesafe way to work with JavaScript without causing runtime errors in our Elm application!
Let's edit `public/index.html` as a starting point:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<script src="/dist/elm.js"></script>
<!-- EDIT THIS LINE -->
<script src="/main.js"></script>
</body>
</html>
```
Here we replace the inline `Elm.Main.init()` script generated by the `elm-spa new` command with a reference to a new file we'll create in `public/main.js`
```js
// public/main.js
const app = Elm.Main.init()
// ...
```
At this point, nothing has changed yet, but we now have access to `app` which will allow us to interact with our Elm app from the JS file!
Let's add in some ports like this:
```js
// public/main.js
const app = Elm.Main.init({
flags: JSON.parse(localStorage.getItem('storage'))
})
app.ports.save.subscribe(storage => {
localStorage.setItem('storage', JSON.stringify(storage))
app.ports.load.send(storage)
})
```
This JS code is doing a few things:
1. __When our Elm app starts up,__ we pass in the current value of `localStorage` via flags. Initially, this will pass in `null`, because no data has been stored yet.
2. We subscribe to the `save` port for events __from Elm__, which we'll wire up on the Elm side shortly.
3. When Elm sends a `save` event, we'll store the data in localStorage (making it ready for the next time the app starts up!) as well as send a message back to Elm via the `load` port.
## The Elm side
None of this code is working yet, because we need to define these `save` and `load` ports on the Elm side too!
Let's create a new file at `src/Storage.elm` that defines the ports referenced on the JS side:
```elm
port module Storage exposing (..)
import Json.Decode as Json
port save : Json.Value -> Cmd msg
port load : (Json.Value -> msg) -> Sub msg
```
Above, we've created a `port module` that defines our `save` and `load` ports. Next, we'll describe the data we want to store, as well as how to convert it to and from JSON:
```elm
port module Storage exposing (..)
import Json.Encode as Encode
-- ... port definitions from before ...
type alias Storage =
{ counter : Int
}
-- Converting to JSON
toJson : Storage -> Json.Value
toJson storage =
Encode.object
[ ( "counter", Encode.int storage.counter )
]
-- Converting from JSON
fromJson : Json.Value -> Storage
fromJson value =
value
|> Json.decodeValue decoder
|> Result.withDefault initial
decoder : Json.Decoder Storage
decoder =
Json.map Storage
(Json.field "counter" Json.int)
initial : Storage
initial =
{ counter = 0
}
```
If this decoder stuff is new to you, please check out the [JSON section of the Elm guide](https://guide.elm-lang.org/effects/json.html). It will lay a solid foundation for understanding decoders and encode functions!
### Sending data to JS
For this example, we're going to define `increment` and `decrement` as side-effects because they change the state of the world. We'll be using the `save` port to send these events to JS:
```elm
-- src/Storage.elm
increment : Storage -> Cmd msg
increment storage =
{ storage | counter = storage.counter + 1 }
|> toJson
|> save
decrement : Storage -> Cmd msg
decrement storage =
{ storage | counter = storage.counter - 1 }
|> toJson
|> save
```
This should look pretty similar to how our homepage handled the `Increment` and `Decrement` messages, but this time we use `toJson` and `save` to send an event for JS to handle.
( As a last step, we'll revisit `Home_.elm` and swap out the old behavior with the new )
### Listening for data from JS
We're going to add one final function to `Storage.elm` that will allow us to subscribe to events from the `load` port, that use's our `fromJson` function to safely parse the message we get back:
```elm
onChange : (Storage -> msg) -> Sub msg
onChange fromStorage =
load (\json -> fromJson json |> fromStorage)
```
Here, the `onChange` function will allow the outside world to handle the `load` event without having to deal with raw JSON values by hand.
That's it for this file- now we're ready to use our `Storage` module in our app!
### Wiring up the shared state
Let's eject `Shared.elm` by moving it from `.elm-spa/defaults` into our `src` folder. This will allow us to make local changes to it, as explained in the [shared state section](/guides/05-shared-state) of the guide.
Our first step is to add `Storage` to our `Shared.Model`, so we can access `storage` from _any_ page in our application:
```elm
-- src/Shared.elm
import Storage
type alias Model =
{ storage : Storage
}
```
The `Shared.init` function is the __only place__ we have access to `Flags`, which is how JS passed in our initial value earlier. We can use `Storage.fromJson` to convert that raw JSON into our nice `Storage` type.
```elm
-- src/Shared.elm
init : Request -> Flags -> ( Model, Cmd Msg )
init _ flags =
( { storage = Storage.fromJson flags }
, Cmd.none
)
```
Now let's listen for those `load` events from JS, so we can update the `Shared.Model` as soon as we get them. This code will use the `Storage.onChange` function we made to send a `Shared.Msg` to our `Shared.update` function:
```elm
-- src/Shared.elm
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Storage.onChange StorageUpdated
type Msg
= StorageUpdated Storage
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update _ msg model =
case msg of
StorageUpdated storage ->
( { model | storage = storage }
, Cmd.none
)
```
That's all for `src/Shared.elm`. The last step is to upgrade our homepage to send side-effects instead of changing the data locally.
### Upgrading Home_.elm
To gain access to `Cmd msg`, we'll start by using `Page.element` instead of `Page.static`. The signature of our `init` and `update` functions will need to change to handle the new capabilities:
Our `Model` no longer needs to track the state of the application. This means the `Home_.init` function won't be doing much at all:
```elm
-- src/Pages/Home_.elm
type alias Model =
{}
init : ( Model, Cmd Msg )
init =
( {}, Cmd.none )
```
This time around, the `update` function will need access to the current `Storage` value and use `Storage.increment` and `Storage.decrement` to send commands to the JS side.
```elm
-- src/Pages/Home_.elm
type Msg
= Increment
| Decrement
update : Storage -> Msg -> Model -> ( Model, Cmd Msg )
update storage msg model =
case msg of
Increment ->
( model
, Storage.increment storage
)
Decrement ->
( model
, Storage.decrement storage
)
```
When the `load` event comes in from JS, it triggers our `Storage.onChange` subscription. This updates the `storage` for us, meaning the `storage.counter` we get in our `view` will be the latest counter value.
```elm
-- src/Pages/Home_.elm
view : Storage -> Model -> View Msg
view storage _ =
{ title = "Homepage"
, body =
[ Html.h1 [] [ Html.text "Local storage" ]
, Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ]
, Html.p [] [ Html.text ("Count: " ++ String.fromInt storage.counter) ]
, Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ]
]
}
```
We can use `Page.element` to wire all these things up, and even pass `Storage` into our `view` and `update` functions, which depend on the current value to do their thing:
```elm
-- src/Pages/Home_.elm
page : Shared.Model -> Request -> Page.With Model Msg
page shared _ =
Page.element
{ init = init
, update = update shared.storage
, view = view shared.storage
, subscriptions = \_ -> Sub.none
}
```
> Here, I've stubbed out `subscriptions` with an inline function, we won't be needing it, because Shared.subscriptions listens to `Storage.onChange` for us.
#### Hooray!
In the browser, we now have a working counter app that persists on refresh. Even if you close the browser and open it up again, you'll see your previous counter value on the screen.
As a reminder, all the source code for this example is available on [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/03-storage)
---
__Next up:__ [User Authentication](./04-authentication)

View File

@ -0,0 +1,407 @@
# User authentication
__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/04-authentication)
In a real world application, it's common to have the notion of a signed-in users. When it comes to routing, it's often useful to only allow signed-in users to visit specific pages.
It would be wonderful if we could define logic in _one place_ that guarantees only signed-in users could view those pages:
```elm
case currentVisitor of
SignedIn user ->
ProvidePageWith user
NotSignedIn ->
RedirectTo Route.SignIn
```
__Great news:__ This is exactly what we can do in __elm-spa__!
## Protected pages
At the end of the [pages docs](/guide/03-pages#pageprotected), we learned that there are also `protected` versions of every __page type__.
These protected pages have slightly different signatures:
```elm
Page.sandbox :
{ init : Model
, update : Msg -> Model -> Model
, view : Model -> View Msg
}
Page.protected.sandbox :
User ->
{ init : Model
, update : Msg -> Model -> Model
, view : Model -> View Msg
}
```
Protected pages are __guaranteed__ to have access to a `User`, so you don't need to handle the impossible case where you are viewing a page without one.
## Following along
Feel free to follow along by creating a new __elm-spa__ project:
```terminal
npm install -g elm-spa@latest
```
```terminal
elm-spa new
```
This will create a new project that you can run with the `elm-spa server` command!
### Ejecting Auth.elm
There's a default file that has this code stubbed out for you in the `.elm-spa/defaults` folder. Let's eject that file into our `src` folder so we can edit it:
```elm
.elm-spa/
|- defaults/
|- Auth.elm
-- move into
src/
|- Auth.elm
```
Now that we have `Auth.elm` in our `src` folder, we can start adding the code that makes __elm-spa__ protect certain pages.
The `Auth.elm` file only needs to expose two things:
- __User__ - The type that we want to provide all protected pages.
- __beforeProtectedInit__ - The logic that runs before any `Page.protected.*` page loads
```elm
module Auth exposing (User, beforeProtectedInit)
import Gen.Route
import ElmSpa.Internals as ElmSpa
import Request exposing (Request)
import Shared
type alias User =
()
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
ElmSpa.RedirectTo Gen.Route.NotFound
```
By default, this code redirects all protected pages to the `NotFound` page. Instead we want something like this:
```elm
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
case shared.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn
```
But before that code will work we need to take care of two things:
1. Updating Shared.elm
2. Adding a sign in page
## Updating Shared.elm
If you haven't already ejected `Shared.elm`, you should move it from `.elm-spa/defaults` into your `src` folder. The [shared state](/guide/shared-state) docs cover this file in depth, but we'll provide all the code you'll need to change here.
Let's change `Shared.Model` to keep track of a `Maybe User`, the value that can either be a user or nothing:
```elm
-- src/Shared.elm
type alias Model =
{ user : Maybe User
}
type alias User =
{ name : String
}
```
> For now, a user is just going to have a `name` field. This might also store an `email`, `profilePictureUrl`, or `token` too.
Next, we should initially set our user to `Nothing` when our Elm application starts up:
```elm
-- src/Shared.elm
init : Request -> Flags -> ( Model, Cmd Msg )
init _ _ =
( { user = Nothing }
, Cmd.none
)
```
To make sure that `Auth.elm` is using the same type, __let's expose__ the `User` type from our `Shared` module and reuse it:
```elm
-- src/Shared.elm
module Shared exposing ( ..., User )
```
```elm
-- src/Auth.elm
type alias User =
Shared.User
```
As the final update to `Shared`, lets add some sign in/sign out logic
```elm
module Shared exposing ( ..., Msg(..))
import Gen.Route
-- ...
type Msg
= SignIn User
| SignOut
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
case msg of
SignIn user ->
( { model | user = Just user }
, Request.pushRoute Gen.Route.Home_ req
)
SignOut ->
( { model | user = Nothing }
, Cmd.none
)
```
> Make sure that you expose `Msg(..)` as shown above (instead of just `Msg`). This allows `SignIn` and `SignOut` to be available to pages that send shared updates.
Great work! Let's use that `SignIn` message on a new sign in page.
## Adding a sign in page
With __elm-spa__, adding a new page from the terminal is easy:
```terminal
elm-spa add /sign-in advanced
```
Here we'll start with an "advanced" page, because we'll need to send `Shared.Msg` to sign in and sign out users.
Let's add a few lines of code to `src/Pages/SignIn.elm`:
```elm
-- Import some HTML
import Html exposing (..)
import Html.Events as Events
```
```elm
-- Replace Msg with this
type Msg = ClickedSignIn
```
```elm
-- Replace update with this
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
ClickedSignIn ->
( model
, Effect.fromShared (Shared.SignIn "Ryan")
)
```
```elm
-- Make view show a sign out button
view : Model -> Html msg
view model =
{ title = "Sign In"
, body =
[ button
[ Events.onClick ClickedSignIn ]
[ text "Sign in" ]
]
```
Nice work- we're only a step away from getting auth set up!
### Final touches to Auth.elm
Now that we have a `shared.user` and a `SignIn` route, let's bring it all together in the `Auth.elm` file
```elm
-- src/Auth.elm
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
case shared.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn
```
Now visiting [http://localhost:1234/sign-in](http://localhost:1234/sign-in) will show us our sign in page, complete with a sign in button!
Clicking the "Sign in" button signs in the user when clicked. Because of the logic we added in `Shared.elm`, this also redirects the user to the homepage after sign in!
## Protecting our homepage
Let's make it so the homepage is only available to signed in users.
Let's create a fresh homepage with the __elm-spa add__:
```terminal
elm-spa add / advanced
```
Now that `Auth.elm` is set up, we only need to change the `page` function to guarantee signed-in users are viewing the homepage:
```elm
-- src/Pages/Home_.elm
Page.advanced
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
-- this becomes
Page.protected.advanced
(\user ->
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
)
```
If you want to pass a `User` into any of these functions, you can do it like this:
```elm
-- Only the view is passed a user
Page.protected.advanced
(\user ->
{ init = init
, update = update
, view = view user
, subscriptions = subscriptions
}
)
view : User -> Model -> View Msg
view user model =
...
```
Let's use that `user` so the homepage greets them by name:
```elm
-- src/Pages/Home_.elm
import Html exposing (..)
import Html.Events as Events
-- ...
view : User -> Model -> View Msg
view user model =
{ title = "Homepage"
, body =
[ h1 [] [ text ("Hello, " ++ user.name ++ "!") ]
]
}
```
#### Try it out!
Now if we visit [http://localhost:1234](http://localhost:1234), we will immediately be redirected to `/sign-in`, because we haven't signed in yet!
Clicking the "Sign in" button takes us back to the homepage, and we should see "Hello, Ryan!" printed on the screen.
### The cherry on top
Let's wrap things up by wiring up a "Sign out" button to the homepage:
```elm
-- src/Pages/Home_.elm
type Msg = ClickedSignOut
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
ClickedSignOut ->
( model
, Effect.fromShared Shared.SignOut
)
-- ...
view : User -> Model -> View Msg
view user model =
{ title = "Homepage"
, body =
[ h1 [] [ text ("Hello, " ++ user.name ++ "!") ]
, button
[ Events.onClick ClickedSignOut ]
[ text "Sign out" ]
]
}
```
Now everything is working! Visiting the `/sign-in` page and clicking "Sign In" signs in the user and redirects to the homepage. Clicking "Sign out" on the homepage signs out the user, and our `Auth.elm` logic automatically redirects to the `SignIn` page.
## Persisting the user
When we refresh the page, the user is signed out... how can we keep them signed in after refresh? Let's tweak the `Storage.elm` file we made in the [last example](./03-storage):
```elm
-- src/Storage.elm
type alias Storage =
{ user : Maybe User
}
```
If we store this on the `Shared.Model` we can ensure the user is still signed in after they refresh their browser, or visit the app later.
```elm
-- src/Shared.elm
type alias Model =
{ storage : Storage
}
```
For more explanation of how this works, check out the [Storage example](./03-storage) in the last section, it will give a better basic understanding of how this mechanism works!
---
__Next up:__ [More examples](/examples#more-examples)

View File

@ -0,0 +1,73 @@
# Guide
Welcome to __elm-spa__, a framework for building web applications with [Elm](https://elm-lang.org)!
If you are new to Elm, you should check out [the official guide](https://guide.elm-lang.org), which
is a great introduction to the language.
The goal of this guide is to help you solve any problems you might run into when building real world single-page web applications.
## Features
Here are some of the benefits for using __elm-spa__:
1. __Automatic routing__ - routes for your web app are automatically generated based on file names. No need to maintain URL routing logic or wire pages together manually.
1. __User authentication__ - provides an easy way to guarantee certain pages are only visible to signed-in users. You can check out the [user authentication](/examples/04-authentication) example for more details!
1. __Zero configuration__ - Includes a hot-reloading dev server, build tool, and everything you need in one CLI tool! No need for webpack, uglify, or other NPM packages.
## Quickstart
If you already have [NodeJS](https://nodejs.org) installed, getting started with __elm-spa__ is easy:
```terminal
npx elm-spa new
```
This will create a new project in the current folder. Even better: this command only creates __three__ files:
```bash
elm.json # project dependencies
src/Pages/Home_.elm # our homepage
public/index.html # entrypoint to your application
```
Let's use __elm-spa__ to spin up a dev server:
```terminal
npx elm-spa server
```
If you see "Hello, world!" at [http://localhost:1234](http://localhost:1234), you did it!
## Installation
So far, we've been using [npx](https://www.npmjs.com/package/npx) so we can run __elm-spa__ directly from the command line. If you'd like to run commands from the terminal without the `npx` prefix, you can install __elm-spa__ like this:
```terminal
npm install -g elm-spa@latest
```
To verify the install succeeded, run `elm-spa help` from your terminal:
```terminal
elm-spa help
elm-spa version 6.0.0
Commands:
elm-spa new . . . . . . . . . create a new project
elm-spa add <url> . . . . . . . . create a new page
elm-spa build . . . . . . one-time production build
elm-spa server . . . . . . start a live dev server
Other commands:
elm-spa gen . . . . generates code without elm make
elm-spa watch . . . . runs elm-spa gen as you code
Visit https://elm-spa.dev for more!
```
That output means you can run the `elm-spa` CLI without needing `npx`
---
__Next up:__ [The CLI](/guide/01-cli)

View File

@ -0,0 +1,127 @@
# The CLI
To install the __elm-spa__ CLI via [npm](https://npmjs.org) run this command:
```terminal
npm install -g elm-spa@latest
```
This CLI gives us these six commands:
1. [__elm-spa new__](#elm-spa-new) - creates a new project
1. [__elm-spa server__](#elm-spa-server) - runs a dev server as you code
1. [__elm-spa add__](#elm-spa-add) - adds a page to an existing project
1. [__elm-spa build__](#elm-spa-build) - one-time production build
1. [__elm-spa gen__](#elm-spa-gen) - generates files, without elm make
1. [__elm-spa watch__](#elm-spa-watch) - generates files as you code
What do these do? Let's dive into each in detail!
## elm-spa new
When you want to create a new project, use the `elm-spa new` command. This creates a new project in the current folder:
```terminal
elm-spa new
```
```bash
New project created in:
/Users/ryan/code/my-new-app
```
The `new` command creates __three__ files:
Filename | Description
--- | ---
`elm.json` | Keeps track of [Elm packages](https://package.elm-lang.org).
`src/Pages/Home_.elm` | The project's homepage.
`public/index.html` | The HTML entrypoint to the app.
## elm-spa server
The first thing you'll want to do after creating a new project is see it in the browser! The `elm-spa server` command is all you need to see the app in action:
```terminal
elm-spa server
```
This command starts a development server for your project at [http://localhost:1234](http://localhost:1234).
> When you edit your code, `elm-spa server` automatically compiles your application.
## elm-spa add
You can add new pages to your app with the `elm-spa add` command:
```terminal
elm-spa add /contact
```
This creates a new file at `src/Pages/Contact.elm`. If you visit [http://localhost:1234/contact](http://localhost:1234/contact) in the browser, you'll see a new page with the text `"Contact"` displayed.
### adding other pages
Here are a few examples of other routes you can create with the add command
```bash
elm-spa add / # src/Pages/Home_.elm
elm-spa add /settings # src/Pages/Settings.elm
elm-spa add /people/:id # src/Pages/People/Id_.elm
```
We'll cover this in more detail in the [routing section](./02-routing)
### using page templates
The `elm-spa add` command also accepts an optional `template` argument too for common pages you might create.
```bash
elm-spa add /example static
elm-spa add /example sandbox
elm-spa add /example element
```
We'll explore those page types in the [pages section](./03-pages)
## elm-spa build
The `elm-spa server` command is great for development, but for __production__, you'll want the `elm-spa build` command.
```terminal
elm-spa build
```
This compiles your app into __an optimized and minified JS file__. This makes it great for serving your application in the real world!
### A note on hosting
By default, the `public` folder can be statically served. Hosting platforms like [Netlify](https://netlify.com) make this free and easy.
Because this is a single page application, be sure to setup redirects to `public/index.html`. Here's an [example of how to do this with Netlify](https://docs.netlify.com/routing/redirects/rewrites-proxies/#history-pushstate-and-single-page-apps).
## elm-spa gen
If you are working with another dev server, you won't need the `.js` file generated by the `build` command. To only generate __elm-spa__ files, use the `elm-spa gen` command:
```terminal
elm-spa gen
```
This will generate code in the `.elm-spa` folder, but allow your custom workflow to define it's own way of compiling Elm. This is a great command to combine __elm-spa__ with another tool like [Vite](/examples/05-npm) or [Parcel](https://parceljs.org/elm.html).
## elm-spa watch
If you want the automatic code generation on change, but don't need elm make or an HTTP server, you can use the `elm-spa watch` command:
```terminal
elm-spa watch
```
This will automatically generate code and compile your Elm files on save, but without the server.
---
__Next up__: [Routing](./02-routing)

View File

@ -0,0 +1,133 @@
# Routing
One of the best features in __elm-spa__ is the automatic routing system! Inspired by popular JS frameworks, the names of your files determine the routes in your application.
Every __elm-spa__ project will have a `src/Pages` folder containing all the pages in your app:
URL | File
--- | ---
`/` | `src/Pages/Home_.elm`
`/people` | `src/Pages/People.elm`
`/people/:name` | `src/Pages/People/Name_.elm`
`/about-us` | `src/Pages/AboutUs.elm`
`/settings/users` | `src/Pages/Settings/Users.elm`
In this section, we'll cover the different kinds of routes you'll find in every __elm-spa__ application.
## The homepage
`Home_.elm` is a reserved filename that handles requests to your homepage. The easiest way to add a new homepage is with the [`elm-spa add`](/guide/01-cli#elm-spa-add) covered in the CLI docs:
```terminal
elm-spa add /
```
> `Home.elm` (without the underscore) is seen as a route to `/home`! To handle requests to the homepage, make sure to include the trailing underscore.
## Static routes
Most pages will be __static routes__, meaning the filepath will translate to a single URL.
```terminal
elm-spa add /people
```
This command creates a file called `People.elm` that will be shown when the user visits `/people` in your application.
These are a few more examples of static routes:
URL | File
--- | ---
`/dashboard` | `src/Pages/Dashboard.elm`
`/people` | `src/Pages/People.elm`
`/about-us` | `src/Pages/AboutUs.elm`
`/settings/users` | `src/Pages/Settings/Users.elm`
### Capitalization matters
Notice how the filename `AboutUs.elm` translated to `/about-us`?
If we named the file `Aboutus.elm` (with a lowercase "u"), then we'd have a path to `/aboutus` (without the dash between words).
> In __elm-spa__, we use "kebab-case" rather than "snake_case" as the convention for separating words.
### Nested static routes
You can even have __nested static routes__ within folders:
```terminal
elm-spa add /settings/users
```
This example creates a file at `Settings/Users.elm`, which will handle all requests to `/settings/user`. This pattern continues, supporting nesting things multiple levels deep:
```terminal
elm-spa add /settings/user/contact
```
## Dynamic routes
Sometimes a 1:1 mapping is what you need, but other times, its useful to have a single page that will handles requests to similar URL structures.
A common example is providing a different ID for a blog post, user, or another item in a collection.
```terminal
elm-spa add /people/:name
```
This creates a file at `People/Name_.elm`. In __elm-spa__, we call this a __dynamic route__. It handles requests to any URLs that match `/people/____` and provides the dynamic bit in the `req.params` value passed into your page!
URL | `req.params`
--- | ---
`/people/ryan` | `{ name = "ryan" }`
`/people/alexa` | `{ name = "alexa" }`
`/people/erik` | `{ name = "erik" }`
> The __underscore__ at the end of the filename (`Name_.elm`) indicates that this route is __dynamic__. Without the underscore, it would only handle requests to a single URL: `/people/name`
The name of the `req.params` variable (`name` in this example) is determined by the name of the file! If we named the file `Id_.elm` instead, the dynamic value would be at `req.params.id`.
### Nested dynamic routes
Just like we saw earlier with nested static routes, you can use nested folders to create __nested dynamic routes__!
```terminal
elm-spa add /users/:name/posts/:id
```
This creates a file at `src/Users/Name_/Posts/Id_.elm`, which handles any request that matches `/users/___/posts/___`:
URL | `req.params`
--- | ---
`/users/ryan/posts/123` | `{ name = "ryan", id = "123" }`
`/users/alexa/posts/456` | `{ name = "alexa", id = "456" }`
`/users/erik/posts/789` | `{ name = "erik", id = "789" }`
## Not found page
If a user visits a URL that doesn't have a corresponding page, it will redirect to the `NotFound.elm` page.
By default, the not found page is generated for you in the `.elm-spa/defaults/Pages` folder. When you are ready to customize your 404 page, move it from the defaults folder into `src/Pages`:
```elm
.elm-spa/
|- defaults/
|- Pages/
|- NotFound.elm
-- move into
src/
|- Pages/
|- NotFound.elm
```
Once you have a `NotFound.elm` within your `src/Pages` folder, __elm-spa__ will stop generating the other one, and use your custom 404 file instead.
The technique of moving a file from the `.elm-spa/defaults` folder is known as "ejecting a default file". Throughout the guide, we'll find more examples of files that we might want to move into our `src` folder.
---
__Next up:__ [Pages](./03-pages)

View File

@ -0,0 +1,257 @@
# Pages
In __elm-spa__, every URL connects to a single page. Let's take a closer look at the homepage created with the `elm-spa new` command:
```elm
module Pages.Home_ exposing (view)
import Html
import View exposing (View)
view : View msg
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
```
This homepage renders __"Homepage"__ in the browser tab, and __"Hello, world!"__ onto the page.
Because the file is named `Home_.elm`, elm-spa knows this is the homepage. Visiting `http://localhost:1234` in a web browser will render this view.
A `view` function is perfect when all you need is to render some HTML on the screen. But many web pages in the real world do more interesting things!
### Upgrading "Hello, world!"
Let's start by adding a new `page` function:
```elm
module Pages.Home_ exposing (page)
import Html
import Page exposing (Page)
import Request exposing (Request)
import Shared
import View exposing (View)
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view
}
view : View msg
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
```
We haven't changed the original code much- except we've added a new `page` function that:
1. Accepts 2 inputs: `Shared.Model` and `Request`
2. Returns a `Page` value
3. Has been __exposed__ at the top of the file.
> Exposing `page` from this module lets __elm-spa__ know we want to use `page` instead of the plain `view` function from before.
The `view` function from before is now passed into `page`. In the web browser, we still see __"Hello, world!"__. However, this page now has access to two new bits of information!
1. `Shared.Model` is our global application state, which might contain the signed-in user, settings, or other things that should persist as we move from one page to another.
2. `Request` is a record with access to the current route, query parameters, and any other information about the current URL.
You can rely on the fact that the `page` will always be passed the latest `Shared.Model` and `Request` value. If we want either of these values to be available in our `view` function, we pass them in like so:
```elm
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view req -- passing in req here!
}
```
Now our `view` function can read the current `URL` value:
```elm
view : Request -> View msg
view req =
{ title = "Homepage"
, body =
[ Html.text ("Hello, " ++ req.url.host ++ "!")
]
}
```
Now the browser should display __"Hello, localhost!"__
### Beyond static pages
You might have noticed `Page.static` earlier in our page function. This is one of the built in __page types__ that is built-in to __elm-spa__.
The rest of this section will introduce you to the other __page types__ exposed by the `Page` module, so you know which one to reach for.
> Always choose the __simplest__ page type for the job and reach for the more advanced ones when your page _really_ needs the extra features!
- __[Page.static](#pagestatic)__ - for pages that only render a view.
- __[Page.sandbox](#pagesandbox)__ - for pages that need to keep track of state.
- __[Page.element](#pageelement)__ - for pages that send HTTP requests or continually listen for events from the browser or user.
- __[Page.advanced](#pageadvanced)__ - for pages that need to sign in a user or work with other details that should persist between page navigation.
## Page.static
```terminal
elm-spa add /example static
```
This was the page type we looked at above. It is perfect for pages that render static HTML, but might need access to the `Shared.Model` or `Request` values.
```elm
module Pages.Example exposing (page)
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view
}
view : View msg
```
## Page.sandbox
```terminal
elm-spa add /example sandbox
```
This is the first __page type__ that introduces [the Elm architecture](https://guide.elm-lang.org/architecture/), which uses `Model` to store the current page state and `Msg` to define what actions users can take on this page.
It's time to upgrade to `Page.sandbox` when you __need to track state__ on the page. Here are a few examples of things you'd store in page state:
- The current slide of a carousel
- The selected tab section to view
- The open / close state of a modal
All these examples require us to be able to __initialize__ a `Model`, __update__ it based on `Msg` values sent from the __view__.
If you are new to the Elm architecture, be sure to visit [guide.elm-lang.org](https://guide.elm-lang.org/architecture/). We'll be using it for all the upcoming page types!
```elm
module Pages.Example exposing (Model, Msg, page)
page : Shared.Model -> Request -> Page.With Model Msg
page shared req =
Page.sandbox
{ init = init
, update = update
, view = view
}
init : Model
update : Msg -> Model -> Model
view : Model -> View Msg
```
> Our `page` function now returns `Page.With Model Msg` instead of `Page`. This is because our page is now stateful.
_( Inspired by [__Browser.sandbox__](https://package.elm-lang.org/packages/elm/browser/latest/Browser#sandbox) )_
## Page.element
```terminal
elm-spa add /example element
```
When you are ready to send __HTTP requests__ or __subscribe to events__ like keyboard presses, mouse move, or incoming data from JS upgrade to `Page.element`.
This is the same as `Page.sandbox`, but introduces `Cmd Msg` and `Sub Msg` to handle side effects.
```elm
module Pages.Example exposing (Model, Msg, page)
page : Shared.Model -> Request -> Page.With Model Msg
page shared req =
Page.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
init : ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Cmd Msg )
view : Model -> View Msg
subscriptions : Model -> Sub Msg
```
_( Inspired by [__Browser.element__](https://package.elm-lang.org/packages/elm/browser/latest/Browser#element) )_
## Page.advanced
```terminal
elm-spa add /example advanced
```
For many applications, `Page.element` is all you need to store a `Model`, handle `Msg` values, and work with side-effects.
Some Elm users prefer sending global updates directly from their pages, so we've included this `Page.advanced` page type.
Using a custom `Effect` module, users are able to send `Cmd Msg` value via `Effect.fromCmd` or `Shared.Msg` values with `Effect.fromSharedMsg`.
```elm
module Pages.Example exposing (Model, Msg, page)
page : Shared.Model -> Request -> Page.With Model Msg
page shared req =
Page.advanced
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
init : ( Model, Effect Msg )
update : Msg -> Model -> ( Model, Effect Msg )
view : Model -> View Msg
subscriptions : Model -> Sub Msg
```
This `Effect Msg` value also allows support for folks using [elm-program-test](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/), which requires users to define their own custom type on top of `Cmd Msg`. More about that in the [testing guide](/examples/06-testing)
## Page.protected
Each of those four __page types__ also have a __protected__ version. This means pages are guaranteed to receive a `User` or redirect if no user is signed in.
```elm
-- not protected
Page.sandbox
{ init : Model
, update : Msg -> Model -> Model
, view : Model -> View Msg
}
-- protected
Page.protected.sandbox
{ init : User -> Model
, update : User -> Msg -> Model -> Model
, view : User -> Model -> View Msg
}
```
When you are ready for user authentication, you can learn more about using `Page.protected` in the [authentication guide](/examples/04-authentication).
---
__Next up:__ [Requests](./04-requests)

View File

@ -0,0 +1,120 @@
# Requests
Every page in your application gets access to a `Request` value, containing details about the current URL.
```elm
page : Shared.Model -> Request -> Page
page shared req =
...
```
This might be useful when you need to show the active link in your navbar, or navigate to a page programmatically. Let's look at the properties on `req` that you might find useful!
## req.params
Every [dynamic route](/guide/02-routing#dynamic-routes) has parameters that you'll want to get access to. For [static routes](/guide/02-routing#static-routes), those parameters will be `()`:
URL | Request
--- | ---
`/` | `Request`
`/about-us` | `Request`
`/people/:name` | `Request.With { name : String }`
`/posts/:post/users/:user` | `Request.With { post : String, user : String }`
Here's an example for a file at `People/Name_.elm`:
URL | `req.params`
--- | ---
`/people/alexa` | `{ name = "alexa" }`
`/people/erik` | `{ name = "erik" }`
`/people/ryan` | `{ name = "ryan" }`
## req.query
For convenience, query parameters are automatically turned into a `Dict String String`, making it easy to handle common query URL parameters like these:
```
/people?team=design&ascending
```
```elm
Dict.get "team" req.query == Just "design"
Dict.get "ascending" req.query == Just ""
Dict.get "name" req.query == Nothing
```
> If you need ever access to the raw query string, you can with the `req.url.query` value!
## req.route
The `req.route` value has the current `Route`, so you can safely check if you are on a specific page.
All the routes generated by __elm-spa__ are available at `Gen.Route`.
```elm
-- "/"
req.route == Gen.Route.Home_
-- "/about-us"
req.route == Gen.Route.AboutUs
-- "/people/ryan"
req.route == Gen.Route.People.Name_ { name = "ryan" }
```
## req.url
If you need the `port`, `fragment`, or anything else, `req.url` contains the original [elm/url](https://package.elm-lang.org/packages/elm/url/latest/Url) URL value.
```elm
type alias Url =
{ protocol : Protocol
, host : String
, port_ : Maybe Int
, path : String
, query : Maybe String
, fragment : Maybe String
}
```
This is less commonly used than `req.params` and `req.query`, but is useful in specific cases.
## Programmatic Navigation
Most of the time, navigation in Elm is as easy as giving an `href` attribute to an anchor tag:
```elm
link =
a [ href "/guide" ] [ text "Guide" ]
```
With the generated route code, we can even prevent the need for string URLs. This is great for refactoring and catching typos:
```elm
import Gen.Route as Route
link =
a [ href (Route.toHref Route.Guide) ] [ text "Guide" ]
```
Other times, you'll want to do __programmatic navigation__ navigating to another page after some event completes. Maybe you want to __redirect__ to a sign in page, or head to the __dashboard after signing in successfully__.
In that case we store `req.key` in order to use `Request.pushRoute` or `Request.replaceRoute`. Here's a quick example of what that looks like:
```elm
type Msg = SignedIn User
update : Request Params -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
case msg of
SignedIn user ->
( model
, Request.pushRoute Gen.Route.Dashboard req
)
```
When the `SignedIn` message is fired, this code will redirect the user to the `Dashboard` route.
---
__Next up:__ [Shared state](./05-shared-state)

View File

@ -0,0 +1,106 @@
# Shared state
With __elm-spa__, any time we move from one page to another, the `init` function for that new page is called. This means that the state of the previous page you were looking at has been replaced by the new page's state.
So if we sign in a user on the `SignIn` page, we'll need a place to store the user before navigating over to the `Dashboard`.
This is where the `Shared` module comes in the perfect place to store data that every page needs to access!
### Ejecting the default file
By default, an empty `Shared.elm` file is generated for us in `.elm-spa/defaults`. When you are ready to share data between pages move that file from the defaults folder to the `src` folder.
```elm
.elm-spa/
|- defaults/
|- Shared.elm
-- move into
src/
|- Shared.elm
```
Once you've done that, `src/Shared.elm` is under your control and __elm-spa__ will stop generating the old one. Let's dive into the different parts of that file!
## Shared.Flags
The first thing you'll see is a `Flags` type exposed from the top of the file. If we need to load some initial data from Javascript when our Elm app starts up, we can pass that data in as flags.
When you have the need to send in initial JSON data, take a look at [Elm's official guide on JS interop](https://guide.elm-lang.org/interop/).
## Shared.Model
By default, our `Model` is just an empty record:
```elm
type alias Model =
{}
```
If we wanted to store a signed-in user, adding it to the model would make it available to all pages:
```elm
type alias Model =
{ user : Maybe User
}
type alias User =
{ name : String
, email : String
, token : String
}
```
As we saw in the [pages guide](/guide/03-pages), this `Shared.Model` will be passed into every page so we can check if `shared.user` has a value or not!
## Shared.init
```elm
init : Flags -> Request -> ( Model, Cmd Msg )
init flags req =
...
```
The `init` function is called when your application loads for the first time. It takes in two inputs:
- `Flags` - initial JS values passed in on startup.
- `Request` - the [Request](/guide/request) value with current URL information.
The `init` function returns the initial `Shared.Model`, as well as any side effect's you'd like to run (like initial HTTP requests, etc)
## Shared.Msg
Once you become familiar with [the Elm architecture](https://guide.elm-lang.org/architecture/), you'll recognize the `Msg` type as the only way to update `Shared.Model`.
Maybe it looks something like this for our user example
```elm
type Msg
= SignedIn User
| SignedOut
```
These are used in the next section on `Shared.update`!
## Shared.update
```elm
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
```
The `update` function allows you to respond when one of your pages or this module send a `Shared.Msg`. Just like pages, you define a `Msg` type to handle how they update the shared state here.
## Shared.subscriptions
```elm
subscriptions : Request -> Model -> Sub Msg
```
If you want all pages to listen for keyboard events, window resizing, or other external updates, this `subscriptions` function is a great place to wire those up!
It also has access to the current URL request value, so you can conditionally subscribe to events.
---
__Next up:__ [Views](./06-views)

View File

@ -0,0 +1,109 @@
# Views
With __elm-spa__, you can choose any Elm view library you like. Whether it's [elm/html](https://package.elm-lang.org/packages/elm/html/latest/), [Elm UI](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/), or even your own custom library, the `View` module has you covered!
### Ejecting the default view
If you would like to switch to another UI library you can move the `View.elm` file from `.elm-spa/defaults` into your `src` folder:
```elm
.elm-spa/
|- defaults/
|- View.elm
-- move into
src/
|- View.elm
```
From here on out, __elm-spa__ will use your `View` module as the return type for all `view` functions across your pages!
## View msg
By default, a `View` lets you set the tab title as well as render some `Html` in the `body` value.
```elm
type alias View msg =
{ title : String
, body : List (Html msg)
}
```
### Using Elm UI
If you wanted to use Elm UI, a popular HTML/CSS alternative in the community, you would tweak this `View msg` type to not use `Html msg`:
```elm
import Element exposing (Element)
type alias View msg =
{ title : String
, element : Element msg
}
```
## View.toBrowserDocument
Whichever library you use, Elm needs a way to convert it to a `Browser.Document` type. Make sure to provide this function, so __elm-spa__ can convert your UI at the top level.
Here's an example for Elm UI:
```elm
toBrowserDocument : View msg -> Browser.Document msg
toBrowserDocument view =
{ title = view.title
, body =
[ Element.layout [] view.element
]
}
```
## View.map
When connecting pages together, __elm-spa__ needs a way to map from one `View msg` to another. For `elm/html`, this is the `Html.map` function.
But when using a different library, you'll need to specify the `map` function for things to work.
Fortunately, most UI libraries ship with their own! Here's another example with Elm UI:
```elm
map : (a -> b) -> View a -> View b
map fn view =
{ title = view.title
, element = Element.map fn view.element
}
```
## View.empty
When loading between pages, __elm-spa__ also needs a `View.empty` to be specified for your custom `View` type.
For Elm UI, that is just `Element.none`:
```elm
empty : View msg
empty =
{ title = ""
, element = Element.none
}
```
## View.placeholder
The last thing you need to provide is a `View.placeholder`, used by the __elm-spa add__ command to provide a stubbed out `view` function implementation.
Here's an example of a `placeholder` with Elm UI:
```elm
placeholder : String -> View msg
placeholder pageName =
{ title = pageName
, element = Element.text pageName
}
```
---
__Next up:__ [Examples](../examples)

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

BIN
docs/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CCD6DD" d="M34 29.096c-.417-.963-.896-2.008-2-2.008h-1c1.104 0 2-.899 2-2.008V8.008C33 6.899 32.104 6 31 6H5c-1.104 0-2 .899-2 2.008V25.08c0 1.109.896 2.008 2 2.008H4c-1.104 0-1.667 1.004-2 2.008l-2 4.895C0 35.101.896 36 2 36h32c1.104 0 2-.899 2-2.008l-2-4.896z"/><path fill="#9AAAB4" d="M.008 34.075l.006.057.17.692C.5 35.516 1.192 36 2 36h32c1.076 0 1.947-.855 1.992-1.925H.008z"/><path fill="#5DADEC" d="M31 24.075c0 .555-.447 1.004-1 1.004H6c-.552 0-1-.449-1-1.004V9.013c0-.555.448-1.004 1-1.004h24c.553 0 1 .45 1 1.004v15.062z"/><path fill="#AEBBC1" d="M32.906 31.042l-.76-2.175c-.239-.46-.635-.837-1.188-.837H5.11c-.552 0-.906.408-1.156 1.036l-.688 1.977c-.219.596.448 1.004 1 1.004h7.578s.937-.047 1.103-.608c.192-.648.415-1.624.463-1.796.074-.264.388-.531.856-.531h8.578c.5 0 .746.253.811.566.042.204.312 1.141.438 1.782.111.571 1.221.586 1.221.586h6.594c.551 0 1.217-.471.998-1.004z"/><path fill="#9AAAB4" d="M22.375 33.113h-7.781c-.375 0-.538-.343-.484-.675.054-.331.359-1.793.383-1.963.023-.171.274-.375.524-.375h7.015c.297 0 .49.163.55.489.059.327.302 1.641.321 1.941.019.301-.169.583-.528.583z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#AAB8C2" d="M18 3C12.477 3 8 7.477 8 13v10h4V13c0-3.313 2.686-6 6-6s6 2.687 6 6v10h4V13c0-5.523-4.477-10-10-10z"/><path fill="#FFAC33" d="M31 32c0 2.209-1.791 4-4 4H9c-2.209 0-4-1.791-4-4V20c0-2.209 1.791-4 4-4h18c2.209 0 4 1.791 4 4v12z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#292F33" d="M3.651 29.852L29.926 3.576c.391-.391 2.888 2.107 2.497 2.497L6.148 32.349c-.39.391-2.888-2.107-2.497-2.497z"/><path fill="#66757F" d="M30.442 4.051L4.146 30.347l.883.883L31.325 4.934z"/><path fill="#E1E8ED" d="M34.546 2.537l-.412-.412-.671-.671c-.075-.075-.165-.123-.255-.169-.376-.194-.844-.146-1.159.169l-2.102 2.102.495.495.883.883 1.119 1.119 2.102-2.102c.391-.391.391-1.024 0-1.414zM5.029 31.23l-.883-.883-.495-.495-2.209 2.208c-.315.315-.363.783-.169 1.159.046.09.094.18.169.255l.671.671.412.412c.391.391 1.024.391 1.414 0l2.208-2.208-1.118-1.119z"/><path fill="#F5F8FA" d="M31.325 4.934l2.809-2.809-.671-.671c-.075-.075-.165-.123-.255-.169l-2.767 2.767.884.882zM4.146 30.347L1.273 33.22c.046.09.094.18.169.255l.671.671 2.916-2.916-.883-.883z"/><path d="M28.897 14.913l1.542-.571.6-2.2c.079-.29.343-.491.644-.491.3 0 .564.201.643.491l.6 2.2 1.542.571c.262.096.435.346.435.625s-.173.529-.435.625l-1.534.568-.605 2.415c-.074.296-.341.505-.646.505-.306 0-.573-.209-.647-.505l-.605-2.415-1.534-.568c-.262-.096-.435-.346-.435-.625 0-.278.173-.528.435-.625M11.961 5.285l2.61-.966.966-2.61c.16-.433.573-.72 1.035-.72.461 0 .874.287 1.035.72l.966 2.61 2.609.966c.434.161.721.573.721 1.035 0 .462-.287.874-.721 1.035l-2.609.966-.966 2.61c-.161.433-.574.72-1.035.72-.462 0-.875-.287-1.035-.72l-.966-2.61-2.61-.966c-.433-.161-.72-.573-.72-1.035.001-.462.288-.874.72-1.035M24.13 20.772l1.383-.512.512-1.382c.085-.229.304-.381.548-.381.244 0 .463.152.548.381l.512 1.382 1.382.512c.23.085.382.304.382.548 0 .245-.152.463-.382.548l-1.382.512-.512 1.382c-.085.229-.304.381-.548.381-.245 0-.463-.152-.548-.381l-.512-1.382-1.383-.512c-.229-.085-.381-.304-.381-.548 0-.245.152-.463.381-.548" fill="#FFAC33"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
docs/public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

20
docs/public/index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nunito:ital,wght@0,300;0,600;0,800;1,300&family=Nunito+Sans:wght@800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css">
<link rel="stylesheet" href="https://nope.rhg.dev/dist/3.0.0/core.min.css">
<link rel="stylesheet" href="/vendor/prism.css">
<link rel="stylesheet" href="/style.css">
<link rel="shortcut icon" href="/favicon.png" type="image/x-png">
</head>
<body>
<script src="/vendor/prism.js" data-manual></script>
<script src="/dist/elm.js"></script>
<script src="/dist/flags.js"></script>
<script src="/main.js"></script>
</body>
</html>

102
docs/public/main.js Normal file
View File

@ -0,0 +1,102 @@
const app = Elm.Main.init({ flags: window.__FLAGS__ })
// Handle smoothly scrolling to links
const scrollToHash = () => {
const BREAKPOINT_XL = 1920
const NAVBAR_HEIGHT_PX = window.innerWidth > BREAKPOINT_XL ? 127 : 102
const element = window.location.hash && document.querySelector(window.location.hash)
if (element) {
// element.scrollIntoView({ behavior: 'smooth' })
window.scroll({ behavior: 'smooth', top: window.pageYOffset + element.getBoundingClientRect().top - NAVBAR_HEIGHT_PX })
} else {
window.scroll({ behavior: 'smooth', top: 0 })
}
}
app.ports.onUrlChange.subscribe(_ => setTimeout(scrollToHash, 400))
setTimeout(scrollToHash, 200)
// Quick search shortcut (/)
window.addEventListener('keypress', (e) => {
if (e.key === '/') {
const el = document.getElementById('quick-search')
if (el && el !== document.activeElement) {
el.focus()
el.select()
e.preventDefault()
}
}
return false
})
// HighlightJS custom element
customElements.define('prism-js', class HighlightJS extends HTMLElement {
constructor() { super() }
connectedCallback() {
const pre = document.createElement('pre')
pre.className = this.language ? `language-${this.language}` : `language-elm`
pre.textContent = this.body
this.appendChild(pre)
window.Prism.highlightElement(pre)
}
})
// Dropdown arrow key support
customElements.define('dropdown-arrow-keys', class DropdownArrowKeys extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
const component = this
const arrows = { ArrowUp: -1, ArrowDown: 1 }
const interactiveChildren = () => component.querySelectorAll('input, a, button')
const onBlur = (e) => window.requestAnimationFrame(_ => {
const active = document.activeElement
const siblings = interactiveChildren()
let foundFocusedSibling = false
e.target.removeEventListener('blur', onBlur)
siblings.forEach(sibling => {
if (sibling === active) {
sibling.addEventListener('blur', onBlur)
foundFocusedSibling = true
}
})
if (foundFocusedSibling === false) {
component.dispatchEvent(new CustomEvent('clearDropdown'))
siblings.forEach(el => el.addEventListener('focus', _ => el.addEventListener('blur', onBlur)))
}
})
interactiveChildren().forEach(el => el.addEventListener('blur', onBlur))
component.addEventListener('keydown', (e) => {
const delta = arrows[e.key]
if (delta) {
e.preventDefault()
const interactive = interactiveChildren()
const count = interactive.length
const active = document.activeElement
if (count < 2) return
interactive.forEach((el, i) => {
if (active == el) {
const next = interactive[(i + delta + count) % count]
next.focus()
}
})
}
})
}
})
window.addEventListener('keyup', (e) => {
const el = document.getElementById('quick-search')
if (e.key === 'Escape' && el === document.activeElement) {
if (el) el.blur()
}
})

588
docs/public/style.css Normal file
View File

@ -0,0 +1,588 @@
:root {
--font--display: 'Nunito Sans', sans-serif;
--font--body: 'Nunito', sans-serif;
--font--monospace: 'Fira Code', monospace;
--weight--light: 300;
--weight--semibold: 600;
--weight--bold: 800;
--color--white: #ffffff;
--color--grey-100: #f0f0f0;
--color--grey-200: #cccccc;
--color--grey-300: #aaaaaa;
--color--grey-500: #696969;
--color--grey-700: #333333;
--color--green: #407742;
--color--green-dark: #264727;
--color--green-light: #d7ead8;
--size--h1: 3em;
--size--h2: 2em;
--size--h3: 1.5em;
--size--h4: 1.2em;
--size--h5: 1.2em;
--size--h6: 0.75em;
--size--paragraph: 1.2em;
--shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
--shadow-dark: 0 0.5em 2em rgb(0, 0, 0, 0.2);
--height--header: 102px;
}
/* Resets */
@media screen and (min-width: 1920px ) {
html { font-size: 20px; }
:root { --height--header: 127px; }
}
body {
background: var(--color--grey-100);
color: var(--color--grey-700);
font-family: var(--font--body);
font-weight: var(--weight--light);
overflow-y: scroll;
padding-top: var(--height--header);
}
* {
outline-color: dodgerblue;
outline-offset: 0.25em;
}
input {
line-height: normal;
-webkit-appearance: none;
}
code {
font-family: var(--font--monospace);
font-variant-ligatures: normal;
}
pre {
line-height: 1.45;
border: solid 1px var(--color--grey-200);
border-radius: 0.25rem;
padding: 1rem;
background-color: white;
}
.aside {
white-space: nowrap;
}
.table-of-contents {
min-width: 14em;
max-width: 14em;
white-space: nowrap;
}
main {
animation: fadeIn 200ms 400ms ease-in forwards;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
hr { border: 0; }
.container { max-width: 64rem; }
/* Typography */
.h1 {
font-family: var(--font--display);
font-weight: var(--weight--bold);
font-size: var(--size--h1);
line-height: 1;
}
.h2 {
font-family: var(--font--display);
font-weight: var(--weight--bold);
font-size: var(--size--h2);
line-height: 1.2;
}
.h3 {
font-family: var(--font--display);
font-weight: var(--weight--bold);
font-size: var(--size--h3);
line-height: 1.2;
}
.h4 {
font-family: var(--font--body);
font-weight: var(--weight--bold);
font-size: var(--size--h4);
line-height: 1.2;
}
.h5 {
font-family: var(--font--body);
font-weight: var(--weight--light);
font-size: var(--size--h5);
line-height: 1.2;
}
.h6 {
font-family: var(--font--body);
font-weight: var(--weight--light);
font-size: var(--size--h6);
line-height: 1.2;
}
.p, li {
font-family: var(--font--body);
font-weight: var(--weight--light);
font-size: var(--size--paragraph);
line-height: 1.45;
}
.faded { opacity: 0.6; }
.markdown {
display: flex;
flex-direction: column;
max-width: 36em;
}
.markdown hr {
margin-top: 2em;
}
.markdown > h1:not(:first-child),
.markdown > h2:not(:first-child) {
padding-top: 2rem;
}
.markdown > h3:not(:first-child),
.markdown > h4:not(:first-child) {
padding-top: 1rem;
}
.markdown > h1:first-child { margin-bottom: 2rem }
.markdown > *:not(:last-child) { margin-bottom: 1.2rem }
.markdown code {
font-size: 0.9em;
}
.markdown table code {
white-space: pre;
line-height: 1.4;
}
.markdown p code,
.markdown li code {
font-size: 0.92em;
color: var(--color--green);
}
.markdown p code::before, .markdown li code::before { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown p code::after, .markdown li code::after { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown blockquote {
padding-left: 1rem;
border-left: solid 4px var(--color--green);
background-color: var(--color--white);
padding: 1rem;
box-shadow: var(--shadow);
color: var(--color--grey-500);
}
.markdown blockquote::before {
content: 'Note:';
display: block;
/* font-size: var(--size--paragraph); */
line-height: 1.4;
padding-bottom: 0.25em;
font-weight: bold;
color: var(--color--grey-700);
font-size: 1.2em;
}
.markdown ul, .markdown ol {
margin-left: 1.5rem;
}
.markdown li {
padding-bottom: 0.75rem;
}
.markdown pre {
position: relative;
border-color: var(--color--grey-700);
box-shadow: inset 0 0.5em 1.5em rgba(0, 0, 0, 0.4);
text-shadow: 0 0.125em 0.5em rgba(0, 0, 0, 0.5);
background-color: var(--color--grey-700);
color: var(--color--grey-100);
padding: 1rem;
font-size: 0.9em;
font-family: var(--font--monospace);
}
.markdown pre.language-terminal {
position: relative;
padding-left: 2.25em;
}
.markdown pre.language-terminal::before {
font-family: var(--font--monospace);
content: '$ ';
position: absolute;
top: 1em;
left: 1em;
pointer-events: none;
user-select: none;
opacity: 0.6;
}
.markdown .table {
display: block;
width: 100%;
overflow-x: auto;
}
.markdown table {
border-radius: 5px;
overflow: hidden;
background-color: var(--color--white);
border-color: var(--color--grey-200);
font-size: 0.85em;
}
.markdown tr {
border-color: var(--color--grey-200);
}
.markdown th,
.markdown td { padding: 0.75em }
.markdown tbody tr:nth-child(2n + 1) {
background-color: var(--color--grey-100);
}
.markdown__link:hover::after {
content: ' 🔗';
display: inline-block;
font-size: 0.5em;
transform: translate(50%, -25%);
}
.markdown a img {
transition: transform 200ms ease-in-out, opacity 200ms ease-in-out;
}
.markdown a img:hover {
transform: scale(1.05);
opacity: 0.75;
}
.bold {
font-weight: var(--weight--bold);
}
/* Colors */
.text-100 { color: var(--color--grey-100) }
.text-500 { color: var(--color--grey-500) }
.text-700 { color: var(--color--grey-700) }
.text-blue { color: var(--color--green) }
.bg-white { background-color: var(--color--white) }
.bg-100 { background-color: var(--color--grey-100) }
.bg-500 { background-color: var(--color--grey-500) }
.bg-700 { background-color: var(--color--grey-700) }
.border-left {
border-left: solid 3px var(--color--grey-200);
}
.border-thin {
border-width: 2px;
}
/* Links & Buttons */
.link, .underline {
border-bottom: solid 2px var(--color--grey-200);
transition: border 200ms ease-in-out;
}
.button {
padding: 0.7em 1.2em;
background: var(--color--white);
border: 1px solid var(--color--grey-300);
color: var(--color--green);
border-radius: 0.25em;
transition: transform 200ms ease-in-out, opacity 200ms ease-in-out;
transform-origin: center;
font-weight: var(--weight--bold);
}
.button:hover {
opacity: 0.8;
transform: translateY(-2px);
}
.dropdown__link {
padding: 1rem;
transition: background-color 200ms ease-in-out;
}
.dropdown__link:focus .underline,
.dropdown__link:hover .underline {
border-color: var(--color--green);
}
.dropdown__link:hover,
.dropdown__link:focus {
background-color: var(--color--green-light);
}
.dropdown__link strong {
color: var(--color--green);
}
.markdown__link:hover {
border-bottom: solid 2px var(--color--grey-200);
}
.link:hover, .link:focus {
border-color: var(--color--green);
}
/* STICKY SCROLLING */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2;
background: var(--color--grey-100);
}
.header__logo {
font-size: 1.5em;
}
@media screen and (max-width: 640px) {
.header__logo { font-size: 1.25em }
}
.page {
background-color: var(--color--grey-100);
position: relative;
z-index: 1;
}
.sticky {
position: sticky;
left: 0;
top: var(--height--header);
}
/* Images & Icons */
.logo {
background-image: url('/images/logo.svg');
background-size: contain;
min-width: 5em;
min-height: 5em;
}
.logo--small {
min-width: 1.5em;
min-height: 1.5em;
}
.logo__text {
white-space: nowrap;
font-weight: var(--weight--bold);
font-family: var(--font--display);
font-size: 1.25em;
}
.hero {
padding: 10em 0;
}
.hero__logo {
font-size: 1.75em;
}
@media screen and (max-width: 640px) {
.hero { padding: 5em 0 }
.hero__logo { font-size: 1em }
}
/* Search */
.search {
display: flex;
position: relative;
}
.search__input {
border-radius: 1.25em;
min-width: 0;
width: 15em;
padding: 0.5em 1em;
padding-right: 3.25em;
padding-left: 2.75em;
border: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.search__icon {
/* display: none; */
position: absolute;
top: 50%;
left: 0em;
transform: translate(1em, -50%);
}
.search__kbd {
position: absolute;
top: 50%;
right: 0em;
transform: translate(-1.5em, -50%);
pointer-events: none;
user-select: none;
padding: 0.5em 0.7em;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
color: var(--color--grey-300);
border-radius: 3px;
font-family: var(--font--monospace);
font-size: 0.75em;
}
.icon {
width: 1.25em;
height: 1.25em;
background-size: contain;
background-position: center;
}
.icon--search {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><ellipse cx="40" cy="40" rx="30" ry="30" fill="none" stroke="rgb(180, 180, 180)" stroke-width="10" /><line x1="66" y1="66" x2="85" y2="85" stroke-linecap="round" stroke="rgb(180, 180, 180)" stroke-width="10" /></svg>');
}
.link__icon {
font-size: 1.25em;
}
.fa-elm {
width: 0.85em;
height: 0.85em;
background-size: contain;
background-position: center;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 323.141 322.95"><g><polygon fill="rgb(51,51,51)" points="161.649,152.782 231.514,82.916 91.783,82.916"/><polygon fill="rgb(51,51,51)" points="8.867,0 79.241,70.375 232.213,70.375 161.838,0"/><rect fill="rgb(51,51,51)" x="192.99" y="107.392" transform="matrix(0.7071 0.7071 -0.7071 0.7071 186.4727 -127.2386)" width="107.676" height="108.167"/><polygon fill="rgb(51,51,51)" points="323.298,143.724 323.298,0 179.573,0"/><polygon fill="rgb(51,51,51)" points="152.781,161.649 0,8.868 0,314.432"/><polygon fill="rgb(51,51,51)" points="255.522,246.655 323.298,314.432 323.298,178.879"/><polygon fill="rgb(51,51,51)" points="161.649,170.517 8.869,323.298 314.43,323.298"/> </g> </svg>');
}
.link__icon.fa-npm:hover {
color: indianred;
}
.link__icon.fa-github:hover {
color: mediumseagreen;
}
.link__icon.fa-elm:hover {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 323.141 322.95"><g><polygon fill="dodgerblue" points="161.649,152.782 231.514,82.916 91.783,82.916"/><polygon fill="dodgerblue" points="8.867,0 79.241,70.375 232.213,70.375 161.838,0"/><rect fill="dodgerblue" x="192.99" y="107.392" transform="matrix(0.7071 0.7071 -0.7071 0.7071 186.4727 -127.2386)" width="107.676" height="108.167"/><polygon fill="dodgerblue" points="323.298,143.724 323.298,0 179.573,0"/><polygon fill="dodgerblue" points="152.781,161.649 0,8.868 0,314.432"/><polygon fill="dodgerblue" points="255.522,246.655 323.298,314.432 323.298,178.879"/><polygon fill="dodgerblue" points="161.649,170.517 8.869,323.298 314.43,323.298"/> </g> </svg>');
}
.shadow { box-shadow: var(--shadow) }
.shadow-dark { box-shadow: var(--shadow-dark);}
.rounded { border-radius: 5px; }
.faint { opacity: 0.6; }
.toc {
padding: 1rem;
opacity: 0.9;
}
/* Homepage */
.home__section-row {
padding: 8em 1em;
}
.home__section:nth-child(2n + 1) {
background: linear-gradient(30deg, var(--color--green-dark), var(--color--green));
color: var(--color--white);
}
.home__section:nth-child(2n + 1) code {
color: var(--color--white);
}
.home__section:nth-child(2n + 1) .h2 {
text-shadow: 0 0 0.5em rgb(0 0 0 / 25%);
}
.home__section pre {
font-size: 1.1em;
max-width: 16em;
padding: 0.85rem 1rem;
}
.home__section code {
font-size: 1.2em;
}
.home__section > .col {
position: relative;
z-index: 1;
}
.home__section-icon {
position: absolute;
top: 50%;
transform: translate(50%, -50%);
right: calc(50% + 18rem);
width: 12em;
z-index: 2;
filter: drop-shadow( 0 0.25rem 0.125rem rgba(0, 0, 0, .3));
}
.home__section:nth-child(2n + 1) .home__section-icon {
right: unset;
left: calc(50% + 18rem);
transform: translate(-50%, -50%);
}
@media screen and (max-width: 800px) {
.home__section-icon {
display: none;
}
.home__section-row {
padding: 4em 1em;
}
}
@media screen and (max-width: 640px) {
.home__section { font-size: 1em }
.margin-override { margin-right: 0 !important; }
}
/* Footer */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
.footer__zone {
padding: 8em 0;
}

5
docs/public/vendor/prism.css vendored Normal file
View File

@ -0,0 +1,5 @@
code[class*=language-elm],pre[class*=language-elm]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-elm]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-elm],pre[class*=language-elm]{background:#2d2d2d}:not(pre)>code[class*=language-elm]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}

8
docs/public/vendor/prism.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
const fs = require('fs').promises
const path = require('path')
const config = {
content: path.join(__dirname, '..', 'public', 'content'),
output: path.join(__dirname, '..', 'public', 'dist')
}
// Terminal color output
const green = ``
const reset = ``
// Recursively lists all files in the given folder
const listContainedFiles = async (folder) => {
let files = []
const items = await fs.readdir(folder)
await Promise.all(items.map(async item => {
const filepath = path.join(folder, item)
const stat = await fs.stat(filepath)
if (stat.isDirectory()) {
const innerFiles = await listContainedFiles(filepath)
files = files.concat(innerFiles)
} else {
files.push(filepath)
}
}))
return files
}
// The entrypoint to my script
const main = () =>
listContainedFiles(config.content)
.then(files =>
Promise.all(files.map(async f => {
const url = f.substring(config.content.length, f.length - '.md'.length)
const content = await fs.readFile(f, { encoding: 'utf-8' })
const headers =
content.split('\n')
.reduce((acc, line) => {
if (line.startsWith('# ')) {
acc[line.substring(2)] = 1
} else if (line.startsWith('## ')) {
acc[line.substring(3)] = 2
}
return acc
}, {})
return { url, headers }
}))
)
.then(json => `window.__FLAGS__ = ${JSON.stringify(json, null, 2)}`)
.then(async contents => {
await fs.mkdir(config.output, { recursive: true })
return fs.writeFile(path.join(config.output, 'flags.js'), contents, { encoding: 'utf-8' })
})
.then(_ => console.info(`\n ${green}${reset} Indexed the content folder\n`))
.catch(console.error)
// Run the program
main()

156
docs/src/Domain/Index.elm Normal file
View File

@ -0,0 +1,156 @@
module Domain.Index exposing
( Index, decoder
, Link, search
, Section, sections
)
{-|
@docs Index, decoder
@docs Link, search
@docs Section, sections
-}
import Dict exposing (Dict)
import Html exposing (Html)
import Json.Decode as Json
import Utils.String
type alias Index =
List IndexedPage
decoder : Json.Decoder Index
decoder =
let
indexedPageDecoder : Json.Decoder IndexedPage
indexedPageDecoder =
Json.map2 IndexedPage
(Json.field "url" Json.string)
(Json.field "headers" (Json.dict Json.int))
in
Json.list indexedPageDecoder
type alias IndexedPage =
{ url : String
, headers : Dict String Int
}
type alias Link =
{ html : Html Never
, label : String
, url : String
, level : Int
}
terms : Index -> List ( String, String, Int )
terms =
List.concatMap
(\page ->
page.headers
|> Dict.toList
|> List.map
(\( header, level ) ->
( header
, page.url
++ (if level == 1 then
""
else
"#" ++ Utils.String.toId header
)
, level
)
)
)
search : String -> Index -> List Link
search query index =
index
|> terms
|> List.map
(\( label, url, level ) ->
{ label = label
, url = url
, level = level
, html = Utils.String.format query label
}
)
|> List.filter (\link -> Utils.String.caseInsensitiveContains query link.label)
-- SECTIONS
type alias Section =
{ header : String
, url : String
, pages : List SectionLink
}
type alias SectionLink =
{ label : String
, url : String
}
sections : Index -> List Section
sections index =
let
sectionOrder =
[ "Guide"
, "Examples"
]
toLabelUrls =
List.filterMap
(\doc ->
doc.headers
|> Dict.filter (\_ level -> level == 1)
|> Dict.toList
|> List.head
|> Maybe.map (Tuple.first >> (\label -> { label = label, url = doc.url }))
)
topLevelLabelUrls : List { label : String, url : String }
topLevelLabelUrls =
let
isOneLevelDeep doc =
List.length (String.split "/" doc.url) == 2
in
index
|> List.filter isOneLevelDeep
|> toLabelUrls
toSection top children =
{ header = top.label
, url = top.url
, pages = children
}
in
topLevelLabelUrls
|> List.map
(\top ->
index
|> List.filter (.url >> (\url -> String.startsWith top.url url && url /= top.url))
|> toLabelUrls
|> List.sortBy .url
|> toSection top
)
|> List.sortBy
(\section ->
sectionOrder
|> List.indexedMap Tuple.pair
|> List.filter (Tuple.second >> (==) section.header)
|> List.map Tuple.first
|> List.head
|> Maybe.withDefault -1
)

150
docs/src/Main.elm Normal file
View File

@ -0,0 +1,150 @@
module Main exposing (main)
import Browser
import Browser.Navigation as Nav exposing (Key)
import Effect
import Gen.Model
import Gen.Pages as Pages
import Gen.Route as Route
import Ports
import Request
import Shared
import Url exposing (Url)
import View
main : Program Shared.Flags Model Msg
main =
Browser.application
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
, onUrlChange = ChangedUrl
, onUrlRequest = ClickedLink
}
-- INIT
type alias Model =
{ url : Url
, key : Key
, shared : Shared.Model
, page : Pages.Model
}
init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( shared, sharedCmd ) =
Shared.init (Request.create () url key) flags
( page, effect ) =
Pages.init (Route.fromUrl url) shared url key
in
( Model url key shared page
, Cmd.batch
[ Cmd.map Shared sharedCmd
, Effect.toCmd ( Shared, Page ) effect
]
)
-- UPDATE
type Msg
= ChangedUrl Url
| ClickedLink Browser.UrlRequest
| Shared Shared.Msg
| Page Pages.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ClickedLink (Browser.Internal url) ->
( model
, Nav.pushUrl model.key (Url.toString url)
)
ClickedLink (Browser.External url) ->
( model
, Nav.load url
)
ChangedUrl url ->
if url.path /= model.url.path then
let
( page, effect ) =
Pages.init (Route.fromUrl url) model.shared url model.key
in
( { model | url = url, page = page }
, Cmd.batch
[ Effect.toCmd ( Shared, Page ) effect
, Ports.onUrlChange ()
]
)
else
( { model | url = url }
, Ports.onUrlChange ()
)
Shared sharedMsg ->
let
( shared, sharedCmd ) =
Shared.update (Request.create () model.url model.key) sharedMsg model.shared
( page, effect ) =
Pages.init (Route.fromUrl model.url) shared model.url model.key
in
if page == Gen.Model.Redirecting_ then
( { model | shared = shared, page = page }
, Cmd.batch
[ Cmd.map Shared sharedCmd
, Effect.toCmd ( Shared, Page ) effect
]
)
else
( { model | shared = shared }
, Cmd.map Shared sharedCmd
)
Page pageMsg ->
let
( page, effect ) =
Pages.update pageMsg model.page model.shared model.url model.key
in
( { model | page = page }
, Effect.toCmd ( Shared, Page ) effect
)
-- VIEW
view : Model -> Browser.Document Msg
view model =
Pages.view model.page model.shared model.url model.key
|> View.map Page
|> View.toBrowserDocument
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Pages.subscriptions model.page model.shared model.url model.key |> Sub.map Page
, Shared.subscriptions (Request.create () model.url model.key) model.shared |> Sub.map Shared
]

View File

@ -0,0 +1,19 @@
module Pages.Examples exposing (Model, Msg, page)
import Page
import Request
import Shared
import UI.Docs
page : Shared.Model -> Request.With params -> Page.With Model Msg
page =
UI.Docs.page
type alias Model =
UI.Docs.Model
type alias Msg =
UI.Docs.Msg

View File

@ -0,0 +1,19 @@
module Pages.Examples.Section_ exposing (Model, Msg, page)
import Page
import Request
import Shared
import UI.Docs
page : Shared.Model -> Request.With params -> Page.With Model Msg
page =
UI.Docs.page
type alias Model =
UI.Docs.Model
type alias Msg =
UI.Docs.Msg

19
docs/src/Pages/Guide.elm Normal file
View File

@ -0,0 +1,19 @@
module Pages.Guide exposing (Model, Msg, page)
import Page
import Request
import Shared
import UI.Docs
page : Shared.Model -> Request.With params -> Page.With Model Msg
page =
UI.Docs.page
type alias Model =
UI.Docs.Model
type alias Msg =
UI.Docs.Msg

View File

@ -0,0 +1,19 @@
module Pages.Guide.Section_ exposing (Model, Msg, page)
import Page
import Request
import Shared
import UI.Docs
page : Shared.Model -> Request.With params -> Page.With Model Msg
page =
UI.Docs.page
type alias Model =
UI.Docs.Model
type alias Msg =
UI.Docs.Msg

108
docs/src/Pages/Home_.elm Normal file
View File

@ -0,0 +1,108 @@
module Pages.Home_ exposing (Model, Msg, page)
import Gen.Params.Home_ exposing (Params)
import Gen.Route exposing (Route)
import Html
import Html.Attributes as Attr
import Page
import Request
import Shared
import UI exposing (Html)
import UI.Layout
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page =
UI.Layout.pageFullWidth
{ view = view
}
type alias Model =
UI.Layout.Model
type alias Msg =
UI.Layout.Msg
view : View Msg
view =
{ title = "elm-spa"
, body =
[ Html.div [ Attr.class "row center-x" ]
[ UI.hero
{ title = "elm-spa"
, description = "single page apps made easy"
}
]
, alternatingMarkdownSections
[ ( "laptop"
, """
## Build reliable applications with Elm
With __elm-spa__, you can create production-ready applications with one command:
```terminal
npx elm-spa new
```
No need to configure webpack, gulp, or any other NPM dev tools. This __zero-configuration__ CLI comes with a live-reloading dev server, production-ready build commands, and even a few scaffolding commands for new and existing applications.
"""
, [ ( "Explore the CLI", Gen.Route.Guide__Section_ { section = "cli" } )
]
)
, ( "magic"
, """
## Automatic routing
With __elm-spa__, routing is automatically generated for you based on a standard file-structure convention. This means you'll be able to navigate any project, making it great for onboarding new hires or collaborating with a team!
"""
, [ ( "Learn how routing works", Gen.Route.Guide__Section_ { section = "routing" } )
]
)
, ( "lock"
, """
## User authentication
The latest release comes with a simple way to setup user authentication. Use the `Page.protected` API to easily guarantee only logged-in users can view certain pages.
"""
, [ ( "See it in action", Gen.Route.Examples__Section_ { section = "04-authentication" } )
]
)
, ( "brain"
, """
## Ready to learn more?
Awesome! Check out the official guide to learn the concepts, or start by looking at a collection of examples.
"""
, [ ( "Read the guide", Gen.Route.Guide )
, ( "View examples", Gen.Route.Examples )
]
)
]
]
}
alternatingMarkdownSections : List ( String, String, List ( String, Route ) ) -> Html msg
alternatingMarkdownSections sections =
let
viewSection i ( emoji, str, buttons ) =
Html.section [ Attr.class "home__section" ]
[ Html.div [ Attr.class "home__section-row container relative row", Attr.classList [ ( "align-right", modBy 2 i == 1 ) ] ]
[ Html.img [ Attr.class "home__section-icon", Attr.src ("/images/icons/" ++ emoji ++ ".svg"), Attr.alt emoji ] []
, Html.div [ Attr.class "col gap-lg" ]
[ UI.markdown { withHeaderLinks = False } str
, Html.div [ Attr.class "row gap-md" ]
(List.map
(\( label, route ) -> Html.a [ Attr.class "button", Attr.href (Gen.Route.toHref route) ] [ Html.text label ])
buttons
)
]
]
]
in
Html.main_ [ Attr.class "col" ]
(List.indexedMap viewSection sections)

View File

@ -0,0 +1,37 @@
module Pages.NotFound exposing (Model, Msg, page)
import Gen.Params.NotFound exposing (Params)
import Page
import Request
import Shared
import UI
import UI.Layout
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page =
UI.Layout.page
{ view = view
}
type alias Model =
UI.Layout.Model
type alias Msg =
UI.Layout.Msg
view : View Msg
view =
{ title = "404 · elm-spa"
, body =
[ UI.hero
{ title = "404"
, description = "that page wasn't found."
}
, UI.markdown { withHeaderLinks = False } "## But that's alright.\n\nThere's always [the homepage](/)!"
]
}

6
docs/src/Ports.elm Normal file
View File

@ -0,0 +1,6 @@
port module Ports exposing (onUrlChange)
import Json.Decode as Json
port onUrlChange : () -> Cmd msg

67
docs/src/Shared.elm Normal file
View File

@ -0,0 +1,67 @@
module Shared exposing
( Flags
, Model
, Msg
, init
, subscriptions
, update
)
import Browser.Navigation exposing (Key)
import Dict exposing (Dict)
import Domain.Index exposing (Index)
import Json.Decode as Json
import Request exposing (Request)
import Url exposing (Url)
type alias Flags =
Json.Value
type alias Model =
{ index : Index
}
type alias Token =
()
type Msg
= NoOp
-- INIT
init : Request -> Flags -> ( Model, Cmd Msg )
init _ flags =
( Model
(flags
|> Json.decodeValue Domain.Index.decoder
|> Result.withDefault []
)
, Cmd.none
)
-- UPDATE
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update request msg model =
case msg of
NoOp ->
( model, Cmd.none )
-- SUBSCRIPTIONS
subscriptions : Request -> Model -> Sub Msg
subscriptions request model =
Sub.none

370
docs/src/UI.elm Normal file
View File

@ -0,0 +1,370 @@
module UI exposing
( Html, none, row, col
, h1, h2, h3, h4, h5, h6, markdown
, pad, padX, padY, align
, link
, logo, icons, iconLink
, gutter, hero
)
{-|
@docs Html, none, el, row, col
@docs h1, h2, h3, h4, h5, h6, markdown
@docs pad, padX, padY, align
@docs link
@docs logo, icons, iconLink
-}
import Html
import Html.Attributes as Attr
import Html.Keyed
import Json.Encode as Json
import Markdown.Block
import Markdown.Html
import Markdown.Parser
import Markdown.Renderer
import UI.Searchbar
import Url exposing (Url)
import Utils.String
import View exposing (View)
type alias Html msg =
Html.Html msg
none : Html msg
none =
Html.text ""
link : { text : String, url : String } -> Html msg
link options =
link_
{ destination = options.url
, title = Nothing
}
[ Html.text options.text
]
link_ : { destination : String, title : Maybe String } -> List (Html msg) -> Html msg
link_ options =
Html.a
([ Attr.class "link", Attr.href options.destination ]
++ (if String.startsWith "http" options.destination then
[ Attr.target "_blank"
]
else
[]
)
)
-- TYPOGRAPHY
h1 : String -> Html msg
h1 str =
Html.h1 [ Attr.class "h1" ] [ Html.text str ]
h2 : String -> Html msg
h2 str =
Html.h2 [ Attr.class "h2" ] [ Html.text str ]
h3 : String -> Html msg
h3 str =
Html.h3 [ Attr.class "h3" ] [ Html.text str ]
h4 : String -> Html msg
h4 str =
Html.h4 [ Attr.class "h4" ] [ Html.text str ]
h5 : String -> Html msg
h5 str =
Html.h5 [ Attr.class "h5" ] [ Html.text str ]
h6 : String -> Html msg
h6 str =
Html.h6 [ Attr.class "h6" ] [ Html.text str ]
paragraphs : List String -> Html msg
paragraphs strs =
strs
|> List.map (Html.text >> List.singleton >> Html.p [ Attr.class "p" ])
|> Html.div [ Attr.class "col gap-md" ]
gutter : Html msg
gutter =
Html.div [ Attr.style "height" "25vh" ] []
markdown : { withHeaderLinks : Bool } -> String -> Html msg
markdown options str =
let
default =
Markdown.Renderer.defaultHtmlRenderer
renderer =
{ default
| heading =
\props ->
let
id : String
id =
Utils.String.toId props.rawText
content : List (Html msg)
content =
contentWith ("#" ++ id)
contentWith : String -> List (Html msg)
contentWith url =
if options.withHeaderLinks then
[ Html.a [ Attr.class "markdown__link", Attr.href url ] props.children ]
else
props.children
in
case props.level of
Markdown.Block.H1 ->
Html.h1 [ Attr.id id, Attr.class "h1" ] (contentWith "")
Markdown.Block.H2 ->
Html.h2 [ Attr.id id, Attr.class "h2" ] content
Markdown.Block.H3 ->
Html.h3 [ Attr.id id, Attr.class "h3" ] content
Markdown.Block.H4 ->
Html.h4 [ Attr.id id, Attr.class "h4" ] content
Markdown.Block.H5 ->
Html.h5 [ Attr.id id, Attr.class "h5" ] content
Markdown.Block.H6 ->
Html.h6 [ Attr.class "h6" ] content
, paragraph = Html.p [ Attr.class "p" ]
, table = \children -> Html.div [ Attr.class "table" ] [ Html.table [] children ]
, link = link_
, codeBlock =
\{ body, language } ->
let
supported =
[ "html", "css", "js", "elm" ]
simplePre =
Html.pre [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.code [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.text body ]
]
in
case language of
Just lang ->
if List.member lang supported then
Html.Keyed.node "div"
[]
[ ( body
, Html.node "prism-js"
[ Attr.property "body" (Json.string body)
, Attr.property "language" (Json.string lang)
]
[]
)
]
else
simplePre
Nothing ->
simplePre
}
in
Markdown.Parser.parse str
|> Result.mapError (\_ -> "Failed to parse.")
|> Result.andThen (Markdown.Renderer.render renderer)
|> Result.withDefault []
|> Html.div [ Attr.class "markdown" ]
-- LAYOUT
row :
{ xs : List (Attribute msg) -> List (Html msg) -> Html msg
, sm : List (Attribute msg) -> List (Html msg) -> Html msg
, md : List (Attribute msg) -> List (Html msg) -> Html msg
, lg : List (Attribute msg) -> List (Html msg) -> Html msg
, xl : List (Attribute msg) -> List (Html msg) -> Html msg
}
row =
{ xs = \attrs -> Html.div (Attr.class "row gap-xs" :: attrs)
, sm = \attrs -> Html.div (Attr.class "row gap-sm" :: attrs)
, md = \attrs -> Html.div (Attr.class "row gap-md" :: attrs)
, lg = \attrs -> Html.div (Attr.class "row gap-lg" :: attrs)
, xl = \attrs -> Html.div (Attr.class "row gap-xl" :: attrs)
}
col :
{ xs : List (Attribute msg) -> List (Html msg) -> Html msg
, sm : List (Attribute msg) -> List (Html msg) -> Html msg
, md : List (Attribute msg) -> List (Html msg) -> Html msg
, lg : List (Attribute msg) -> List (Html msg) -> Html msg
, xl : List (Attribute msg) -> List (Html msg) -> Html msg
}
col =
{ xs = \attrs -> Html.div (Attr.class "col gap-xs" :: attrs)
, sm = \attrs -> Html.div (Attr.class "col gap-sm" :: attrs)
, md = \attrs -> Html.div (Attr.class "col gap-md" :: attrs)
, lg = \attrs -> Html.div (Attr.class "col gap-lg" :: attrs)
, xl = \attrs -> Html.div (Attr.class "col gap-xl" :: attrs)
}
-- ATTRS
type alias Attribute msg =
Html.Attribute msg
pad :
{ xs : Attribute msg
, sm : Attribute msg
, md : Attribute msg
, lg : Attribute msg
, xl : Attribute msg
}
pad =
{ xs = Attr.class "pad-xs"
, sm = Attr.class "pad-sm"
, md = Attr.class "pad-md"
, lg = Attr.class "pad-lg"
, xl = Attr.class "pad-xl"
}
padX :
{ xs : Attribute msg
, sm : Attribute msg
, md : Attribute msg
, lg : Attribute msg
, xl : Attribute msg
}
padX =
{ xs = Attr.class "pad-x-xs"
, sm = Attr.class "pad-x-sm"
, md = Attr.class "pad-x-md"
, lg = Attr.class "pad-x-lg"
, xl = Attr.class "pad-x-xl"
}
padY :
{ xs : Attribute msg
, sm : Attribute msg
, md : Attribute msg
, lg : Attribute msg
, xl : Attribute msg
}
padY =
{ xs = Attr.class "pad-y-xs"
, sm = Attr.class "pad-y-sm"
, md = Attr.class "pad-y-md"
, lg = Attr.class "pad-y-lg"
, xl = Attr.class "pad-y-xl"
}
align :
{ center : Attribute msg
, top : Attribute msg
, left : Attribute msg
, right : Attribute msg
, bottom : Attribute msg
, centerX : Attribute msg
, centerY : Attribute msg
}
align =
{ center = Attr.class "align-center"
, top = Attr.class "align-top"
, left = Attr.class "align-left"
, right = Attr.class "align-right"
, bottom = Attr.class "align-bottom"
, centerX = Attr.class "align-center-x"
, centerY = Attr.class "align-center-y"
}
-- HERO
hero : { title : String, description : String } -> Html msg
hero options =
Html.div [ Attr.class "hero" ]
[ Html.div [ Attr.class "hero__logo row gap-md" ]
[ Html.div [ Attr.class "logo" ] []
, Html.div [ Attr.class "col gap-xs" ]
[ h1 options.title
, Html.div [ Attr.class "text-500" ] [ Html.h2 [ Attr.class "h5" ] [ Html.text options.description ] ]
]
]
]
-- LOGO
logo : Html msg
logo =
Html.div [ Attr.class "row gap-sm" ]
[ Html.div [ Attr.class "logo logo--small" ] []
, Html.div [ Attr.class "logo__text" ] [ Html.text "elm-spa" ]
]
-- ICONS
type Icon
= Icon String
icons :
{ github : Icon
, npm : Icon
, elm : Icon
}
icons =
{ github = Icon "fa-github"
, npm = Icon "fa-npm"
, elm = Icon "fa-elm"
}
iconLink : { text : String, icon : Icon, url : String } -> Html msg
iconLink options =
let
(Icon class) =
options.icon
in
Html.a [ Attr.href options.url, Attr.target "_blank", Attr.attribute "aria-label" options.text ]
[ Html.i [ Attr.class ("link__icon fab " ++ class) ] []
]

142
docs/src/UI/Docs.elm Normal file
View File

@ -0,0 +1,142 @@
module UI.Docs exposing (Model, Msg, page)
import Http
import Page
import Request
import Shared
import UI
import UI.Layout
import Url exposing (Url)
import View exposing (View)
page : Shared.Model -> Request.With params -> Page.With Model Msg
page shared req =
Page.element
{ init = init req.url
, update = update
, view = view shared req.url
, subscriptions = \_ -> Sub.none
}
-- INIT
type alias Model =
{ layout : UI.Layout.Model
, markdown : Fetchable String
}
type Fetchable data
= Loading
| Success data
| Failure String
withDefault : value -> Fetchable value -> value
withDefault fallback fetchable =
case fetchable of
Success value ->
value
_ ->
fallback
init : Url -> ( Model, Cmd Msg )
init url =
( Model UI.Layout.init Loading
, Http.get
{ url = "/content" ++ url.path ++ ".md"
, expect = Http.expectString GotMarkdown
}
)
-- UPDATE
type Msg
= Layout UI.Layout.Msg
| GotMarkdown (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Layout layoutMsg ->
( { model | layout = UI.Layout.update layoutMsg model.layout }
, Cmd.none
)
GotMarkdown response ->
let
success markdown =
( { model | markdown = Success markdown }
, Cmd.none
)
failure =
( { model | markdown = Failure "Couldn't find that section of the guide..." }
, Cmd.none
)
in
case response of
Ok markdown ->
if String.startsWith "<!DOCTYPE" markdown then
failure
else
success markdown
Err _ ->
failure
-- VIEW
view : Shared.Model -> Url -> Model -> View Msg
view shared url model =
{ title =
case model.markdown of
Loading ->
""
Success content ->
let
firstLine =
content
|> String.lines
|> List.head
|> Maybe.withDefault "Guide"
in
String.dropLeft 2 firstLine ++ " | elm-spa"
Failure _ ->
"Uh oh. | elm-spa"
, body =
UI.Layout.viewDocumentation
{ shared = shared
, url = url
, onMsg = Layout
, model = model.layout
}
(withDefault "" model.markdown)
[ case model.markdown of
Loading ->
UI.none
Failure reason ->
UI.markdown { withHeaderLinks = False } ("# Uh oh.\n\n" ++ reason)
Success markdown ->
UI.markdown { withHeaderLinks = True } markdown
, UI.gutter
]
}

229
docs/src/UI/Layout.elm Normal file
View File

@ -0,0 +1,229 @@
module UI.Layout exposing
( Model, init
, Msg, update
, viewDefault, viewDocumentation
, page, pageFullWidth
)
{-|
@docs Model, init
@docs Msg, update
@docs viewDefault, viewDocumentation
-}
import Gen.Route as Route exposing (Route)
import Html exposing (Html)
import Html.Attributes as Attr
import Page exposing (Page)
import Request exposing (Request)
import Shared
import UI
import UI.Searchbar
import UI.Sidebar
import Url exposing (Url)
import View exposing (View)
type alias Model =
{ query : String
}
init : Model
init =
{ query = ""
}
type Msg
= OnQueryChange String
update : Msg -> Model -> Model
update msg model =
case msg of
OnQueryChange query ->
{ model | query = query }
viewDefault :
{ model : Model
, onMsg : Msg -> msg
, shared : Shared.Model
, url : Url
}
-> List (Html msg)
-> List (Html msg)
viewDefault options view =
[ navbar options
, Html.main_ [ Attr.class "page container pad-x-md" ] view
, footer
]
viewFullWidth :
{ model : Model
, onMsg : Msg -> msg
, shared : Shared.Model
, url : Url
}
-> List (Html msg)
-> List (Html msg)
viewFullWidth options view =
[ navbar options
, Html.div [ Attr.class "page" ] view
, footer
]
viewDocumentation :
{ model : Model
, onMsg : Msg -> msg
, shared : Shared.Model
, url : Url
}
-> String
-> List (Html msg)
-> List (Html msg)
viewDocumentation options markdownContent view =
[ navbar options
, Html.div [ Attr.class "page container pad-md" ]
[ UI.row.xl [ UI.align.top, UI.padY.lg ]
[ Html.aside [ Attr.class "only-desktop sticky pad-y-lg aside" ]
[ UI.Sidebar.viewSidebar
{ index = options.shared.index
, url = options.url
}
]
, Html.main_ [ Attr.class "flex" ]
[ UI.row.lg [ UI.align.top ]
[ Html.div [ Attr.class "col flex margin-override" ] view
, Html.div [ Attr.class "hidden-mobile sticky pad-y-lg table-of-contents" ]
[ UI.Sidebar.viewTableOfContents
{ content = markdownContent
, url = options.url
}
]
]
]
]
]
, footer
]
navbar :
{ model : Model
, onMsg : Msg -> msg
, shared : Shared.Model
, url : Url
}
-> Html msg
navbar { onMsg, model, shared, url } =
let
navLink : { text : String, route : Route } -> Html msg
navLink options =
let
href : String
href =
Route.toHref options.route
in
Html.a
[ Attr.class "link"
, Attr.href href
, Attr.classList
[ ( "bold text-blue"
, if href == "/" then
href == url.path
else
String.startsWith href url.path
)
]
]
[ Html.text options.text ]
in
Html.header [ Attr.class "header pad-y-lg pad-x-md" ]
[ Html.div [ Attr.class "container" ]
[ Html.div [ Attr.class "row gap-md spread" ]
[ Html.div [ Attr.class "row align-center gap-lg" ]
[ Html.a [ Attr.class "header__logo", Attr.href "/" ] [ UI.logo ]
, Html.nav [ Attr.class "row gap-md hidden-mobile pad-left-xs" ]
[ navLink { text = "about", route = Route.Home_ }
, navLink { text = "guide", route = Route.Guide }
, navLink { text = "examples", route = Route.Examples }
]
]
, Html.div [ Attr.class "row gap-md spread" ]
[ Html.nav [ Attr.class "row gap-md hidden-mobile" ]
[ UI.iconLink { text = "GitHub Repo", icon = UI.icons.github, url = "https://github.com/ryannhg/elm-spa" }
, UI.iconLink { text = "NPM Package", icon = UI.icons.npm, url = "https://npmjs.org/elm-spa" }
, UI.iconLink { text = "Elm Package", icon = UI.icons.elm, url = "https://package.elm-lang.org/packages/ryannhg/elm-spa/latest" }
]
, UI.Searchbar.view
{ index = shared.index
, query = model.query
, onQueryChange = onMsg << OnQueryChange
}
]
]
]
]
footer : Html msg
footer =
Html.div [ Attr.class "footer__zone" ]
[ Html.footer [ Attr.class "footer container pad-top-xl" ]
[ Html.div [ Attr.class "row pad-x-md pad-y-lg pad-top-xl spread faded" ]
[ Html.a [ Attr.href "https://github.com/ryannhg/elm-spa/tree/main/docs", Attr.target "_blank", Attr.class "link hidden-mobile" ] [ Html.text "Site source code" ]
, Html.span [] [ Html.text "© 2019 2021, Ryan Haskell-Glatz" ]
]
]
]
-- PAGE
page : { view : View Msg } -> Shared.Model -> Request.With params -> Page.With Model Msg
page options shared req =
Page.sandbox
{ init = init
, update = update
, view =
\model ->
{ title = options.view.title
, body =
viewDefault
{ shared = shared
, url = req.url
, model = model
, onMsg = identity
}
options.view.body
}
}
pageFullWidth : { view : View Msg } -> Shared.Model -> Request.With params -> Page.With Model Msg
pageFullWidth options shared req =
Page.sandbox
{ init = init
, update = update
, view =
\model ->
{ title = options.view.title
, body =
viewFullWidth
{ shared = shared
, url = req.url
, model = model
, onMsg = identity
}
options.view.body
}
}

68
docs/src/UI/Searchbar.elm Normal file
View File

@ -0,0 +1,68 @@
module UI.Searchbar exposing (view)
import Domain.Index exposing (Index, Link)
import Html exposing (Html)
import Html.Attributes as Attr
import Html.Events as Events
import Json.Decode as Json
view :
{ index : Index
, query : String
, onQueryChange : String -> msg
}
-> Html msg
view options =
Html.node "dropdown-arrow-keys"
[ Events.on "clearDropdown" (Json.succeed (options.onQueryChange ""))
]
[ Html.label [ Attr.class "search relative z-2", Attr.attribute "aria-label" "Search" ]
[ Html.input
[ Attr.id "quick-search"
, Attr.class "search__input"
, Attr.type_ "search"
, Attr.placeholder "Search"
, Attr.value options.query
, Events.onInput options.onQueryChange
]
[]
, Html.div [ Attr.class "search__icon icon icon--search" ] []
, Html.kbd [ Attr.class "search__kbd" ] [ Html.text "/" ]
, if String.length options.query > 2 then
case Domain.Index.search options.query options.index of
[] ->
viewDropdownWindow
[ Html.span [ Attr.class "faint pad-md" ] [ Html.text "No matches found." ]
]
matches ->
viewMatches matches
else
Html.text ""
]
]
viewMatches : List Link -> Html msg
viewMatches matches =
viewDropdownWindow
(matches
|> List.sortBy (\link -> ( link.level, link.label |> String.length ))
|> List.map
(\match ->
Html.a [ Attr.class "dropdown__link", Attr.href match.url ]
[ Html.span [ Attr.class "underline" ] [ Html.map never match.html ]
]
)
|> List.take 5
)
viewDropdownWindow : List (Html msg) -> Html msg
viewDropdownWindow children =
Html.div [ Attr.class "absolute align-below fill-x pad-top-md" ]
[ Html.div [ Attr.class "col bg-white shadow border rounded" ]
children
]

180
docs/src/UI/Sidebar.elm Normal file
View File

@ -0,0 +1,180 @@
module UI.Sidebar exposing (viewSidebar, viewTableOfContents)
import Domain.Index exposing (Index)
import Html exposing (Html)
import Html.Attributes as Attr
import Markdown.Block
import Markdown.Html
import Markdown.Parser
import Markdown.Renderer
import UI
import Url exposing (Url)
import Utils.String
parseTableOfContents : String -> List Section
parseTableOfContents =
Markdown.Parser.parse
>> Result.mapError (\_ -> "Failed to parse.")
>> Result.andThen (Markdown.Renderer.render tableOfContentsRenderer)
>> Result.withDefault []
>> List.filterMap identity
>> headersToSections
type alias Section =
{ header : String
, url : String
, pages : List Link
}
type alias Link =
{ label : String
, url : String
}
type alias Header =
( HeaderLevel, String, Maybe String )
type HeaderLevel
= Heading2
| Heading3
headersToSections : List Header -> List Section
headersToSections =
let
loop : Header -> ( List Section, Maybe Section ) -> ( List Section, Maybe Section )
loop ( level, text, url_ ) ( sections, current ) =
let
url =
url_ |> Maybe.map (Utils.String.toId >> (++) "#") |> Maybe.withDefault ""
in
case ( level, current ) of
( Heading2, Just existing ) ->
( sections ++ [ existing ], Just { header = text, url = url, pages = [] } )
( Heading2, Nothing ) ->
( sections, Just { header = text, url = url, pages = [] } )
( Heading3, Just existing ) ->
( sections, Just { existing | pages = existing.pages ++ [ { label = text, url = url } ] } )
( Heading3, Nothing ) ->
( sections ++ [ { header = text, url = url, pages = [] } ], Nothing )
in
List.foldl loop ( [], Nothing )
>> (\( sections, maybe ) ->
maybe
|> Maybe.map (\section -> sections ++ [ section ])
|> Maybe.withDefault sections
)
tableOfContentsRenderer : Markdown.Renderer.Renderer (Maybe Header)
tableOfContentsRenderer =
{ heading =
\{ level, rawText } ->
case level of
Markdown.Block.H1 ->
Just ( Heading2, rawText, Nothing )
Markdown.Block.H2 ->
Just ( Heading2, rawText, Just rawText )
Markdown.Block.H3 ->
Just ( Heading3, rawText, Just rawText )
_ ->
Nothing
, paragraph = \_ -> Nothing
, blockQuote = \_ -> Nothing
, html = Markdown.Html.oneOf []
, text = \_ -> Nothing
, codeSpan = \_ -> Nothing
, strong = \_ -> Nothing
, emphasis = \_ -> Nothing
, hardLineBreak = Nothing
, link = \_ _ -> Nothing
, image = \_ -> Nothing
, unorderedList = \_ -> Nothing
, orderedList = \_ _ -> Nothing
, codeBlock = \_ -> Nothing
, thematicBreak = Nothing
, table = \_ -> Nothing
, tableHeader = \_ -> Nothing
, tableBody = \_ -> Nothing
, tableRow = \_ -> Nothing
, tableCell = \_ _ -> Nothing
, tableHeaderCell = \_ _ -> Nothing
}
viewSidebar : { url : Url, index : Index } -> Html msg
viewSidebar { url, index } =
let
viewSidebarLink : Link -> Html msg
viewSidebarLink link__ =
viewDocumentationLink (url.path == link__.url) link__
viewSidebarSection : Section -> Html msg
viewSidebarSection section =
UI.col.sm [ UI.align.left ]
[ Html.a
[ Attr.href section.url
, Attr.classList [ ( "bold text-blue", url.path == section.url ) ]
, Attr.class "h4 bold underline"
]
[ Html.text section.header ]
, if List.isEmpty section.pages then
Html.text ""
else
UI.col.md [ Attr.class "border-left pad-y-sm pad-x-md align-left" ] (List.map viewSidebarLink section.pages)
]
in
UI.col.md [] (List.map viewSidebarSection (Domain.Index.sections index))
viewDocumentationLink : Bool -> Link -> Html msg
viewDocumentationLink isActive link__ =
Html.a
[ Attr.class "link"
, Attr.classList [ ( "bold text-blue", isActive ) ]
, Attr.href link__.url
]
[ Html.text link__.label ]
viewTableOfContents : { url : Url, content : String } -> Html msg
viewTableOfContents { url, content } =
let
viewTableOfContentsLink : Link -> Html msg
viewTableOfContentsLink link__ =
viewDocumentationLink (url.fragment == Nothing && link__.url == "" || (url.fragment |> Maybe.map ((++) "#")) == Just link__.url) link__
viewTocSection : Section -> Html msg
viewTocSection section =
Html.div [ Attr.class "col gap-xs align-left" ]
[ viewTableOfContentsLink { label = section.header, url = section.url }
, if List.isEmpty section.pages then
Html.text ""
else
Html.div [ Attr.class "col pad-left-sm pad-xs gap-sm" ]
(section.pages
|> List.map (\l -> Html.div [ Attr.class "h6" ] [ viewTableOfContentsLink l ])
)
]
in
if String.isEmpty content then
Html.text ""
else
Html.nav [ Attr.class "col gap-md align-left toc shadow rounded bg-white" ]
[ Html.h4 [ Attr.class "h4 bold" ] [ Html.text "On this page" ]
, Html.div [ Attr.class "col gap-md" ] (List.map viewTocSection (parseTableOfContents content))
]

58
docs/src/Utils/String.elm Normal file
View File

@ -0,0 +1,58 @@
module Utils.String exposing
( caseInsensitiveContains
, format
, toId
)
import Html exposing (Html)
caseInsensitiveContains : String -> String -> Bool
caseInsensitiveContains sub word =
String.contains (String.toLower sub) (String.toLower word)
toId : String -> String
toId =
String.toLower
>> String.words
>> List.map (String.filter (\c -> c == '-' || Char.isAlphaNum c))
>> String.join "-"
format : String -> String -> Html msg
format query original =
original
|> String.toLower
|> String.split (String.toLower query)
|> List.indexedMap Tuple.pair
|> List.foldl
(\( index, segment ) ( length, str ) ->
let
nextLength =
length + String.length segment + String.length query
in
( nextLength
, str
++ [ original
|> String.dropLeft length
|> String.left (String.length segment)
|> Html.text
]
++ (if nextLength > String.length original then
[]
else
[ original
|> String.dropLeft (length + String.length segment)
|> String.left (String.length query)
|> Html.text
|> List.singleton
|> Html.strong []
]
)
)
)
( 0, [] )
|> Tuple.second
|> Html.span []

View File

@ -1,28 +1,18 @@
{
"type": "application",
"source-directories": [
"src"
"type": "package",
"name": "ryannhg/elm-spa",
"summary": "single page apps made easy.",
"license": "BSD-3-Clause",
"version": "6.0.0",
"exposed-modules": [
"ElmSpa.Page",
"ElmSpa.Request"
],
"elm-version": "0.19.1",
"elm-version": "0.19.0 <= v < 0.20.0",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
"elm/browser": "1.0.0 <= v < 2.0.0",
"elm/core": "1.0.0 <= v < 2.0.0",
"elm/url": "1.0.0 <= v < 2.0.0"
},
"test-dependencies": {
"direct": {
"elm-explorations/test": "1.2.2"
},
"indirect": {
"elm/random": "1.0.0"
}
}
"test-dependencies": {}
}

5
examples/01-hello-world/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist

View File

@ -0,0 +1,28 @@
# examples/01-hello-world
> 🌳 built with [elm-spa](https://elm-spa.dev)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm elm-spa
```
## running locally
```bash
elm-spa server # starts this app at http:/localhost:1234
```
### other commands
```bash
elm-spa add # add a new page to the application
elm-spa build # production build
elm-spa watch # runs build as you code (without the server)
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

View File

@ -0,0 +1,27 @@
{
"type": "application",
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>

View File

@ -0,0 +1,11 @@
module Pages.Home_ exposing (view)
import Html
import View exposing (View)
view : View msg
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}

5
examples/02-pages/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist

View File

@ -0,0 +1,28 @@
# examples/02-pages
> 🌳 built with [elm-spa](https://elm-spa.dev)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm elm-spa
```
## running locally
```bash
elm-spa server # starts this app at http:/localhost:1234
```
### other commands
```bash
elm-spa add # add a new page to the application
elm-spa build # production build
elm-spa watch # runs build as you code (without the server)
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

View File

@ -1,7 +1,10 @@
{
"type": "application",
"source-directories": [
"src"
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
@ -9,26 +12,19 @@
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/url": "1.0.0",
"mdgriffith/elm-ui": "1.1.7"
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/json": "1.1.3",
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {
"avh4/elm-program-test": "3.2.0",
"elm-explorations/test": "1.2.2"
},
"indirect": {
"avh4/elm-fifo": "1.0.4",
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/http": "2.0.0",
"elm/random": "1.0.0"
}
"direct": {},
"indirect": {}
}
}

View File

@ -3,12 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSS goes here -->
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- JavaScript goes here -->
<script src="/dist/elm.compiled.js"></script>
<script src="/main.js"></script>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>

View File

@ -0,0 +1,27 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.container {
max-width: 960px;
margin: 1rem auto;
}
.navbar {
display: flex;
align-items: center;
}
.navbar .brand {
font-size: 1.5rem;
}
.navbar .splitter { flex: 1 1 auto; }
.navbar a {
margin-right: 16px;
}
h1, h2 {
margin-top: 3rem;
}

View File

@ -0,0 +1,86 @@
module Pages.Advanced exposing (Model, Msg, page)
import Effect exposing (Effect)
import Gen.Params.Advanced exposing (Params)
import Html
import Html.Events as Events
import Page
import Request
import Shared
import UI
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.advanced
{ init = init
, update = update
, view = view shared
, subscriptions = subscriptions
}
-- INIT
type alias Model =
{}
init : ( Model, Effect Msg )
init =
( {}, Effect.none )
-- UPDATE
type Msg
= IncrementShared
| DecrementShared
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
IncrementShared ->
( model
, Effect.fromShared Shared.Increment
)
DecrementShared ->
( model
, Effect.fromShared Shared.Decrement
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view : Shared.Model -> Model -> View Msg
view shared model =
{ title = "Advanced"
, body =
UI.layout
[ UI.h1 "Advanced"
, Html.p [] [ Html.text "An advanced page uses Effects instead of Cmds, which allow you to send Shared messages directly from a page." ]
, Html.h2 [] [ Html.text "Shared Counter" ]
, Html.h3 [] [ Html.text (String.fromInt shared.counter) ]
, Html.button [ Events.onClick DecrementShared ] [ Html.text "-" ]
, Html.button [ Events.onClick IncrementShared ] [ Html.text "+" ]
, Html.p [] [ Html.text "This value doesn't reset as you navigate from one page to another (but will on page refresh)!" ]
]
}

View File

@ -0,0 +1,30 @@
module Pages.Dynamic.Name_ exposing (page)
import Gen.Params.Dynamic.Name_ exposing (Params)
import Html exposing (Html)
import Page exposing (Page)
import Request
import Shared
import UI
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page
page shared req =
Page.static
{ view = view req.params
}
view : Params -> View msg
view params =
{ title = "Dynamic: " ++ params.name
, body =
UI.layout
[ UI.h1 "Dynamic Page"
, Html.p [] [ Html.text "Dynamic pages with underscores can safely access URL parameters." ]
, Html.p [] [ Html.text "Because this file is named \"Name_.elm\", it has a \"name\" parameter." ]
, Html.p [] [ Html.text "Try changing the URL above to something besides \"apple\" or \"banana\"! " ]
, Html.h2 [] [ Html.text params.name ]
]
}

View File

@ -0,0 +1,154 @@
module Pages.Element exposing (Model, Msg, page)
import Browser.Dom exposing (Viewport)
import Browser.Events
import Gen.Params.Element exposing (Params)
import Html
import Html.Attributes as Attr
import Html.Events as Events
import Http
import Json.Decode as Json
import Page
import Request
import Shared
import Task
import UI
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
-- INIT
type alias Model =
{ window : { width : Int, height : Int }
, image : WebRequest
}
type WebRequest
= NotAsked
| Success String
| Failure
init : ( Model, Cmd Msg )
init =
( { window = { width = 0, height = 0 }
, image = NotAsked
}
, Browser.Dom.getViewport
|> Task.perform GotInitialViewport
)
-- UPDATE
type Msg
= ResizedWindow Int Int
| GotInitialViewport Viewport
| ClickedFetchCat
| GotCatGif (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotInitialViewport { viewport } ->
( { model
| window =
{ width = floor viewport.width
, height = floor viewport.height
}
}
, Cmd.none
)
ResizedWindow w h ->
( { model | window = { width = w, height = h } }
, Cmd.none
)
ClickedFetchCat ->
let
gifDecoder =
Json.field "url" Json.string
|> Json.map (\url -> "https://cataas.com" ++ url)
in
( model
, Http.get
{ url = "https://cataas.com/cat?json=true&type=sm"
, expect = Http.expectJson GotCatGif gifDecoder
}
)
GotCatGif (Ok url) ->
( { model | image = Success url }
, Cmd.none
)
GotCatGif (Err _) ->
( { model | image = Failure }
, Cmd.none
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Browser.Events.onResize ResizedWindow
-- VIEW
view : Model -> View Msg
view model =
{ title = "Element"
, body =
UI.layout
[ UI.h1 "Element"
, Html.p [] [ Html.text "An element page can perform side-effects like HTTP requests and subscribe to events from the browser!" ]
, Html.br [] []
, Html.h2 [] [ Html.text "Commands" ]
, Html.p []
[ Html.button [ Events.onClick ClickedFetchCat ] [ Html.text "Get a cat" ]
]
, case model.image of
NotAsked ->
Html.text ""
Failure ->
Html.text "Something went wrong, please try again."
Success image ->
Html.img [ Attr.src image, Attr.alt "Cat" ] []
, Html.br [] []
, Html.h2 [] [ Html.text "Subscriptions" ]
, Html.p []
[ Html.strong [] [ Html.text "Window size:" ]
, Html.text (windowSizeToString model.window)
]
]
}
windowSizeToString : { width : Int, height : Int } -> String
windowSizeToString { width, height } =
"( " ++ String.fromInt width ++ ", " ++ String.fromInt height ++ " )"

View File

@ -0,0 +1,16 @@
module Pages.Home_ exposing (view)
import Html
import UI
import View exposing (View)
view : View msg
view =
{ title = "Homepage"
, body =
UI.layout
[ Html.h1 [] [ Html.text "Homepage" ]
, Html.p [] [ Html.text "This homepage is just a view function, click the links in the navbar to see more pages!" ]
]
}

View File

@ -0,0 +1,71 @@
module Pages.Sandbox exposing (Model, Msg, page)
import Gen.Params.Sandbox exposing (Params)
import Html
import Html.Events
import Page
import Request
import Shared
import UI
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.sandbox
{ init = init
, update = update
, view = view
}
-- INIT
type alias Model =
{ counter : Int
}
init : Model
init =
{ counter = 0
}
-- UPDATE
type Msg
= Increment
| Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | counter = model.counter + 1 }
Decrement ->
{ model | counter = model.counter - 1 }
-- VIEW
view : Model -> View Msg
view model =
{ title = "Sandbox"
, body =
UI.layout
[ UI.h1 "Sandbox"
, Html.p [] [ Html.text "A sandbox page can keep track of state!" ]
, Html.h3 [] [ Html.text (String.fromInt model.counter) ]
, Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ]
, Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ]
]
}

View File

@ -0,0 +1,27 @@
module Pages.Static exposing (page)
import Gen.Params.Static exposing (Params)
import Html
import Page exposing (Page)
import Request exposing (Request)
import Shared
import UI
import View exposing (View)
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view
}
view : View msg
view =
{ title = "Static"
, body =
UI.layout
[ UI.h1 "Static"
, Html.p [] [ Html.text "A static page only renders a view, but has access to shared state and URL information." ]
]
}

View File

@ -0,0 +1,49 @@
module Shared exposing
( Flags
, Model
, Msg(..)
, init
, subscriptions
, update
)
import Json.Decode as Json
import Request exposing (Request)
type alias Flags =
Json.Value
type alias Model =
{ counter : Int
}
type Msg
= Increment
| Decrement
init : Request -> Flags -> ( Model, Cmd Msg )
init _ _ =
( { counter = 0 }, Cmd.none )
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update _ msg model =
case msg of
Increment ->
( { model | counter = model.counter + 1 }
, Cmd.none
)
Decrement ->
( { model | counter = model.counter - 1 }
, Cmd.none
)
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Sub.none

View File

@ -0,0 +1,33 @@
module UI exposing (h1, layout)
import Gen.Route as Route exposing (Route)
import Html exposing (Html)
import Html.Attributes as Attr
layout : List (Html msg) -> List (Html msg)
layout children =
let
viewLink : String -> Route -> Html msg
viewLink label route =
Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ]
in
[ Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ Html.strong [ Attr.class "brand" ] [ viewLink "Home" Route.Home_ ]
, viewLink "Static" Route.Static
, viewLink "Sandbox" Route.Sandbox
, viewLink "Element" Route.Element
, viewLink "Advanced" Route.Advanced
, Html.div [ Attr.class "splitter" ] []
, viewLink "Dynamic: Apple" (Route.Dynamic__Name_ { name = "apple" })
, viewLink "Dynamic: Banana" (Route.Dynamic__Name_ { name = "banana" })
]
, Html.main_ [] children
]
]
h1 : String -> Html msg
h1 label =
Html.h1 [] [ Html.text label ]

5
examples/03-local-storage/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist

View File

@ -0,0 +1,28 @@
# my new project
> 🌳 built with [elm-spa](https://elm-spa.dev)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm elm-spa
```
## running locally
```bash
elm-spa server # starts this app at http:/localhost:1234
```
### other commands
```bash
elm-spa add # add a new page to the application
elm-spa build # production build
elm-spa watch # runs build as you code (without the server)
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

View File

@ -0,0 +1,27 @@
{
"type": "application",
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@ -3,11 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSS goes here -->
</head>
<body>
<!-- JavaScript goes here -->
<script src="/dist/elm.compiled.js"></script>
<script src="/dist/elm.js"></script>
<script src="/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
const app = Elm.Main.init({
flags: JSON.parse(localStorage.getItem('storage'))
})
app.ports.save.subscribe(storage => {
localStorage.setItem('storage', JSON.stringify(storage))
app.ports.load.send(storage)
})

View File

@ -0,0 +1,80 @@
module Pages.Home_ exposing (Model, Msg, init, page, update, view)
import Html
import Html.Events
import Page
import Request exposing (Request)
import Shared
import Storage exposing (Storage)
import View exposing (View)
page : Shared.Model -> Request -> Page.With Model Msg
page shared _ =
Page.element
{ init = init
, update = update shared.storage
, view = view shared.storage
, subscriptions = subscriptions
}
-- INIT
type alias Model =
{}
init : ( Model, Cmd Msg )
init =
( {}, Cmd.none )
-- UPDATE
type Msg
= Increment
| Decrement
update : Storage -> Msg -> Model -> ( Model, Cmd Msg )
update storage msg model =
case msg of
Increment ->
( model
, Storage.increment storage
)
Decrement ->
( model
, Storage.decrement storage
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.none
-- VIEW
view : Storage -> Model -> View Msg
view storage _ =
{ title = "Homepage"
, body =
[ Html.h1 [] [ Html.text "Local storage" ]
, Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ]
, Html.p [] [ Html.text ("Count: " ++ String.fromInt storage.counter) ]
, Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ]
]
}

View File

@ -0,0 +1,46 @@
module Shared exposing
( Flags
, Model
, Msg
, init
, subscriptions
, update
)
import Json.Decode as Json
import Request exposing (Request)
import Storage exposing (Storage)
type alias Flags =
Json.Value
type alias Model =
{ storage : Storage
}
init : Request -> Flags -> ( Model, Cmd Msg )
init _ flags =
( { storage = Storage.fromJson flags }
, Cmd.none
)
type Msg
= StorageUpdated Storage
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update _ msg model =
case msg of
StorageUpdated storage ->
( { model | storage = storage }
, Cmd.none
)
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Storage.onChange StorageUpdated

View File

@ -0,0 +1,94 @@
port module Storage exposing
( Storage, fromJson, onChange
, increment, decrement
)
{-|
@docs Storage, fromJson, onChange
@docs increment, decrement
-}
import Json.Decode as Json
import Json.Encode as Encode
-- PORTS
port save : Json.Value -> Cmd msg
port load : (Json.Value -> msg) -> Sub msg
-- STORAGE
type alias Storage =
{ counter : Int
}
-- Converting to JSON
toJson : Storage -> Json.Value
toJson storage =
Encode.object
[ ( "counter", Encode.int storage.counter )
]
-- Converting from JSON
fromJson : Json.Value -> Storage
fromJson json =
json
|> Json.decodeValue decoder
|> Result.withDefault init
init : Storage
init =
{ counter = 0
}
decoder : Json.Decoder Storage
decoder =
Json.map Storage
(Json.field "counter" Json.int)
-- Updating storage
increment : Storage -> Cmd msg
increment storage =
{ storage | counter = storage.counter + 1 }
|> toJson
|> save
decrement : Storage -> Cmd msg
decrement storage =
{ storage | counter = storage.counter - 1 }
|> toJson
|> save
-- LISTENING FOR STORAGE UPDATES
onChange : (Storage -> msg) -> Sub msg
onChange fromStorage =
load (\json -> fromJson json |> fromStorage)

5
examples/04-authentication/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist

View File

@ -0,0 +1,28 @@
# my new project
> 🌳 built with [elm-spa](https://elm-spa.dev)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm elm-spa
```
## running locally
```bash
elm-spa server # starts this app at http:/localhost:1234
```
### other commands
```bash
elm-spa add # add a new page to the application
elm-spa build # production build
elm-spa watch # runs build as you code (without the server)
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

View File

@ -0,0 +1,27 @@
{
"type": "application",
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@ -3,11 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSS goes here -->
</head>
<body>
<!-- JavaScript goes here -->
<script src="/dist/elm.compiled.js"></script>
<script src="/dist/elm.js"></script>
<script src="/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
const app = Elm.Main.init({
flags: JSON.parse(localStorage.getItem('storage'))
})
app.ports.save_.subscribe(storage => {
localStorage.setItem('storage', JSON.stringify(storage))
app.ports.load_.send(storage)
})

View File

@ -0,0 +1,31 @@
module Auth exposing
( User
, beforeProtectedInit
)
{-|
@docs User
@docs beforeProtectedInit
-}
import Domain.User
import ElmSpa.Page as ElmSpa
import Gen.Route exposing (Route)
import Request exposing (Request)
import Shared
type alias User =
Domain.User.User
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit { storage } _ =
case storage.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn

View File

@ -0,0 +1,22 @@
module Domain.User exposing (User, decoder, encode)
import Json.Decode as Json
import Json.Encode as Encode
type alias User =
{ name : String
}
decoder : Json.Decoder User
decoder =
Json.map User
(Json.field "name" Json.string)
encode : User -> Json.Value
encode user =
Encode.object
[ ( "name", Encode.string user.name )
]

View File

@ -0,0 +1,63 @@
module Pages.Home_ exposing (Model, Msg, page, view)
import Auth
import Html
import Html.Events as Events
import Page
import Request exposing (Request)
import Shared
import Storage exposing (Storage)
import UI
import View exposing (View)
page : Shared.Model -> Request -> Page.With Model Msg
page shared _ =
Page.protected.element <|
\user ->
{ init = init
, update = update shared.storage
, view = view user
, subscriptions = \_ -> Sub.none
}
-- INIT
type alias Model =
{}
init : ( Model, Cmd Msg )
init =
( {}, Cmd.none )
-- UPDATE
type Msg
= ClickedSignOut
update : Storage -> Msg -> Model -> ( Model, Cmd Msg )
update storage msg model =
case msg of
ClickedSignOut ->
( model
, Storage.signOut storage
)
view : Auth.User -> Model -> View Msg
view user _ =
{ title = "Homepage"
, body =
UI.layout
[ Html.h1 [] [ Html.text ("Hello, " ++ user.name ++ "!") ]
, Html.button [ Events.onClick ClickedSignOut ] [ Html.text "Sign out" ]
]
}

View File

@ -0,0 +1,95 @@
module Pages.SignIn exposing (Model, Msg, page)
import Gen.Params.SignIn exposing (Params)
import Html
import Html.Attributes as Attr
import Html.Events as Events
import Page
import Request
import Shared
import Storage exposing (Storage)
import UI
import View exposing (View)
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.element
{ init = init
, update = update shared.storage
, view = view
, subscriptions = subscriptions
}
-- INIT
type alias Model =
{ name : String }
init : ( Model, Cmd Msg )
init =
( { name = "" }
, Cmd.none
)
-- UPDATE
type Msg
= UpdatedName String
| SubmittedSignInForm
update : Storage -> Msg -> Model -> ( Model, Cmd Msg )
update storage msg model =
case msg of
UpdatedName name ->
( { model | name = name }
, Cmd.none
)
SubmittedSignInForm ->
( model
, Storage.signIn { name = model.name } storage
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view : Model -> View Msg
view model =
{ title = "Sign in"
, body =
UI.layout
[ Html.form [ Events.onSubmit SubmittedSignInForm ]
[ Html.label []
[ Html.span [] [ Html.text "Name" ]
, Html.input
[ Attr.type_ "text"
, Attr.value model.name
, Events.onInput UpdatedName
]
[]
]
, Html.button [ Attr.disabled (String.isEmpty model.name) ]
[ Html.text "Sign in" ]
]
]
}

View File

@ -0,0 +1,59 @@
module Shared exposing
( Flags
, Model
, Msg
, init
, subscriptions
, update
)
import Gen.Route
import Json.Decode as Json
import Request exposing (Request)
import Storage exposing (Storage)
type alias Flags =
Json.Value
type alias Model =
{ storage : Storage
}
init : Request -> Flags -> ( Model, Cmd Msg )
init req flags =
let
model =
{ storage = Storage.fromJson flags }
in
( model
, if model.storage.user /= Nothing && req.route == Gen.Route.SignIn then
Request.replaceRoute Gen.Route.SignIn req
else
Cmd.none
)
type Msg
= StorageUpdated Storage
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
case msg of
StorageUpdated storage ->
( { model | storage = storage }
, if Gen.Route.SignIn == req.route then
Request.pushRoute Gen.Route.Home_ req
else
Cmd.none
)
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Storage.load StorageUpdated

View File

@ -0,0 +1,85 @@
port module Storage exposing
( Storage, load
, signIn, signOut
, fromJson
)
{-|
@docs Storage, save, load
@docs signIn, signOut
-}
import Domain.User as User exposing (User)
import Json.Decode as Json
import Json.Encode as Encode
type alias Storage =
{ user : Maybe User
}
fromJson : Json.Value -> Storage
fromJson json =
json
|> Json.decodeValue decoder
|> Result.withDefault init
init : Storage
init =
{ user = Nothing
}
decoder : Json.Decoder Storage
decoder =
Json.map Storage
(Json.field "user" (Json.maybe User.decoder))
save : Storage -> Json.Value
save storage =
Encode.object
[ ( "user"
, storage.user
|> Maybe.map User.encode
|> Maybe.withDefault Encode.null
)
]
-- UPDATING STORAGE
signIn : User -> Storage -> Cmd msg
signIn user storage =
saveToLocalStorage { storage | user = Just user }
signOut : Storage -> Cmd msg
signOut storage =
saveToLocalStorage { storage | user = Nothing }
-- PORTS
saveToLocalStorage : Storage -> Cmd msg
saveToLocalStorage =
save >> save_
port save_ : Json.Value -> Cmd msg
load : (Storage -> msg) -> Sub msg
load fromStorage =
load_ (fromJson >> fromStorage)
port load_ : (Json.Value -> msg) -> Sub msg

View File

@ -0,0 +1,27 @@
module UI exposing (h1, layout)
import Gen.Route as Route exposing (Route)
import Html exposing (Html)
import Html.Attributes as Attr
layout : List (Html msg) -> List (Html msg)
layout children =
let
viewLink : String -> Route -> Html msg
viewLink label route =
Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ]
in
[ Html.div [ Attr.style "margin" "2rem" ]
[ Html.header [ Attr.style "margin-bottom" "1rem" ]
[ Html.strong [ Attr.style "margin-right" "1rem" ] [ viewLink "Home" Route.Home_ ]
, viewLink "Sign in" Route.SignIn
]
, Html.main_ [] children
]
]
h1 : String -> Html msg
h1 label =
Html.h1 [] [ Html.text label ]

5
examples/05-vite/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.elm-spa
elm-stuff
node_modules
dist

View File

@ -0,0 +1,27 @@
# examples/05-vite
> 🌳 built with [elm-spa](https://elm-spa.dev)
## dependencies
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
```bash
npm install -g elm elm-spa
```
## running locally
```bash
npm start
```
### other commands
```bash
npm run dev # run elm-spa and Vite without "npm install"
npm run build # production codegen and vite build
```
## learn more
You can learn more at [elm-spa.dev](https://elm-spa.dev)

27
examples/05-vite/elm.json Normal file
View File

@ -0,0 +1,27 @@
{
"type": "application",
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

Some files were not shown because too many files have changed in this diff Show More