Merge branch 'next' into main
20
.github/ISSUE_TEMPLATE/new-example.md
vendored
@ -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
|
28
.github/workflows/nodejs.yml
vendored
@ -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
@ -1,8 +1,8 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
||||
Generated
|
||||
elm-spa-*.tgz
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
10
.npmignore
@ -1,10 +0,0 @@
|
||||
elm-stuff
|
||||
/src
|
||||
/tests
|
||||
/elm.json
|
||||
/examples
|
||||
!.gitignore
|
||||
.github
|
||||
.netlify
|
||||
*.tgz
|
||||
/dist/elm.js
|
42
README.md
@ -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
@ -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)
|
||||
|
@ -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
@ -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
|
61
docs/public/content/examples.md
Normal 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.
|
139
docs/public/content/examples/01-hello-world.md
Normal 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)
|
295
docs/public/content/examples/02-pages.md
Normal 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)
|
394
docs/public/content/examples/03-storage.md
Normal 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)
|
407
docs/public/content/examples/04-authentication.md
Normal 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)
|
73
docs/public/content/guide.md
Normal 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)
|
127
docs/public/content/guide/01-cli.md
Normal 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)
|
133
docs/public/content/guide/02-routing.md
Normal 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)
|
257
docs/public/content/guide/03-pages.md
Normal 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)
|
120
docs/public/content/guide/04-requests.md
Normal 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)
|
106
docs/public/content/guide/05-shared-state.md
Normal 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)
|
109
docs/public/content/guide/06-views.md
Normal 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)
|
BIN
docs/public/content/images/01-hello-world.png
Normal file
After Width: | Height: | Size: 261 KiB |
BIN
docs/public/content/images/02-pages.png
Normal file
After Width: | Height: | Size: 302 KiB |
BIN
docs/public/content/images/03-storage.png
Normal file
After Width: | Height: | Size: 278 KiB |
BIN
docs/public/content/images/04-authentication.png
Normal file
After Width: | Height: | Size: 281 KiB |
BIN
docs/public/content/images/realworld.png
Normal file
After Width: | Height: | Size: 379 KiB |
BIN
docs/public/content/images/this-site.png
Normal file
After Width: | Height: | Size: 658 KiB |
BIN
docs/public/favicon.png
Normal file
After Width: | Height: | Size: 82 KiB |
1
docs/public/images/icons/brain.svg
Normal file
After Width: | Height: | Size: 5.2 KiB |
1
docs/public/images/icons/laptop.svg
Normal 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 |
1
docs/public/images/icons/lock.svg
Normal 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 |
1
docs/public/images/icons/magic.svg
Normal 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
After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
BIN
docs/public/images/outlined-to-edge.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
docs/public/images/rounded-logo-bg.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
docs/public/images/screenshot.png
Normal file
After Width: | Height: | Size: 255 KiB |
20
docs/public/index.html
Normal 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
@ -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
@ -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
@ -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
63
docs/scripts/generate-index.js
Normal 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
@ -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
@ -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
|
||||
]
|
19
docs/src/Pages/Examples.elm
Normal 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
|
19
docs/src/Pages/Examples/Section_.elm
Normal 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
@ -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
|
19
docs/src/Pages/Guide/Section_.elm
Normal 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
@ -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)
|
37
docs/src/Pages/NotFound.elm
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 []
|
36
elm.json
@ -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
@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
28
examples/01-hello-world/README.md
Normal 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)
|
27
examples/01-hello-world/elm.json
Normal 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": {}
|
||||
}
|
||||
}
|
11
examples/01-hello-world/public/index.html
Normal 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>
|
11
examples/01-hello-world/src/Pages/Home_.elm
Normal 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
@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
28
examples/02-pages/README.md
Normal 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)
|
@ -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": {}
|
||||
}
|
||||
}
|
@ -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>
|
27
examples/02-pages/public/style.css
Normal 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;
|
||||
}
|
86
examples/02-pages/src/Pages/Advanced.elm
Normal 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)!" ]
|
||||
]
|
||||
}
|
30
examples/02-pages/src/Pages/Dynamic/Name_.elm
Normal 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 ]
|
||||
]
|
||||
}
|
154
examples/02-pages/src/Pages/Element.elm
Normal 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 ++ " )"
|
16
examples/02-pages/src/Pages/Home_.elm
Normal 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!" ]
|
||||
]
|
||||
}
|
71
examples/02-pages/src/Pages/Sandbox.elm
Normal 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 "+" ]
|
||||
]
|
||||
}
|
27
examples/02-pages/src/Pages/Static.elm
Normal 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." ]
|
||||
]
|
||||
}
|
49
examples/02-pages/src/Shared.elm
Normal 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
|
33
examples/02-pages/src/UI.elm
Normal 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
@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
28
examples/03-local-storage/README.md
Normal 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)
|
27
examples/03-local-storage/elm.json
Normal 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": {}
|
||||
}
|
||||
}
|
@ -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>
|
8
examples/03-local-storage/public/main.js
Normal 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)
|
||||
})
|
80
examples/03-local-storage/src/Pages/Home_.elm
Normal 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 "-" ]
|
||||
]
|
||||
}
|
46
examples/03-local-storage/src/Shared.elm
Normal 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
|
94
examples/03-local-storage/src/Storage.elm
Normal 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
@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
28
examples/04-authentication/README.md
Normal 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)
|
27
examples/04-authentication/elm.json
Normal 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": {}
|
||||
}
|
||||
}
|
@ -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>
|
8
examples/04-authentication/public/main.js
Normal 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)
|
||||
})
|
31
examples/04-authentication/src/Auth.elm
Normal 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
|
22
examples/04-authentication/src/Domain/User.elm
Normal 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 )
|
||||
]
|
63
examples/04-authentication/src/Pages/Home_.elm
Normal 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" ]
|
||||
]
|
||||
}
|
95
examples/04-authentication/src/Pages/SignIn.elm
Normal 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" ]
|
||||
]
|
||||
]
|
||||
}
|
59
examples/04-authentication/src/Shared.elm
Normal 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
|
85
examples/04-authentication/src/Storage.elm
Normal 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
|
27
examples/04-authentication/src/UI.elm
Normal 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
@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.elm-spa
|
||||
elm-stuff
|
||||
node_modules
|
||||
dist
|
27
examples/05-vite/README.md
Normal 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
@ -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": {}
|
||||
}
|
||||
}
|