prepare for v5

This commit is contained in:
Ryan Haskell-Glatz 2020-07-13 22:39:10 -05:00
parent 255a25ab3f
commit d12d183889
230 changed files with 13012 additions and 5571 deletions

28
.github/workflows/nodejs.yml vendored Normal file
View File

@ -0,0 +1,28 @@
# 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

5
.gitignore vendored
View File

@ -2,4 +2,7 @@
elm-stuff
node_modules
dist
Generated
Generated
elm-spa-*.tgz
# Local Netlify folder
.netlify

10
.npmignore Normal file
View File

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

View File

@ -1,3 +0,0 @@
language: elm
before_install: cd cli
script: elm-format --validate src tests && elm-test

161
README.md
View File

@ -1,161 +1,12 @@
# elm-spa
# elm-spa
![Build](https://github.com/ryannhg/elm-spa/workflows/Build/badge.svg?branch=master)
[![Build Status](https://travis-ci.org/ryannhg/elm-spa.svg?branch=master)](https://travis-ci.org/ryannhg/elm-spa)
## single page apps made easy
When you create an app with the [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest) package, you can build anything from a static `Html msg` page to a fully-fledged web `Browser.application`.
__elm-spa__ uses that design at the page-level, so you can quickly add new pages to your Elm application!
✅ Automatically generate routes and pages
✅ Read and update global state across pages
## static pages
```elm
-- can render a static page
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
```
npm install -g elm-spa@latest
```
## sandbox pages
```elm
-- can keep track of page state
page : Page Flags Model Msg
page =
Page.sandbox
{ init = init
, update = update
, view = view
}
```
## element pages
```elm
-- can perform side effects
page : Page Flags Model Msg
page =
Page.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
```
## component pages
```elm
-- can read and update global state
page : Page Flags Model Msg
page =
Page.component
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
```
## easily put together pages!
The reason we return the same `Page` type is to make it super
easy to write top-level `init`, `update`, `view`, and `susbcriptions` functions.
(And if you're using the [official cli tool](https://npmjs.org/elm-spa), this code will be automatically generated for you)
### `init`
```elm
init : Route -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
init route =
case route of
Route.Home -> pages.home.init ()
Route.About -> pages.about.init ()
Route.Posts slug -> pages.posts.init slug
Route.SignIn -> pages.signIn.init ()
```
### `update`
```elm
update : Msg -> Model -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
update bigMsg bigModel =
case ( bigMsg, bigModel ) of
( Home_Msg msg, Home_Model model ) ->
pages.home.update msg model
( About_Msg msg, About_Model model ) ->
pages.about.update msg model
( Posts_Msg msg, Posts_Model model ) ->
pages.posts.update msg model
( SignIn_Msg msg, SignIn_Model model ) ->
pages.signIn.update msg model
_ ->
always ( bigModel, Cmd.none, Cmd.none )
```
### `view` + `subscriptions`
```elm
-- handle view and subscriptions in one case expression!
bundle : Model -> Global.Model -> { view : Document Msg, subscriptions : Sub Msg }
bundle bigModel =
case route of
Home_Model model -> pages.home.bundle model
About_Model model -> pages.about.bundle model
Posts_Model model -> pages.posts.bundle model
SignIn_Model model -> pages.signIn.bundle model
```
### install the npm package
The [cli tool](https://www.npmjs.com/package/elm-spa) has commands like `elm-spa init`, `elm-spa add`, and `elm-spa build` for
generating your routes and pages for you!
```
npm install -g elm-spa
elm-spa init new-project
```
### install the elm package
If you'd rather define routes and pages by hand,
you can add [the elm package](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest) to your project:
```
elm install ryannhg/elm-spa
```
### rather see an example?
This repo comes with an example project that you can
play around with. Add in some pages and see how it works!
#### html example
```
git clone https://github.com/ryannhg/elm-spa
cd elm-spa/examples/html
npm start
```
#### elm-ui example
```
git clone https://github.com/ryannhg/elm-spa
cd elm-spa/examples/elm-ui
npm start
```
The __elm-spa__ will be running at http://localhost:8000
Learn more at [the offical guide](https://elm-spa.dev/guide)!

View File

@ -1,5 +0,0 @@
elm-stuff
/src
/tests
/elm.json
!.gitignore

View File

@ -1,144 +0,0 @@
# elm-spa
[![Build Status](https://travis-ci.org/ryannhg/elm-spa.svg?branch=master)](https://travis-ci.org/ryannhg/elm-spa)
## single page apps made easy
this is the cli tool for [the ryannhg/elm-spa package](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest).
It comes with a few commands to help you build single page applications in Elm!
## installation
```
npm install -g elm-spa
```
## available commands
- [elm-spa init](#elm-spa-init) create a new project
- [elm-spa add](#elm-spa-add) add a new page
- [elm-spa build](#elm-spa-build) generate routes and pages
## elm-spa init
```
elm-spa init <directory>
Create a new elm-spa app in the <directory>
folder specified.
examples:
elm-spa init .
elm-spa init my-app
```
## elm-spa add
```
elm-spa add <static|sandbox|element|component> <name>
Create a new page of type <static|sandbox|element|component>
with the module name <name>.
examples:
elm-spa add static Top
elm-spa add sandbox Posts.Top
elm-spa add element Posts.Dynamic
elm-spa add component SignIn
```
## elm-spa build
```
elm-spa build [dir]
Create "Generated.Route" and "Generated.Pages" modules for
this project, based on the files in "src/Pages"
Here are more details on how that works:
https://www.npmjs.com/package/elm-spa#naming-conventions
examples:
elm-spa build
elm-spa build ../some/other-folder
elm-spa build ./help
```
## naming conventions
the `elm-spa build` command is pretty useful, because it
automatically generates `Routes.elm` and `Pages.elm` code for you,
based on the naming convention in `src/Pages/*.elm`
Here's an example project structure:
```
src/
└─ Pages/
├─ Top.elm
├─ About.elm
├─ Posts/
| ├─ Top.elm
| └─ Dynamic.elm
└─ Authors/
└─ Dynamic/
└─ Posts/
└─ Dynamic.elm
```
When you run `elm-spa build` with these files in the `src/Pages` directory, __elm-spa__ can
automatically generate routes like these:
__Page__ | __Route__ | __Example__
:-- | :-- | :--
`Top.elm` | `/` | -
`About.elm` | `/about` | -
`Posts/Top.elm` | `/posts` | -
`Posts/Dynamic.elm` | `/posts/:param1` | `/posts/123`
`Authors/Dynamic/Posts/Dynamic.elm` | `/authors/:param1/posts/:param2` | `/authors/ryan/posts/123`
### top-level and dynamic routes
- `Top.elm` represents the top-level index in the folder.
- `Dynamic.elm` means that a dynamic parameter should match there.
- `Dynamic` can also be used as a folder, supporting nested dynamic routes.
### accessing url parameters
These dynamic parameters are available as `Flags` for the given page.
Here are some specific examples from the routes above:
```elm
module Pages.About exposing (..)
type alias Flags =
()
```
```elm
module Pages.Posts.Dynamic exposing (..)
type alias Flags =
{ param1 : String
}
```
```elm
module Pages.Authors.Dynamic.Posts.Dynamic exposing (..)
type alias Flags =
{ param1 : String
, param2 : String
}
```
These `Flags` are automatically passed in to the `init` function of any `element` or `component` page.
## the elm package
Need more details? Feel free to check out the [official elm package documentation](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest)!

View File

@ -1,192 +0,0 @@
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const { Elm } = require('./dist/elm.worker.js')
const package = require('./package.json')
// File stuff
const folders = {
src: (dir) => path.join(process.cwd(), dir, 'src'),
pages: (dir) => path.join(process.cwd(), dir, 'src', 'Pages'),
generated: (dir) => path.join(process.cwd(), dir, 'src', 'Generated')
}
const rejectIfMissing = (dir) => new Promise((resolve, reject) =>
fs.existsSync(dir) ? resolve(true) : reject(false)
)
const cp = (src, dest) => {
const exists = fs.existsSync(src)
const stats = exists && fs.statSync(src)
if (stats && stats.isDirectory()) {
fs.mkdirSync(dest)
fs.readdirSync(src).forEach(child =>
cp(path.join(src, child), path.join(dest, child))
)
} else {
fs.copyFileSync(src, dest)
}
}
const listFiles = (dir) =>
fs.readdirSync(dir)
.reduce((files, file) =>
fs.statSync(path.join(dir, file)).isDirectory() ?
files.concat(listFiles(path.join(dir, file))) :
files.concat(path.join(dir, file)),
[])
const ensureDirectory = (dir) =>
fs.existsSync(dir) || fs.mkdirSync(dir, { recursive: true })
const saveToFolder = (prefix) => ({ filepath, content }) =>
fs.writeFileSync(path.join(prefix, filepath), content, { encoding: 'utf8' })
// Formatting output
const bold = (str) => '\033[1m' + str + '\033[0m'
const toFilepath = name => path.join(folders.pages('.'), `${name.split('.').join('/')}.elm`)
// Flags + Validation
const flags = { command: '', name: '', pageType: '', filepaths: [] }
const isValidPageType = type =>
[ 'static', 'sandbox', 'element', 'component' ].some(x => x === type)
const isValidModuleName = (name = '') => {
const isAlphaOnly = word => word.match(/[A-Z|a-z]+/)[0] === word
const isCapitalized = word => word[0].toUpperCase() === word[0]
return name &&
name.length &&
name.split('.').every(word => isAlphaOnly(word) && isCapitalized(word))
}
// Help commands
const help = {
general: `
${bold('elm-spa')} version ${package.version}
${bold('elm-spa init')} create a new project
${bold('elm-spa add')} add a new page
${bold('elm-spa build')} generate routes and pages automatically
${bold('elm-spa <command> help')} get detailed help for a command
${bold('elm-spa -v')} print version number
`,
init: `
${bold('elm-spa init')} <directory>
Create a new elm-spa app in the <directory>
folder specified.
${bold('examples:')}
elm-spa init .
elm-spa init my-app
`,
add: `
${bold('elm-spa add')} <static|sandbox|element|component> <name>
Create a new page of type <static|sandbox|element|component>
with the module name <name>.
${bold('examples:')}
elm-spa add static Top
elm-spa add sandbox Posts.Top
elm-spa add element Posts.Dynamic
elm-spa add component SignIn
`,
build: `
${bold('elm-spa build')} [dir]
Create "Generated.Route" and "Generated.Pages" modules for
this project, based on the files in "src/Pages"
Here are more details on how that works:
https://www.npmjs.com/package/elm-spa#naming-conventions
${bold('examples:')}
elm-spa build
elm-spa build ../some/other-folder
elm-spa build ./help
`
}
const toUnixFilepath = (filepath) =>
filepath.split(path.sep).join('/')
// Available commands
const commands = {
'init': ([ folder ]) =>
folder && folder !== 'help'
? Promise.resolve()
.then(_ => {
const dest = path.join(process.cwd(), folder)
cp(path.join(__dirname, 'projects', 'new'), dest)
try { fs.renameSync(path.join(dest, '.npmignore'), path.join(dest, '.gitignore')) } catch (_) {}
})
.then(_ => `\ncreated a new project in ${path.join(process.cwd(), folder)}\n`)
.catch(_ => `\nUnable to initialize a project at ${path.join(process.cwd(), folder)}\n`)
: Promise.resolve(help.init),
'add': ([ type, name ]) =>
(type && name) && type !== 'help' && isValidPageType(type) && isValidModuleName(name)
? rejectIfMissing(folders.pages('.'))
.then(_ => new Promise(
Elm.Main.init({ flags: { ...flags, command: 'add', name: name, pageType: type } }).ports.addPort.subscribe)
)
.then(file => {
const containingFolder = path.join(folders.pages('.'), file.filepath.split('/').slice(0, -1).join('/'))
ensureDirectory(containingFolder)
saveToFolder((folders.pages('.')))(file)
})
.then(_ => `\nadded a new ${bold(type)} page at:\n${toFilepath(name)}\n`)
.catch(_ => `\nplease run ${bold('elm-spa add')} in the folder with ${bold('elm.json')}\n`)
: Promise.resolve(help.add),
'build': ([ dir = '.' ] = []) =>
dir !== 'help'
? Promise.resolve(folders.pages(dir))
.then(listFiles)
.then(names => names.filter(name => name.endsWith('.elm')))
.then(names => names.map(name => name.substring(folders.pages(dir).length)))
.then(filepaths => new Promise(
Elm.Main.init({ flags: { ...flags, command: 'build', filepaths: filepaths.map(toUnixFilepath) } }).ports.buildPort.subscribe
))
.then(files => {
ensureDirectory(folders.generated(dir))
files.forEach(saveToFolder(folders.src(dir)))
return files
})
.then(files => `\nelm-spa generated two files:\n${files.map(({ filepath }) => ' - ' + path.join(folders.src(dir), filepath)).join('\n')}\n`)
.catch(_ => `\nplease run ${bold('elm-spa build')} in the folder with ${bold('elm.json')}\n`)
: Promise.resolve(help.build),
'-v': _ => Promise.resolve(package.version),
'help': _ => Promise.resolve(help.general)
}
const main = ([ command, ...args ] = []) =>
(commands[command] || commands['help'])(args)
// .then(_ => args.data.slice)
.then(console.info)
.catch(reason => {
console.info(`\n${bold('Congratulations!')} - you've found a bug!
If you'd like, open an issue here with the following output:
https://github.com/ryannhg/elm-spa/issues/new?labels=cli
${bold(`### terminal output`)}
`)
console.log('```')
console.error(reason)
console.log('```\n')
})
main([...process.argv.slice(2)])

View File

@ -1,33 +0,0 @@
{
"name": "elm-spa",
"version": "4.1.1",
"description": "single page apps made easy",
"main": "index.js",
"bin": "./index.js",
"directories": {
"test": "tests"
},
"scripts": {
"build": "elm make src/Main.elm --optimize --output dist/elm.worker.js",
"test": "elm-test",
"test:watch": "elm-test --watch",
"dev": "chokidar src -c \"(npm run build || true)\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/ryannhg/elm-spa.git"
},
"keywords": [
"elm",
"spa",
"web",
"framework"
],
"author": "Ryan Haskell-Glatz",
"license": "ISC",
"bugs": {
"url": "https://github.com/ryannhg/elm-spa/issues"
},
"homepage": "https://github.com/ryannhg/elm-spa#readme",
"dependencies": {}
}

View File

@ -1,6 +0,0 @@
.DS_Store
dist
elm-stuff
node_modules
src/Generated

View File

@ -1,28 +0,0 @@
{
"name": "elm-spa-app",
"version": "1.0.0",
"description": "my new elm-spa application",
"main": "public/index.html",
"scripts": {
"start": "npm install && npm run build && npm run dev",
"build": "npm run build:elm-spa && npm run build:elm",
"build:elm-spa": "elm-spa build .",
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
},
"keywords": [
"elm",
"spa"
],
"author": "",
"license": "ISC",
"devDependencies": {
"chokidar-cli": "2.1.0",
"concurrently": "5.0.0",
"elm": "0.19.1-3",
"elm-live": "4.0.2",
"elm-spa": "4.1.1"
}
}

View File

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://not-much-css.netlify.com/not-much.css" />
<title>elm-spa</title>
</head>
<body>
<script src="/dist/elm.js"></script>
<script>
Elm.Main.init()
</script>
</body>
</html>

View File

@ -1,36 +0,0 @@
module Components exposing (layout)
import Browser exposing (Document)
import Generated.Route as Route exposing (Route)
import Html exposing (..)
import Html.Attributes as Attr exposing (class, href, style)
layout : { page : Document msg } -> Document msg
layout { page } =
{ title = page.title
, body =
[ div [ class "column spacing--large pad--medium container h--fill" ]
[ navbar
, div [ class "column", style "flex" "1 0 auto" ] page.body
, footer
]
]
}
navbar : Html msg
navbar =
header [ class "row center-y spacing--between" ]
[ a [ class "link font--h5", href (Route.toHref Route.Top) ] [ text "home" ]
, div [ class "row center-y spacing--medium" ]
[ a [ class "link", href (Route.toHref Route.Docs) ] [ text "docs" ]
, a [ class "link", href (Route.toHref Route.NotFound) ] [ text "a broken link" ]
, a [ class "button", href "https://twitter.com/intent/tweet?text=elm-spa is ez pz" ] [ text "tweet about it" ]
]
]
footer : Html msg
footer =
Html.footer [] [ text "built with elm " ]

View File

@ -1,98 +0,0 @@
module Global exposing
( Flags
, Model
, Msg
, init
, navigate
, subscriptions
, update
, view
)
import Browser exposing (Document)
import Browser.Navigation as Nav
import Components
import Generated.Route as Route exposing (Route)
import Task
import Url exposing (Url)
-- INIT
type alias Flags =
()
type alias Model =
{ flags : Flags
, url : Url
, key : Nav.Key
}
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
( Model
flags
url
key
, Cmd.none
)
-- UPDATE
type Msg
= Navigate Route
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Navigate route ->
( model
, Nav.pushUrl model.key (Route.toHref route)
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view :
{ page : Document msg
, global : Model
, toMsg : Msg -> msg
}
-> Document msg
view { page, global, toMsg } =
Components.layout
{ page = page
}
-- COMMANDS
send : msg -> Cmd msg
send =
Task.succeed >> Task.perform identity
navigate : Route -> Cmd Msg
navigate route =
send (Navigate route)

View File

@ -1,141 +0,0 @@
module Main exposing (main)
import Browser exposing (Document)
import Browser.Navigation as Nav exposing (Key)
import Generated.Pages as Pages
import Generated.Route as Route exposing (Route)
import Global
import Html
import Url exposing (Url)
main : Program Flags Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlRequest = LinkClicked
, onUrlChange = UrlChanged
}
-- INIT
type alias Flags =
()
type alias Model =
{ key : Key
, url : Url
, global : Global.Model
, page : Pages.Model
}
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( global, globalCmd ) =
Global.init flags url key
( page, pageCmd, pageGlobalCmd ) =
Pages.init (fromUrl url) global
in
( Model key url global page
, Cmd.batch
[ Cmd.map Global globalCmd
, Cmd.map Global pageGlobalCmd
, Cmd.map Page pageCmd
]
)
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url
| Global Global.Msg
| Page Pages.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked (Browser.Internal url) ->
( model, Nav.pushUrl model.key (Url.toString url) )
LinkClicked (Browser.External href) ->
( model, Nav.load href )
UrlChanged url ->
let
( page, pageCmd, globalCmd ) =
Pages.init (fromUrl url) model.global
in
( { model | url = url, page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
Global globalMsg ->
let
( global, globalCmd ) =
Global.update globalMsg model.global
in
( { model | global = global }
, Cmd.map Global globalCmd
)
Page pageMsg ->
let
( page, pageCmd, globalCmd ) =
Pages.update pageMsg model.page model.global
in
( { model | page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ model.global
|> Global.subscriptions
|> Sub.map Global
, model.page
|> (\page -> Pages.subscriptions page model.global)
|> Sub.map Page
]
view : Model -> Browser.Document Msg
view model =
let
documentMap :
(msg1 -> msg2)
-> Document msg1
-> Document msg2
documentMap fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
in
Global.view
{ page = Pages.view model.page model.global |> documentMap Page
, global = model.global
, toMsg = Global
}
fromUrl : Url -> Route
fromUrl =
Route.fromUrl >> Maybe.withDefault Route.NotFound

View File

@ -1,81 +0,0 @@
module Page exposing
( Page, Document, Bundle
, upgrade
, static, sandbox, element, component
)
{-|
@docs Page, Document, Bundle
@docs upgrade
@docs static, sandbox, element, component
-}
import Browser
import Global
import Spa
type alias Document msg =
Browser.Document msg
type alias Page flags model msg =
Spa.Page flags model msg Global.Model Global.Msg
type alias Bundle msg =
Spa.Bundle msg
upgrade :
(pageModel -> model)
-> (pageMsg -> msg)
-> Page pageFlags pageModel pageMsg
->
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, bundle : pageModel -> Global.Model -> Bundle msg
}
upgrade =
Spa.upgrade
static : { view : Document msg } -> Page flags () msg
static =
Spa.static
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> Document msg
}
-> Page flags model msg
sandbox =
Spa.sandbox
element :
{ init : flags -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
, view : model -> Document msg
}
-> Page flags model msg
element =
Spa.element
component :
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
, subscriptions : Global.Model -> model -> Sub msg
, view : Global.Model -> model -> Document msg
}
-> Page flags model msg
component =
Spa.component

View File

@ -1,30 +0,0 @@
module Pages.Docs exposing (Flags, Model, Msg, page)
import Html
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "Docs"
, body = [ Html.text "Docs" ]
}

View File

@ -1,30 +0,0 @@
module Pages.NotFound exposing (Flags, Model, Msg, page)
import Html
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "NotFound"
, body = [ Html.text "NotFound" ]
}

View File

@ -1,30 +0,0 @@
module Pages.Top exposing (Flags, Model, Msg, page)
import Html
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "Top"
, body = [ Html.text "Top" ]
}

View File

@ -1,63 +0,0 @@
module Add.Component exposing (create)
import Path exposing (Path)
create : Path -> String
create path =
"""
module Pages.{{name}} exposing (Flags, Model, Msg, page)
import Global
import Html
import Page exposing (Document, Page)
type alias Flags =
{{flags}}
type alias Model =
{}
type Msg
= NoOp
page : Page Flags Model Msg
page =
Page.component
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
init : Global.Model -> Flags -> ( Model, Cmd Msg, Cmd Global.Msg )
init global flags =
( {}, Cmd.none, Cmd.none )
update : Global.Model -> Msg -> Model -> ( Model, Cmd Msg, Cmd Global.Msg )
update global msg model =
case msg of
NoOp ->
( model, Cmd.none, Cmd.none )
subscriptions : Global.Model -> Model -> Sub Msg
subscriptions global model =
Sub.none
view : Global.Model -> Model -> Document Msg
view global model =
{ title = "{{name}}"
, body = [ Html.text "{{name}}" ]
}
"""
|> String.replace "{{name}}" (Path.toModulePath path)
|> String.replace "{{flags}}" (Path.toFlags path)
|> String.trim

View File

@ -1,42 +0,0 @@
module Add.Static exposing (create)
import Path exposing (Path)
create : Path -> String
create path =
"""
module Pages.{{name}} exposing (Flags, Model, Msg, page)
import Html
import Page exposing (Document, Page)
type alias Flags =
{{flags}}
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "{{name}}"
, body = [ Html.text "{{name}}" ]
}
"""
|> String.replace "{{name}}" (Path.toModulePath path)
|> String.replace "{{flags}}" (Path.toFlags path)
|> String.trim

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,28 @@
{
"type": "package",
"name": "ryannhg/elm-spa",
"summary": "a way to build single page apps with Elm",
"license": "BSD-3-Clause",
"version": "4.1.0",
"exposed-modules": [
"Spa",
"Spa.Advanced"
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.0 <= v < 0.20.0",
"elm-version": "0.19.1",
"dependencies": {
"elm/browser": "1.0.0 <= v < 2.0.0",
"elm/core": "1.0.0 <= v < 2.0.0",
"elm/html": "1.0.0 <= v < 2.0.0",
"elm/url": "1.0.0 <= v < 2.0.0"
"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"
}
},
"test-dependencies": {}
}
"test-dependencies": {
"direct": {
"elm-explorations/test": "1.2.2"
},
"indirect": {
"elm/random": "1.0.0"
}
}
}

View File

@ -1,7 +0,0 @@
# examples
a few example projects using elm-spa.
1. [examples/html](./html)
1. [examples/elm-ui](./elm-ui)
1. [examples/transitions](./transitions)

8
examples/elm-spa-dev/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Folders to ignore
elm-stuff
node_modules
public/dist
src/Spa/Generated
# MacOS weird stuff
.DS_Store

View File

@ -0,0 +1,41 @@
# new elm-spa project
> More documentation at https://elm-spa.dev
## local development
You can get this site up and running with one command:
```
npm start
```
### other commands to know
There are a handful of commands in the [package.json](./package.json).
Command | Description
:-- | :--
`npm run dev` | Run a dev server and automatically build changes.
`npm run test:watch` | Run tests as you code.
`npm run build` | Build the site for production.
`npm run test` | Run the test suite once, great for CI
## deploying
After you run `npm run build`, the contents of the `public` folder can be hosted as a static site. If you haven't hosted a static site before, I'd recommend using [Netlify](https://netlify.com) (it's free!)
### using netlify
Add a `netlify.toml` file next to this README, for standard SPA routing:
```toml
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
```
__Build command:__ `npm run build`
__Publish directory:__ `public`

View File

@ -9,17 +9,26 @@
"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",
"ryannhg/elm-spa": "4.1.1"
"elm-explorations/markdown": "1.0.0"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
"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"
}
}
}
}

View File

@ -0,0 +1,10 @@
[[redirects]]
from = "/content/*"
to = "/content/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

2311
examples/elm-spa-dev/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
{
"name": "our-elm-spa-app",
"version": "1.0.0",
"description": "A project created with elm-spa",
"scripts": {
"start": "npm install && npm run build:dev && npm run dev",
"test": "elm-test",
"test:watch": "elm-test --watch",
"build": "run-s build:elm-spa build:elm",
"build:dev": "run-s build:elm-spa build:dev:elm",
"dev": "run-p dev:elm-spa dev:elm",
"build:elm": "elm make src/Main.elm --optimize --output=public/dist/elm.compiled.js",
"build:dev:elm": "elm make src/Main.elm --debug --output=public/dist/elm.compiled.js || true",
"build:elm-spa": "elm-spa build .",
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output=public/dist/elm.compiled.js",
"dev:elm-spa": "chokidar src/Pages -c \"elm-spa build .\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"chokidar-cli": "2.1.0",
"elm": "0.19.1-3",
"elm-live": "4.0.2",
"elm-spa": "next",
"elm-test": "0.19.1-revision2",
"npm-run-all": "4.1.5"
}
}

View File

@ -0,0 +1,21 @@
# Guide
Welcome to the __elm-spa__ guide! This guide assumes you are familiar with Elm and have checked out [the official guide](https://guide.elm-lang.org).
If you have any questions after reading this, please join us the `#elm-spa-users` channel in the [Elm Slack](https://elmlang.herokuapp.com/). There are also amazing folks in the `#beginners` channel that are happy to help with any Elm questions you may have.
## what is elm-spa?
Inspired by projects like [Nuxt.js](https://nuxtjs.org/), __elm-spa__ is:
1. A common __framework__ you can use across projects.
1. A __CLI__ that eliminates boilerplate and routing code.
1. This __website__, with guides on building SPAs in Elm.
If you've ever wondered how to call a REST API, handle user authentication, create components, or organize your application- __elm-spa__ is here to help.
Throughout this guide, [blue links](https://elm-lang.org) will take you to external resources from other sites, while [green links](/guide/getting-started) will take you to other pages on this site!
---
Ready to [get started](/guide/getting-started)?

View File

@ -0,0 +1,77 @@
# Authentication
User authentication can be handled in many ways! In this example, we'll define custom pages that:
1. Are only visible when a user is logged in
1. Redirect to a sign in page otherwise.
1. Still benefit from elm-spa's route generation!
Let's assume we have a `user : Maybe User` field in the `Shared.Model`, and have a sign-in page at `SignIn.elm`!
## Creating Custom Pages
Because `Spa/Page.elm` is in _your_ project, you can use it to define your own custom page functions.
As long as those functions return the same `Page` type, they are valid! The only downside is that they won't be available for `elm-spa add` command.
If your app involves user authentication, you could make a `protectedSandbox` page type that always gets a `User`, and redirects if one is missing:
```elm
-- within Spa/Page.elm
protectedSandbox :
{ init : User -> Url params -> model
, update : Msg -> model -> model
, view : model -> Document msg
}
-> Page params (Maybe model) msg
protectedSandbox options =
{ init =
\shared url ->
case shared.user of
Just user ->
options.init user url |> Tuple.mapFirst Just
Nothing ->
( Nothing
, Nav.pushUrl url.key (Route.toString Route.SignIn)
)
, update = -- ... conditionally call options.update
, view = -- ... conditionally call options.view
, subscriptions = \_ -> Sub.none
, save = \_ shared -> shared
, load = \_ model -> ( model, Cmd.none )
}
```
As long as you return a `Page` type, your page will work with the rest of elm-spa's automated routing!
### Usage
```elm
-- (within an actual page)
type alias Model = Maybe SafeModel
page : Page Params Model Msg
page =
Page.protectedSandbox
{ init = init
, update = update
, view = view
}
```
```elm
init : User -> Url Params -> SafeModel
update : Msg -> SafeModel -> SafeModel
view : SafeModel -> Document Msg
```
One caveat is that the `Model` type exposed by your page is used by the generated code, so your actual model will need a different name (like `SafeModel`).
But now you know that these functions will only be called if the `User` is really logged in!
---
That's it! Swing by the [`#elm-spa-users`](https://elmlang.herokuapp.com/) channel and say hello!

View File

@ -0,0 +1,59 @@
# Beyond HTML
If you're not an CSS ninja, you may have experienced a bad time styling things on the web. Luckily, there's a __wonderful__ project in the Elm community called [Elm UI](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that makes it possible to create UIs without any HTML or CSS at all!
When you create a page with `elm-spa init`, you can choose between the 3 popular options for building Elm applications:
- `html` - uses [elm/html](https://package.elm-lang.org/packages/elm/html/latest)
- `elm-ui` - uses [mdgriffith/elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest)
- `elm-css` - uses [rtfeldman/elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest)
```terminal
elm-spa init my-project --template=elm-ui
```
The `template` option scaffolds out the same starter project, except two files have been modified:
1. `elm.json` has the `mdgriffith/elm-ui` package installed.
2. `Spa/Document.elm` uses `Element` instead of `Html`.
## Using Something Custom
Need something other than the three built-in options? Maybe your company is making a custom design system, and you don't want pages to return `Html` or `Element`, you would rather return a `Ui` type instead.
You can update `src/Spa/Document.elm` with your own custom view library, and the rest of the elm-spa features will still work.
Here's an example with a made up `Ui` library:
```elm
module Spa.Document exposing (Document, map, toBrowserDocument)
import Browser
import Ui exposing (Ui)
type alias Document msg =
{ title : String
, body : List (Ui msg)
}
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
map fn doc =
{ title = doc.title
, body = List.map (Ui.map fn) doc.body
}
toBrowserDocument : Document msg -> Browser.Document msg
toBrowserDocument doc =
{ title = doc.title
, body = List.map Ui.toHtml doc.body
}
```
As long as your library can implement those three exposed functions, you're all set. Your pages can all use your awesome view package!
---
Next up, we'll take a look at [Authentication](/guide/authentication)

View File

@ -0,0 +1,192 @@
# Components
In Elm, components don't have to be complicated! In fact, most of the time, you can use boring functions:
```elm
module Components.Footer exposing (view)
import Html exposing (..)
import Html.Attributes exposing (class)
view : Html msg
view =
footer [ class "footer" ] [ text "built with elm-spa" ]
```
## Passing in data
If you have data you need to display in a component, you can pass them in as arguments:
```elm
module Components.Navbar exposing (view)
import Api.User exposing (User)
import Html exposing (..)
import Html.Attributes as Attr exposing (class)
import Spa.Generated.Route as Route
view : { user : Maybe User } -> Html msg
view options =
header [ class "navbar" ]
[ a [ href Route.Top ] [ text "Home" ]
, a [ href Route.NotFound ] [ text "Not found" ]
, case options.user of
Just user -> button [] [ text "Sign out" ]
Nothing -> button [] [ text "Sign in" ]
]
href : Route -> Html.Attribute msg
href route =
Attr.href (Route.toString route)
```
## Handling messages
What's the easiest way to make a component reusable? Pass in the messages it sends! Rather than giving it it's own hardcoded `Msg` type, pass in the `msg` as an argument.
This enables the caller to decide how to handle events from components, and makes it easier to test component functions without needing to mock the entire application.
```elm
import Html.Events as Events
view :
{ user : Maybe User
, onSignIn : msg
, onSignOut : msg
}
-> Html msg
view options =
header [ class "navbar" ]
[ a [ href Route.Top ] [ text "Home" ]
, a [ href Route.NotFound ] [ text "Not found" ]
, case options.user of
Just _ ->
button [ Events.onClick options.onSignOut ]
[ text "Sign out" ]
Nothing ->
button [ Events.onClick options.onSignIn ]
[ text "Sign in" ]
]
```
## Fancy Components
In JavaScript frameworks like React or Vue.js, it's common to have a component track its own data, view, and handle updates to that view. In Elm, we _could_ follow that methodology with `Model/Msg` and `init/update/view`, but it's not ideal.
Unlike in JS, our `view` functions can only return one type of `msg`. This means using `Html.map` and `Cmd.map` every time you want to use a component. That can become a mess when you begin nesting components!
Modules should be [built around data structures](https://www.youtube.com/watch?v=XpDsk374LDE), and it's easier to reuse functions rather than nesting `update` functions:
### Making a Carousel Component
```elm
module Components.Carousel exposing
( Carousel
, create
, next, previous, select
, view
)
type Carousel slide
create : slide -> List slide -> Carousel slide
next : Carousel slide -> Carousel slide
previous : Carousel slide -> Carousel slide
select : Int -> Carousel slide -> Carousel slide
view :
{ carousel : Carousel slide
, onNext : msg
, onPrevious : msg
, onSelect : Int -> msg
, viewSlide : slide -> Html msg
}
-> Html msg
```
The above example shows a file that provides:
1. A new data structure `Carousel`
1. Functions to update that structure:
`next`, `previous`, and `select`
1. The way to `view` that structure
The implementation for `Carousel` isn't exposed, so callers won't break if you change it later. If you'd like to see the full Carousel implementation, [here it is](https://gist.github.com/ryannhg/b26c0d6a5d2bfd74643e7da6543c5170).
### Using a Carousel Component
Here's how you might call it in a page:
```elm
import Components.Carousel as Carousel exposing (Carousel)
type alias Model =
{ testimonials : Carousel Testimonial
}
type alias Testimonial =
{ quote : String
, author : String
}
init : Model
init =
{ testimonials =
Carousel.create
{ quote = "Cats have ears", author = "Ryan" }
[ { quote = "Dogs also have ears", author = "Alexa" }
, { quote = "I have ears", author = "Erik" }
]
}
```
```elm
type Msg
= NextTestimonial
| PreviousTestimonial
| SelectTestimonial Int
update : Msg -> Model -> Model
update msg model =
case msg of
NextTestimonial ->
{ model | testimonials = Carousel.next model.testimonials }
PreviousTestimonial ->
{ model | testimonials = Carousel.previous model.testimonials }
SelectTestimonial index ->
{ model | testimonials = Carousel.select index model.testimonials }
```
```elm
view : Model -> Html Msg
view model =
div [ class "page" ]
[ Carousel.view
{ carousel = model.testimonials
, onNext = NextTestimonial
, onPrevious = PreviousTestimonial
, onSelect = SelectTestimonial
, viewSlide = viewTestimonial
}
]
viewTestimonial : Testimonial -> Html msg
viewTestimonial options =
div [ class "testimonial" ]
[ p [ class "quote" ] [ text options.quote ]
, p [ class "author" ] [ text options.author ]
]
```
Just like before, we pass our `msg` types into the component, rather than give them their own special `Msg` types. Let your page handle those updates and your code will be much easier to read.
---
Next, let's talk about [using APIs](/guide/using-apis)

View File

@ -0,0 +1,87 @@
# Getting Started
Getting started with __elm-spa__ is easy! Make sure you have the latest stable version of [NodeJS](https://nodejs.org/en/) installed on your system. At the time of writing, that's version `12.18.2`.
```terminal
npx elm-spa init
```
## Project Structure
This one-time command will create a new project in a folder called `our-elm-spa`. Here's an overview of that folder:
```
elm.json
package.json
public/
├─ index.html
├─ main.js
└─ style.css
src/
├─ Pages/
| ├─ Top.elm
| └─ NotFound.elm
├─ Spa/
| ├─ Document.elm
| ├─ Page.elm
| └─ Url.elm
├─ Main.elm
└─ Shared.elm
tests/
└─ README.md
```
### The project folder
There are a few interesting things in the project folder:
File | Description
:-- | :--
`elm.json` | Defines all of our Elm project dependencies.
`package.json` | Has `build`, `dev`, and `test` scripts so anyone with [NodeJS](https://nodejs.org) installed can easily run our project.
`src/` | Where our frontend Elm application lives.
`tests/` | Where our Elm tests live.
`public/` | A static directory for serving HTML, JS, CSS, images, and more!
### The `src` Folder
The `src` folder will contain all your Elm code:
File | Description
:-- | :--
`Pages/Top.elm` | The homepage for our single page application.
`Pages/NotFound.elm` | The page to show if we're at an invalid route.
`Spa/Document.elm` | The kind of thing each page's `view` returns (changing this allows support for [elm-ui](https://github.com/mdgriffith/elm-ui) or [elm-css](https://github.com/rtfeldman/elm-css))
`Spa/Page.elm` | Defines the four page types (`static`, `sandbox`, `element`, and `application`)
`Spa/Url.elm` | Defines a type that holds route parameters, query parameters (automatically passed into the `init` function of `element` and `application` pages)
`Main.elm` | The entrypoint to the app, that wires everything together.
`Shared.elm` | The place to define layouts and shared data between pages.
### The `public` folder
The public folder is served statically. Use this folder to serve images, CSS, JS, and other static assets.
File | Description
:-- | :--
`index.html` | The HTML loaded by the server.
`main.js` | The JS that starts our Elm single page application.
`style.css` | A place to add in some CSS styles.
#### Using assets
Here are examples of how to access files in the public folder via URL:
File Location | URL
:-- | :---
`public/main.js` | `/main.js`
`public/style.css` | `/style.css`
`public/images/puppy.png` | `/images/puppy.png`
__Include the starting slash in your URL!__ If it's missing, it will look for your assets relative to the current URL, which means some pages will work and others won't. (`main.js` vs `/main.js`)
---
Next up is [Installation](/guide/installation), which will introduce the CLI.

View File

@ -0,0 +1,75 @@
# Installation
You can install `elm-spa` via [npm](https://nodejs.org/):
```terminal
npm install -g elm-spa@5.0.0
```
Now, you can run `elm-spa` from the terminal!
## Hello, CLI
If you're ever stuck- run `elm-spa help`, the CLI comes with __built-in documentation__!
```terminal
elm-spa help
elm-spa version 5.0.0
elm-spa init create a new project
elm-spa add add a new page
elm-spa build generate routes and pages automatically
elm-spa version print version number
```
## elm-spa init
The `init` command scaffolds a new __elm-spa__ project.
```terminal
elm-spa init
```
When you run the command, you will be presented with an interactive dialogue to choose between:
1. The UI Library ([elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest), [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest), or [html](https://package.elm-lang.org/packages/elm/html/latest))
2. The folder name
Each project works and behaves the same way, but `elm.json`, `Spa.Document`, and the `Shared.view` are updated to use the UI library of your choice.
## elm-spa add
You can add more pages to an existing __elm-spa__ project with the `elm-spa add` command.
```terminal
elm-spa add
```
Just like the last command, an interactive dialogue will ask you two things:
1. The type of page (static, sandbox, element, or application)
1. The page's module name
The meaning of each of the page types will be explained in the [Pages](/guide/pages) section!
__Note:__ Running the `elm-spa add` command will overwrite the contents of the existing file, so don't use it for upgrading an existing page.
## elm-spa build
This command does the automatic code generation for you. If you follow the naming conventions outlined in the next section, this is where elm-spa saves you time!
```terminal
elm-spa build
```
The generated code is in the `src/Spa/Generated` folder! Feel free to take a look, it's human readable Elm code!
__No need to call this!__ The project created by `elm-spa init` actually calls this under the hood.
Just use `npm start`, and you're good!
---
Next, let's talk about the [Routing](/guide/routing)!

View File

@ -0,0 +1,99 @@
# Pages
By default, there are four kinds of pages you can create with __elm-spa__. Always choose the simplest one for the job!
## Static
A simple, static page that just returns a view.
```elm
page : Page Params Model Msg
page =
Page.static
{ view = view
}
```
```elm
view : Url Params -> Document Msg
```
## Sandbox
A page that needs to maintain local state.
```elm
page : Page Params Model Msg
page =
Page.sandbox
{ init = init
, update = update
, view = view
}
```
```elm
init : Url Params -> Model
update : Msg -> Model -> Model
view : Model -> Document Msg
```
## Element
A page that can make side effects with `Cmd` and listen for updates as `Sub`.
```elm
page : Page Params Model Msg
page =
Page.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
```
```elm
init : Url Params -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Cmd Msg )
view : Model -> Document Msg
subscriptions : Model -> Sub Msg
```
## Application
A page that can read and write to the shared model.
```elm
page : Page Params Model Msg
page =
Page.application
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
, save = save
, load = load
}
```
```elm
init : Shared.Model -> Url Params -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Cmd Msg )
view : Model -> Document Msg
subscriptions : Model -> Sub Msg
save : Model -> Shared.Model -> Shared.Model
load : Shared.Model -> Model -> ( Model, Cmd Msg )
```
Because `save` and `load` are new constructs, here's an explanation of how they work:
#### __save__
Anytime you update the `Model` with `init` or `update`, `save` is automatically called (by `Main.elm`). This allows you to persist local state to the entire application.
#### __load__
Much like `update`, the `load` function gets called whenever the `Shared.Model` changes. This allows you to respond to external changes to update your local state or send a command!
---
Let's take a deeper look at [Shared](/guide/shared) together.

View File

@ -0,0 +1,117 @@
# Routing
With __elm-spa__, the names of pages in the `src/Pages` folder automatically generate your routes! Check out the following examples to learn more.
## Static Routes
You can create a static route like `/contact` or `/not-found` by creating an elm file in `src/Pages`:
File | URL
:-- | :--
`People.elm` | `/people`
`About/Careers.elm` | `/about/careers`
`OurTeam.elm` | `/our-team`
__Capitalization matters!__ Notice how `OurTeam` became `our-team`? Capital letters within file names are translated to dashes in URLs.
## Top Level Routes
Routes like the homepage use the reserved `Top` keyword to indicate that a page should not be a static route.
File | URL
:-- | :--
`Top.elm` | `/`
`Example/Top.elm` | `/example`
`Top/Top.elm` | `/top`
__Reserved, but possible!__ If you actually need a `/top` route, you can still make one by using `Top.elm` within a `Top` folder. (As shown above!)
## Dynamic Routes
Sometimes it's nice to have one page that works for slightly different URLs. __elm-spa__ uses this convention in file names to indicate a dynamic route:
__`Authors/Name_String.elm`__
URL | Params
:-- | :--
`/authors/ryan` | `{ name = "ryan" }`
`/authors/alexa` | `{ name = "alexa" }`
__`Posts/Id_Int.elm`__
URL | Params
:-- | :--
`/posts/123` | `{ id = 123 }`
`/posts/456` | `{ id = 456 }`
You can access these dynamic parameters from the `Url Params` value passed into each page type!
__Supported Parameters__: Only `String` and `Int` dynamic parameters are supported.
### Nested Dynamic Routes
You can also nest your dynamic routes. Here's an example:
__`Users/User_String/Posts/Post_Id.elm`__
URL | Params
:-- | :--
`/users/ryan/posts/123` | `{ user = "ryan"`<br/>`, id = 123`<br/>`}`
`/users/alexa/posts/456` | `{ user = "alexa"`<br/>`, id = 456`<br/>`}`
## URL Params
As we'll see in the next section, every page will get access to `Url Params` these allow you access a few things:
```elm
type alias Url params =
{ params : params
, query : Dict String String
, key : Browser.Navigation.Key
, rawUrl : Url.Url
}
```
#### params
Each dynamic page has it's own params, pulled from the URL. There are examples in the "Params" column above.
```elm
type alias Params =
{ name : String
}
view : Url Params -> Document Msg
view url =
{ title = "Author: " ++ url.params.name
, body = -- ...
}
```
#### query
A dictionary of query parameters. Here are some examples:
```elm
-- https://elm-spa.dev
Dict.get "name" url.query == Nothing
-- https://elm-spa.dev?name=ryan
Dict.get "name" url.query == Just "ryan"
-- https://elm-spa.dev?name
Dict.get "name" url.query == Just ""
```
#### key
Required for programmatic navigation with `Nav.pushUrl` and other functions from [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#pushUrl)
#### rawUrl
The original URL in case you need any other information like the protocol, port, etc.
---
Let's take a closer look at [Pages](/guide/pages)!

View File

@ -0,0 +1,142 @@
# Shared
Whether your sharing layouts or information between pages, the `Shared` module is the place to be!
## Flags
If you have initial data you hope to pass into your Elm application, you should provide it via `Flags`,
When you create a project with `elm-spa init`, a file will be created at `public/main.js`:
```javascript
// (in public/main.js)
var flags = null
var app = Elm.Main.init({ flags: flags })
```
The value passed into the `flags` needs to match up with the type of `Shared.Flags`, for it to be passed into `Shared.init`.
Here's an example:
```javascript
// (in public/main.js)
var flags = { project: "elm-spa", year: 2020 }
```
```elm
-- (in src/Shared.elm)
type alias Flags =
{ project : String
, year : Int
}
```
Once you get comfortable with flags, I recommend always using `Json.Value` from the [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package as your Flags:
```elm
import Json.Decode as Json
type alias Flags =
Json.Value
type alias InitialData =
{ project : String
, year : Int
}
decoder : Json.Decoder InitialData
decoder =
Json.map2 InitialData
(Json.field "project" Json.string)
(Json.field "year" Json.int)
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
case Json.decodeValue decoder flags of
Ok initialData -> -- Initialize app
Err reason -> -- Handle failure
```
This way, you can create a decoder to gracefully handle the JSON being sent into your Elm application.
Learn more about [Flags](https://guide.elm-lang.org/interop/flags.html) in the official Elm guide.
## Model
All data in `Shared.Model` will persist across page navigation.
By default, it only contains `key` and `url`, which are required for the programmatic navigation and reading URL information in your application.
This makes it a great choice for things like logged-in users, dark-mode, or any other data displayed on shared components needed by navbars or footers.
```elm
type alias Model =
{ key : Key
, url : Url
, user : Maybe User
}
```
Here we added a `user` field that we can update with the next function!
## update
The `Shared.update` function is just like a normal `update` function in Elm. It takes in messages and returns the latest version of the `Model`. In this case, the `Model` is the `Shared.Model` mentioned above.
```elm
type Msg
= SignIn User
| SignOut
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SignIn user ->
( { model | user = Just user }
, Cmd.none
)
SignOut ->
( { model | user = Nothing }
, Cmd.none
)
```
This is just an example of using `update` with the `user` field we added earlier. Let's call those messages from our view.
## view
The `Shared.view` function is a great place to render things that should persist across page transitions. It comes with more than just a `Model`, so you can insert the `page` whereever you'd like:
```elm
import Components.Navbar as Navbar
import Components.Footer as Footer
view :
{ page : Document msg
, toMsg : Msg -> msg
}
-> Model
-> Document msg
view { page, toMsg } model =
{ title = page.title
, body =
[ Navbar.view
{ user = model.user
, onSignIn = toMsg SignIn
, onSignOut = toMsg SignOut
}
, div [ class "page" ] page.body
, Footer.view
]
}
```
Using the `toMsg` function passed in the first argument, we're able to convert `Shared.Msg` to the same `msg` that our `view` function returns.
If you want components to send `Shared.Msg`, make sure to use that function first!
---
Let's take a look at [Components](/guide/components) now!

View File

@ -0,0 +1,182 @@
# Using APIs
Most applications interact with a REST API or a GraphQL endpoint to access data.
For this guide, we'll be using the [Reddit REST API](https://www.reddit.com/dev/api/#GET_hot) to fetch the latest posts from [r/elm](https://www.reddit.com/r/elm).
## Defining a module
Just like before, we'll define modules based on data structures:
```elm
module Api.Reddit.Listing exposing
( Listing
, hot, new, top
)
```
## Storing the data
In Elm, there's a better way to model API data other than just toggling a `loading` boolean from `true` to `false`. Using [the RemoteData pattern](https://www.youtube.com/watch?v=NLcRzOyrH08), we can represent all states data from the web might be in, and display the right thing to our users:
```elm
module Api exposing (Data(..), expectJson)
type Data value
= NotAsked
| Loading
| Failure Http.Error
| Success value
expectJson : (Data value -> msg) -> Decoder value -> Expect msg
```
The `expectJson` function is a replacement for [Http.expectJson](https://package.elm-lang.org/packages/elm/http/latest/Http#expectJson) which uses `Result` instead.
## Working with JSON
The [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package allows us to handle JSON from APIs, without crashing our application if the JSON isn't what we initially expected. We do that by creating decoders:
```elm
import Json.Decode as Json
type alias Listing =
{ title : String
, author : String
, url : String
}
decoder : Json.Decoder Listing
decoder =
Json.map3 Listing
(Json.field "title" Json.string)
(Json.field "author_fullname" Json.string)
(Json.field "url" Json.string)
```
## Actually fetching listings
Let's combine our new `Api` and `decoder` to actually fetch those Reddit posts! We'll use the [elm/http](https://package.elm-lang.org/packages/elm/http/latest) to make the GET request.
```elm
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
hot options =
Http.get
{ url = "https://api.reddit.com/r/elm/hot"
, expect =
Api.expectJson options.onResponse
(Json.at [ "data", "children" ] (Json.list decoder))
}
```
The actual listings are located inside `data.children`, so we used `Json.at` and `Json.list` to before we use our `decoder`.
```javascript
{ "data": { "children": [ ... ] } }
```
We can reuse that code to implement `new` and `top`. Let's move the reusable bits into `listings`, and just pass in the endpoint as a string.
```elm
-- API ENDPOINTS
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
hot =
listings "hot"
new : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
new =
listings "new"
top : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
top =
listings "top"
listings :
String
-> { onResponse : Api.Data (List Listing) -> msg }
-> Cmd msg
listings endpoint options =
Http.get
{ url = "https://api.reddit.com/r/elm/" ++ endpoint
, expect =
Api.expectJson options.onResponse
(Json.at [ "data", "children" ] (Json.list decoder))
}
```
## Calling the API
Now that we have our new `Api.Reddit.Listing` module, we can use it in our pages. Here's an example of what that looks like:
```elm
import Api
import Api.Reddit.Listing exposing (Listing)
type alias Model =
{ listings : Api.Data (List Listing)
}
init : Url Params -> ( Model, Cmd Msg )
init url =
( Model Api.Loading
, Api.Reddit.Listing.hot
{ onResponse = GotHotListings
}
)
```
This sends an initial request to fetch the top Reddit posts from r/elm. We need to handle the response in our update function.
```elm
type Msg
= GotHotListings (Api.Data (List Listing))
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotHotListings data ->
( { model | listings = data }
, Cmd.none
)
```
Notice how we stored the entire `Api.Data` response, whether it succeeded or failed? That's perfect for the next bit, where we have control over how to show the user the state of the listings:
```elm
view : Model -> Document Msg
view model =
{ title = "Posts"
, body =
[ div [ class "page" ]
[ viewListings model.listings
]
]
}
viewListings : Api.Data (List Listing) -> Html msg
viewListings data =
case data of
Api.NotAsked -> text "Not asked"
Api.Loading -> text "Loading..."
Api.Failure _ -> text "Something went wrong..."
Api.Success listings ->
div [ class "listings" ]
(List.map viewListing listings)
viewListing : Listing -> Html msg
viewListing listing =
div [ class "listing" ]
[ a [ class "title", href listing.url ]
[ text listing.title ]
, p [ class "author" ]
[ text ("Author: " ++ listing.author) ]
]
```
That's it! Here are the [actual files](https://gist.github.com/ryannhg/3ce83ec17ed473717e5604c7047e4d2c) used for this section.
---
Next we'll go [Beyond HTML](/guide/beyond-html), to explore other view options.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>elm-spa</title>
<meta name="description" content="single page apps made easy.">
<link rel="shortcut icon" href="/favicon.png" type="image/x-png">
<!-- CSS goes here -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/atom-one-dark.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Baloo+Da+2:wght@800&amp;family=Fira+Code:wght@400;700&amp;family=Nunito:ital,wght@0,400;0,800;1,400&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- JavaScript goes here -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/languages/elm.min.js"></script>
<script src="/dist/elm.compiled.js"></script>
<script src="/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
// Initial data to pass in to Elm (linked with `Shared.Flags`)
// https://guide.elm-lang.org/interop/flags.html
var flags = null
// Start our Elm application
var app = Elm.Main.init({ flags: flags })
// Ports go here: https://guide.elm-lang.org/interop/ports.html

View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.elm-spa.dev/dist/sitemap.xml

View File

@ -0,0 +1,100 @@
@import "https://nope.rhg.dev/dist/1.0.0/core.min.css";
html, body {
color: #222;
background-color: #f8f0f4;
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
body { overflow-y: scroll; }
table { line-height: 1.2; }
.font-h5, .font-h6, .font-body, .text-body {
font-weight: normal;
font-family: inherit;
}
.font-h1, .font-h2, .font-h3, .font-h4, .text-header {
font-family: 'Baloo Da 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-weight: 800;
letter-spacing: -0.05em;
line-height: 0.9;
}
.font-h1 { font-size: 6rem; }
.font-h5 { font-size: 1.5rem; }
.text--bigger { font-size: 1.2rem; }
/* links, buttons, and hoverable elements */
.link { text-decoration: underline; font-weight: bold; color: #409844; }
.hoverable, .link, .button {
cursor: pointer;
transition: opacity 200ms ease-in-out;
}
.hoverable:hover:not([disabled]),
.link:hover:not([disabled]),
.button:hover:not([disabled]) {
opacity: 0.6;
}
.link.disabled {
opacity: 0.5;
pointer-events: none;
}
.size--120 { min-width: 6rem; min-height: 6rem; max-width: 6rem; max-height: 6rem; }
.width--sidebar { min-width: 12rem; max-width: 12rem; }
.color--faint { color: rgba(0, 0, 0, 0.6); }
.color--green { color: #409844; }
.fadeable { transition: transform 200ms ease-in-out; }
.no-width { width: 0; overflow: hidden; }
.invisible { opacity: 0; visibility: hidden; }
.readable { max-width: 36em; }
.sticky { position: sticky; top: 0; }
code.lang-terminal { position: relative; padding-left: 1.2rem }
code.lang-terminal::before { position: absolute; top: 0.0675em; left: 0; content: "$"; margin-left: 0; opacity: 0.5; pointer-events: none; }
/* markdown styles */
.markdown h1 { font-weight: bold; font-size: 4rem; letter-spacing: -0.05em; font-family: 'Baloo Da 2', sans-serif; }
.markdown h2 { font-weight: bold; font-size: 2.5rem; margin-top: 1rem; letter-spacing: -0.05em; }
.markdown h3 { font-weight: bold; font-size: 2rem; margin-top: 1rem; letter-spacing: -0.05em; }
.markdown h4 { font-weight: bold; font-size: 1.5rem; margin-top: 0.5rem; letter-spacing: -0.05em; }
.markdown h5 { font-weight: bold; font-size: 1.25rem; }
.markdown h6 { font-weight: bold; font-size: 1rem; }
.markdown p, .markdown ul, .markdown ol { color: #444; line-height: 1.4; font-size: 1.25rem; letter-spacing: -0.01em; }
.markdown blockquote { line-height: 1.4; opacity: 0.5; padding: 0.25rem 1rem; border-left: solid 0.125rem #888; }
.markdown pre { line-height: 1.3; padding: 1rem; background: #333; color: white; border-radius: 0.125em; }
.markdown code { font-family: 'Fira Code', monospace; font-size: 0.9em; letter-spacing: 0; }
.markdown p code, .markdown ul code, .markdown ol code { border: solid 1px #ccc; padding: 0 0.25em; border-radius: 0.125em; white-space: nowrap; }
.markdown a { text-decoration: underline; font-weight: bold; color: #409844; }
.markdown a[href^=https] { text-decoration: underline; font-weight: bold; color: dodgerblue; }
.markdown table { border-radius: 0.125em; border: solid 1px #ccc; }
.markdown td { vertical-align: top }
.markdown td, .markdown th { padding: 0.5rem; }
.markdown tbody tr:nth-child(2n+1) { background: #f2e3eb; }
.markdown hr { height: 2px; width: 12rem; max-width: 100%; background-color: #ccc; border: 0; margin-left: 0; margin-right: 0; }
/* responsive font scaling */
html { font-size: 16px; }
@media screen and (min-width: 641px) { html { font-size: 18px; } }
@media screen and (min-width: 1600px) { html { font-size: 20px; } }
/* accessible code highlighting */
.hljs-comment, .hljs-quote { color: #959ba7; }
.home-pre {
background: linear-gradient(#333, #222);
color: white;
padding: 0.5em 1em;
border-radius: 25px;
box-shadow: 0 0.5em 1em rgba(0,0,0,0.25);
}

View File

@ -0,0 +1,24 @@
const fs = require('fs')
const path = require('path')
const filesInGuideFolder =
fs.readdirSync(path.join(__dirname, 'public/content/guide'))
const routes =
[ '/',
'/guide',
'/examples',
...(
filesInGuideFolder
.map(file => '/guide/' + file.split('.md')[0])
)
]
Promise.resolve(routes)
.then(routes => routes.map(route =>`<url><loc>https://www.elm-spa.dev${route}</loc></url>`))
.then(entries => `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.join('\n ')}
</urlset>`)
.then(content => fs.writeFileSync(path.join(__dirname, 'public', 'dist', 'sitemap.xml'), content, { encoding: 'utf-8' }))
.then(console.log)
.catch(console.error)

View File

@ -0,0 +1,54 @@
module Api.Data exposing (Data(..), fromHttpResult, view)
import Html exposing (Html)
import Html.Attributes as Attr exposing (class, classList)
import Spa.Transition
type Data value
= Loading
| Success value
| Failure String
fromHttpResult : Result error value -> Data value
fromHttpResult result =
case result of
Ok value ->
Success value
Err _ ->
Failure "Either this is a broken link or there's missing documentation!"
view : (value -> Html msg) -> Data value -> Html msg
view toHtml data =
Html.div
[ classList [ ( "invisible", data == Loading ) ]
, Attr.style "transition" Spa.Transition.properties.page
]
<|
case data of
Loading ->
[]
Success value ->
[ toHtml value ]
Failure reason ->
[ Html.div [ class "column spacing-small" ]
[ Html.div [ class "column spacing-small" ]
[ Html.h1 [ class "font-h2" ] [ Html.text "well. that's weird." ]
, Html.p [] [ Html.text reason ]
]
, Html.p []
[ Html.text "Could you please "
, Html.a
[ class "link"
, Attr.href "https://github.com/ryannhg/elm-spa/issues/new?labels=documentation&title=Broken%20docs%20link"
, Attr.target "_blank"
]
[ Html.text "let me know?" ]
]
]
]

View File

@ -0,0 +1,12 @@
module Api.Markdown exposing (get)
import Api.Data exposing (Data)
import Http
get : { file : String, onResponse : Data String -> msg } -> Cmd msg
get options =
Http.get
{ url = "/content/" ++ options.file
, expect = Http.expectString (Api.Data.fromHttpResult >> options.onResponse)
}

View File

@ -0,0 +1,2 @@
# src/Api
> Call backend API services

View File

@ -0,0 +1,16 @@
module Components.Markdown exposing (view)
import Html exposing (Html)
import Html.Attributes exposing (class)
import Markdown
view : String -> Html msg
view =
Markdown.toHtmlWith
{ githubFlavored = Just { tables = True, breaks = False }
, defaultHighlighting = Nothing
, sanitize = False
, smartypants = False
}
[ class "markdown readable column spacing-small" ]

View File

@ -0,0 +1,2 @@
# src/Components
> Reusable views and things

View File

@ -0,0 +1,78 @@
module Components.Sidebar exposing (view)
{-|
@docs Options, view
-}
import Html exposing (..)
import Html.Attributes exposing (class, href)
import Spa.Generated.Route as Route exposing (Route)
import Utils.String
type alias Section =
{ title : String
, links : List Link
}
type alias Link =
{ label : String
, route : Route
}
view : Route -> Html msg
view currentRoute =
let
viewSection : Section -> Html msg
viewSection section =
Html.section [ class "column spacing-small align-left" ]
[ h3 [ class "font-h4" ] [ text section.title ]
, div [ class "column spacing-small align-left" ]
(List.map viewLink section.links)
]
viewLink : Link -> Html msg
viewLink link =
a
[ href (Route.toString link.route)
, if link.route == currentRoute then
class "color--green text-underline text-bold"
else
class "text-underline hoverable"
]
[ text link.label ]
in
div [ class "hidden-mobile width--sidebar column pt-medium spacing-medium align-left" ]
(List.map viewSection sections)
sections : List Section
sections =
let
guide : String -> Link
guide label =
Link label <|
Route.Guide__Topic_String
{ topic = Utils.String.sluggify label
}
in
[ { title = "Guide"
, links =
[ Link "Introduction" Route.Guide
, guide "Getting Started"
, guide "Installation"
, guide "Routing"
, guide "Pages"
, guide "Shared"
, guide "Components"
, guide "Using APIs"
, guide "Beyond HTML"
, guide "Authentication"
]
}
]

View File

@ -0,0 +1,173 @@
module Main exposing (main)
import Browser
import Browser.Navigation as Nav
import Shared exposing (Flags)
import Spa.Document as Document exposing (Document)
import Spa.Generated.Pages as Pages
import Spa.Generated.Route as Route exposing (Route)
import Spa.Transition
import Url exposing (Url)
import Utils.Cmd
main : Program Flags Model Msg
main =
Browser.application
{ init = init
, update = update
, subscriptions = subscriptions
, view = view >> Document.toBrowserDocument
, onUrlRequest = LinkClicked
, onUrlChange = UrlChanged
}
fromUrl : Url -> Route
fromUrl =
Route.fromUrl >> Maybe.withDefault Route.NotFound
-- INIT
type alias Model =
{ url : Url
, key : Nav.Key
, shared : Shared.Model
, page : Pages.Model
, isTransitioning : { layout : Bool, page : Bool }
, nextUrl : Url
}
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
let
shared =
Shared.init flags key
route =
fromUrl url
( page, pageCmd ) =
Pages.init route shared key url
in
( Model url key shared page { layout = True, page = True } url
, Cmd.batch
[ Cmd.map Pages pageCmd
, Utils.Cmd.delay Spa.Transition.delays.layout (FadeIn url)
]
)
-- UPDATE
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url
| Shared Shared.Msg
| Pages Pages.Msg
| FadeIn Url
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked (Browser.Internal url) ->
( model
, Nav.pushUrl model.key (Url.toString url)
)
LinkClicked (Browser.External href) ->
( model
, Nav.load href
)
UrlChanged url ->
if url == model.url then
( model, Cmd.none )
else if url.path == model.url.path then
loadPage url model
else
( { model | isTransitioning = { layout = False, page = True }, nextUrl = url }
, Utils.Cmd.delay Spa.Transition.delays.page (FadeIn url)
)
FadeIn url ->
loadPage url model
Shared sharedMsg ->
let
( shared, cmd ) =
Shared.update sharedMsg model.shared
( page, pageCmd ) =
Pages.load model.page shared
in
( { model | page = page, shared = shared }
, Cmd.map Shared cmd
)
Pages pageMsg ->
let
( page, cmd ) =
Pages.update pageMsg model.page
shared =
Pages.save page model.shared
in
( { model | page = page, shared = shared }
, Cmd.map Pages cmd
)
loadPage : Url -> Model -> ( Model, Cmd Msg )
loadPage url model =
let
route =
fromUrl url
( page, cmd ) =
Pages.init route model.shared model.key url
shared =
Pages.save page model.shared
in
( { model
| url = url
, nextUrl = url
, page = page
, shared = shared
, isTransitioning = { layout = False, page = False }
}
, Cmd.map Pages cmd
)
view : Model -> Document Msg
view model =
Shared.view
{ page = Pages.view model.page |> Document.map Pages
, shared = model.shared
, toMsg = Shared
, isTransitioning = model.isTransitioning
, route = fromUrl model.url
, shouldShowSidebar = isSidebarPage model.url
}
isSidebarPage : Url -> Bool
isSidebarPage { path } =
String.startsWith "/docs" path || String.startsWith "/guide" path
subscriptions : Model -> Sub Msg
subscriptions model =
Pages.subscriptions model.page
|> Sub.map Pages

View File

@ -0,0 +1,42 @@
module Pages.Examples exposing (Model, Msg, Params, page)
import Html exposing (div, h1, p, text)
import Html.Attributes exposing (class)
import Spa.Document exposing (Document)
import Spa.Page as Page exposing (Page)
import Spa.Url as Url exposing (Url)
page : Page Params Model Msg
page =
Page.static
{ view = view
}
type alias Model =
Url Params
type alias Msg =
Never
-- VIEW
type alias Params =
()
view : Url Params -> Document Msg
view { params } =
{ title = "Examples"
, body =
[ div [ class "column spacing-tiny py-large center-x text-center" ]
[ h1 [ class "font-h1" ] [ text "examples" ]
, p [ class "font-h5 color--faint" ] [ text "coming soon!" ]
]
]
}

View File

@ -0,0 +1,67 @@
module Pages.Guide exposing (Model, Msg, Params, page)
import Api.Data exposing (Data)
import Api.Markdown
import Components.Markdown
import Html exposing (..)
import Html.Attributes exposing (class)
import Spa.Document exposing (Document)
import Spa.Generated.Route as Route exposing (Route)
import Spa.Page as Page exposing (Page)
import Spa.Url exposing (Url)
type alias Params =
()
type alias Model =
{ route : Route
, content : Data String
}
page : Page Params Model Msg
page =
Page.element
{ init = init
, update = update
, subscriptions = always Sub.none
, view = view
}
init : Url Params -> ( Model, Cmd Msg )
init { rawUrl } =
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
, content = Api.Data.Loading
}
, Api.Markdown.get
{ file = "guide.md"
, onResponse = GotMarkdown
}
)
type Msg
= GotMarkdown (Data String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotMarkdown content ->
( { model | content = content }
, Cmd.none
)
view : Model -> Document Msg
view model =
{ title = "guide | elm-spa"
, body =
[ Api.Data.view
Components.Markdown.view
model.content
]
}

View File

@ -0,0 +1,77 @@
module Pages.Guide.Topic_String exposing (Model, Msg, Params, page)
import Api.Data exposing (Data)
import Api.Markdown
import Components.Markdown
import Html exposing (..)
import Html.Attributes exposing (class, href)
import Spa.Document exposing (Document)
import Spa.Generated.Route as Route exposing (Route)
import Spa.Page as Page exposing (Page)
import Spa.Url exposing (Url)
type alias Params =
{ topic : String
}
type alias Model =
{ title : String
, route : Route
, content : Data String
}
page : Page Params Model Msg
page =
Page.element
{ init = init
, update = update
, subscriptions = always Sub.none
, view = view
}
init : Url Params -> ( Model, Cmd Msg )
init { rawUrl, params } =
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
, title = params.topic
, content = Api.Data.Loading
}
, Api.Markdown.get
{ file = "guide/" ++ params.topic ++ ".md"
, onResponse = GotMarkdown
}
)
type Msg
= GotMarkdown (Data String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotMarkdown content ->
( { model | content = content }
, Cmd.none
)
view : Model -> Document Msg
view model =
{ title = prettifySlug model.title ++ " | guide | elm-spa"
, body =
[ Api.Data.view
Components.Markdown.view
model.content
]
}
prettifySlug : String -> String
prettifySlug slug =
slug
|> String.replace "-" " "
|> String.replace "elm spa" "elm-spa"

View File

@ -0,0 +1,43 @@
module Pages.NotFound exposing (Model, Msg, Params, page, view)
import Html exposing (..)
import Html.Attributes exposing (class, href)
import Spa.Document exposing (Document)
import Spa.Generated.Route as Route
import Spa.Page as Page exposing (Page)
import Spa.Url exposing (Url)
page : Page Params Model Msg
page =
Page.static
{ view = view
}
type alias Params =
()
type alias Model =
Url Params
type alias Msg =
Never
view : Url Params -> Document Msg
view _ =
{ title = "404"
, body =
[ div [ class "column spacing-tiny" ]
[ h1 [ class "font-h2" ] [ text "Page not found" ]
, p [ class "font-body color--faint" ]
[ text "How about the "
, a [ class "link", href (Route.toString Route.Top) ] [ text "homepage" ]
, text "? That's a nice place."
]
]
]
}

View File

@ -0,0 +1,2 @@
# src/Pages
> Correspond to a URL route

View File

@ -0,0 +1,69 @@
module Pages.Top exposing (Model, Msg, Params, page, view)
import Components.Markdown
import Html exposing (..)
import Html.Attributes exposing (alt, class, src)
import Spa.Document exposing (Document)
import Spa.Page as Page exposing (Page)
import Spa.Url exposing (Url)
page : Page Params Model Msg
page =
Page.static
{ view = view
}
type alias Params =
()
type alias Model =
Url Params
type alias Msg =
Never
view : Url Params -> Document Msg
view _ =
{ title = "elm-spa"
, body =
[ div [ class "column spacing-medium center-x" ]
[ hero
, viewSection "No assembly required." """
Build reliable [Elm](https://elm-lang.org) applications with the wonderful tools created by the community brought together in one place:
- Use __elm-ui__ to create build UIs without CSS.
- Comes with __elm-live__, a hot-reloading web server.
- Create a test suite with __elm-test__
"""
, span [] []
, viewSection "Ready to learn more?" """
[Checkout the official guide](/guide)
"""
, span [] []
]
]
}
hero : Html msg
hero =
div [ class "column spacing-medium py-large center-x text-center" ]
[ div [ class "column spacing-tiny center-x" ]
[ img [ alt "elm-spa logo", class "size--120", src "/images/logo.svg" ] []
, h1 [ class "font-h1" ] [ text "elm-spa" ]
, p [ class "font-h5 color--faint" ] [ text "single page apps made easy." ]
]
, pre [ class "home-pre" ] [ code [ class "lang-terminal" ] [ text "npx elm-spa init" ] ]
]
viewSection : String -> String -> Html msg
viewSection title content =
section [ class "column spacing-small center-x" ]
[ h3 [ class "font-h2" ] [ text title ]
, Components.Markdown.view content
]

View File

@ -0,0 +1,42 @@
module Pages.Tour exposing (Model, Msg, Params, page)
import Html exposing (div, h1, p, text)
import Html.Attributes exposing (class)
import Spa.Document exposing (Document)
import Spa.Page as Page exposing (Page)
import Spa.Url as Url exposing (Url)
page : Page Params Model Msg
page =
Page.static
{ view = view
}
type alias Model =
Url Params
type alias Msg =
Never
-- VIEW
type alias Params =
()
view : Url Params -> Document Msg
view { params } =
{ title = "Tour"
, body =
[ div [ class "column spacing-tiny py-large center-x text-center" ]
[ h1 [ class "font-h1" ] [ text "tour" ]
, p [ class "font-h5 color--faint" ] [ text "coming soon!" ]
]
]
}

View File

@ -0,0 +1,7 @@
port module Ports exposing (log)
-- A place to interact with JavaScript
-- https://guide.elm-lang.org/interop/ports.html
port log : String -> Cmd msg

View File

@ -0,0 +1,126 @@
module Shared exposing
( Flags
, Model
, Msg
, init
, subscriptions
, update
, view
)
import Browser.Navigation as Nav
import Components.Sidebar
import Html exposing (..)
import Html.Attributes exposing (class, classList, href, style)
import Spa.Document exposing (Document)
import Spa.Generated.Route as Route exposing (Route)
import Spa.Transition
type alias Flags =
()
type alias Model =
{ key : Nav.Key
}
init : Flags -> Nav.Key -> Model
init _ key =
Model key
type Msg
= ReplaceMe
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ReplaceMe ->
( model, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
view :
{ page : Document msg
, shared : Model
, toMsg : Msg -> msg
, isTransitioning : { layout : Bool, page : Bool }
, shouldShowSidebar : Bool
, route : Route
}
-> Document msg
view ({ page, isTransitioning } as options) =
{ title = page.title
, body =
[ div
[ class "column container px-medium spacing-small fill-y"
, style "transition" Spa.Transition.properties.layout
, classList [ ( "invisible", isTransitioning.layout ) ]
]
[ viewNavbar
, div [ class "flex row align-top relative" ]
[ viewSidebar options
, viewPage options
]
, viewFooter
]
]
}
viewNavbar : Html msg
viewNavbar =
header [ class "py-medium row spacing-small spread center-y" ]
[ a [ class "font-h3 text-header hoverable", href "/" ]
[ text "elm-spa" ]
, div [ class "row spacing-small text--bigger" ]
[ a [ class "link", href (Route.toString Route.Guide) ] [ text "guide" ]
-- , a [ class "link", href (Route.toString Route.Tour) ] [ text "tour" ]
, a [ class "link", href (Route.toString Route.Examples) ] [ text "examples" ]
]
]
viewPage :
{ options
| page : Document msg
, isTransitioning : { layout : Bool, page : Bool }
}
-> Html msg
viewPage { page, isTransitioning } =
main_
[ class "flex"
, style "transition" Spa.Transition.properties.page
, classList [ ( "invisible", isTransitioning.page ) ]
]
page.body
viewSidebar : { options | shouldShowSidebar : Bool, route : Route } -> Html msg
viewSidebar { shouldShowSidebar, route } =
aside
[ class "hidden-mobile fadeable sticky"
, classList
[ ( "invisible", not shouldShowSidebar )
, ( "no-width", not shouldShowSidebar )
]
]
[ Components.Sidebar.view route
]
viewFooter : Html msg
viewFooter =
footer [ class "footer pt-large pb-medium text-center color--faint" ]
[ text "[ Built with "
, a [ class "text-underline hoverable", Html.Attributes.target "_blank", href "https://elm-lang.org" ] [ text "Elm" ]
, text " ]"
]

View File

@ -0,0 +1,26 @@
module Spa.Document exposing
( Document
, map
, toBrowserDocument
)
import Browser
import Html exposing (Html)
type alias Document msg =
{ title : String
, body : List (Html msg)
}
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
map fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
toBrowserDocument : Document msg -> Browser.Document msg
toBrowserDocument doc =
doc

View File

@ -0,0 +1,126 @@
module Spa.Page exposing
( Page
, static, sandbox, element, application
, Upgraded, Bundle, upgrade
)
{-|
@docs Page
@docs static, sandbox, element, application
@docs Upgraded, Bundle, upgrade
-}
import Browser.Navigation exposing (Key)
import Shared
import Spa.Document as Document exposing (Document)
import Spa.Url exposing (Url)
import Url
type alias Page params model msg =
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> Document msg
, subscriptions : model -> Sub msg
, save : model -> Shared.Model -> Shared.Model
, load : Shared.Model -> model -> ( model, Cmd msg )
}
static :
{ view : Url params -> Document msg
}
-> Page params (Url params) msg
static page =
{ init = \_ url -> ( url, Cmd.none )
, update = \_ model -> ( model, Cmd.none )
, view = page.view
, subscriptions = \_ -> Sub.none
, save = always identity
, load = \_ model -> ( model, Cmd.none )
}
sandbox :
{ init : Url params -> model
, update : msg -> model -> model
, view : model -> Document msg
}
-> Page params model msg
sandbox page =
{ init = \_ url -> ( page.init url, Cmd.none )
, update = \msg model -> ( page.update msg model, Cmd.none )
, view = page.view
, subscriptions = \_ -> Sub.none
, save = always identity
, load = \_ model -> ( model, Cmd.none )
}
element :
{ init : Url params -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> Document msg
, subscriptions : model -> Sub msg
}
-> Page params model msg
element page =
{ init = \_ params -> page.init params
, update = \msg model -> page.update msg model
, view = page.view
, subscriptions = page.subscriptions
, save = always identity
, load = \_ model -> ( model, Cmd.none )
}
application :
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> Document msg
, subscriptions : model -> Sub msg
, save : model -> Shared.Model -> Shared.Model
, load : Shared.Model -> model -> ( model, Cmd msg )
}
-> Page params model msg
application page =
page
-- UPGRADING
type alias Upgraded pageParams pageModel pageMsg model msg =
{ init : pageParams -> Shared.Model -> Key -> Url.Url -> ( model, Cmd msg )
, update : pageMsg -> pageModel -> ( model, Cmd msg )
, bundle : pageModel -> Bundle model msg
}
type alias Bundle model msg =
{ view : Document msg
, subscriptions : Sub msg
, save : Shared.Model -> Shared.Model
, load : Shared.Model -> ( model, Cmd msg )
}
upgrade :
(pageModel -> model)
-> (pageMsg -> msg)
-> Page pageParams pageModel pageMsg
-> Upgraded pageParams pageModel pageMsg model msg
upgrade toModel toMsg page =
{ init = \params shared key url -> page.init shared (Spa.Url.create params key url) |> Tuple.mapBoth toModel (Cmd.map toMsg)
, update = \msg model -> page.update msg model |> Tuple.mapBoth toModel (Cmd.map toMsg)
, bundle =
\model ->
{ view = page.view model |> Document.map toMsg
, subscriptions = page.subscriptions model |> Sub.map toMsg
, save = page.save model
, load = \shared -> page.load shared model |> Tuple.mapBoth toModel (Cmd.map toMsg)
}
}

View File

@ -0,0 +1,2 @@
# src/Spa
> elm-spa configuration and generated code

View File

@ -0,0 +1,23 @@
module Spa.Transition exposing
( delays
, properties
)
delays : { layout : Int, page : Int }
delays =
{ layout = 300
, page = 300
}
properties : { layout : String, page : String }
properties =
{ layout = property delays.layout
, page = property delays.page
}
property : Int -> String
property delay =
"opacity " ++ String.fromInt delay ++ "ms ease-in-out, visibility " ++ String.fromInt delay ++ "ms ease-in-out"

View File

@ -0,0 +1,45 @@
module Spa.Url exposing (Url, create)
import Browser.Navigation exposing (Key)
import Dict exposing (Dict)
import Url
type alias Url params =
{ key : Key
, params : params
, query : Dict String String
, rawUrl : Url.Url
}
create : params -> Key -> Url.Url -> Url params
create params key url =
{ params = params
, key = key
, rawUrl = url
, query =
url.query
|> Maybe.map toQueryDict
|> Maybe.withDefault Dict.empty
}
toQueryDict : String -> Dict String String
toQueryDict queryString =
let
second : List a -> Maybe a
second list =
list |> List.drop 1 |> List.head
toTuple : List String -> Maybe ( String, String )
toTuple list =
Maybe.map2 Tuple.pair
(List.head list)
(second list)
in
queryString
|> String.split "&"
|> List.map (String.split "=")
|> List.filterMap toTuple
|> Dict.fromList

View File

@ -0,0 +1,16 @@
module Utils.Cmd exposing (delay, send)
import Process
import Task
send : msg -> Cmd msg
send =
delay 0
delay : Int -> msg -> Cmd msg
delay ms msg =
Process.sleep (toFloat ms)
|> Task.map (\_ -> msg)
|> Task.perform identity

View File

@ -0,0 +1,2 @@
# src/Utils
> Helpful modules around data structures

View File

@ -0,0 +1,8 @@
module Utils.String exposing (sluggify)
sluggify : String -> String
sluggify words =
words
|> String.replace " " "-"
|> String.toLower

View File

@ -0,0 +1,24 @@
module Program.NotFoundTest exposing (all)
import Pages.NotFound as Page
import ProgramTest exposing (ProgramTest, expectViewHas)
import Program.Utils.Spa
import Test exposing (..)
import Test.Html.Selector exposing (text)
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
start =
Program.Utils.Spa.createStaticPage
{ view = Page.view
}
all : Test
all =
describe "Pages.NotFound"
[ test "should say page not found" <|
\() ->
start
|> expectViewHas [ text "Page not found" ]
]

View File

@ -0,0 +1,2 @@
# tests/Program
> Write tests for pages

View File

@ -0,0 +1,24 @@
module Program.TopTest exposing (all)
import Pages.Top as Page
import ProgramTest exposing (ProgramTest, expectViewHas)
import Program.Utils.Spa
import Test exposing (..)
import Test.Html.Selector exposing (text)
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
start =
Program.Utils.Spa.createStaticPage
{ view = Page.view
}
all : Test
all =
describe "Pages.Top"
[ test "should say homepage" <|
\() ->
start
|> expectViewHas [ text "Homepage" ]
]

View File

@ -0,0 +1,54 @@
module Program.Utils.Spa exposing
( createElementPage
, createSandboxPage
, createStaticPage
)
import ProgramTest exposing (ProgramTest)
import Spa.Document exposing (Document)
import Spa.Url exposing (Url)
createStaticPage :
{ view : Document msg
}
-> ProgramTest () msg (Cmd msg)
createStaticPage page =
ProgramTest.createDocument
{ init = \_ -> ( (), Cmd.none )
, update = \_ model -> ( model, Cmd.none )
, view = \_ -> page.view |> Spa.Document.toBrowserDocument
}
|> ProgramTest.start ()
createSandboxPage :
{ init : model
, update : msg -> model -> model
, view : model -> Document msg
}
-> ProgramTest model msg (Cmd msg)
createSandboxPage page =
ProgramTest.createDocument
{ init = \_ -> ( page.init, Cmd.none )
, update = \msg model -> ( page.update msg model, Cmd.none )
, view = page.view >> Spa.Document.toBrowserDocument
}
|> ProgramTest.start ()
createElementPage :
Url params
->
{ init : Url params -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> Document msg
}
-> ProgramTest model msg (Cmd msg)
createElementPage params page =
ProgramTest.createDocument
{ init = page.init
, update = page.update
, view = page.view >> Spa.Document.toBrowserDocument
}
|> ProgramTest.start params

View File

@ -0,0 +1,2 @@
# tests
> A place for function and program tests

View File

@ -0,0 +1,2 @@
# tests/Unit
> Write tests for functions

View File

@ -1,6 +0,0 @@
.DS_Store
dist
elm-stuff
node_modules
src/Generated

View File

@ -1,18 +0,0 @@
# examples/elm-ui
```
npm start
```
## how i upgraded to elm-ui
```
npm install -g elm-spa
elm-spa init my-project
cd my-project
elm install mdgriffith/elm-ui
```
Checkout the `src/Page.elm` and `src/Document.elm` files, they allow us to create pages with `Element msg` and `Html msg`
From there, I just replaced `Html` with `Element` in the `src/Pages/*.elm` files.

View File

@ -1,26 +0,0 @@
{
"type": "application",
"source-directories": [
"src",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"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.5"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@ -1,28 +0,0 @@
{
"name": "elm-spa-app",
"version": "1.0.0",
"description": "my new elm-spa application",
"main": "public/index.html",
"scripts": {
"start": "npm install && npm run build && npm run dev",
"build": "npm run build:elm-spa && npm run build:elm",
"build:elm-spa": "elm-spa build .",
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
},
"keywords": [
"elm",
"spa"
],
"author": "",
"license": "ISC",
"devDependencies": {
"chokidar-cli": "2.1.0",
"concurrently": "5.0.0",
"elm": "0.19.1-3",
"elm-live": "4.0.2",
"elm-spa": "4.1.0"
}
}

View File

@ -1,86 +0,0 @@
module Components exposing (layout)
import Document exposing (Document)
import Element exposing (..)
import Element.Background as Background
import Element.Border as Border
import Element.Font as Font
import Generated.Route as Route exposing (Route)
layout : { page : Document msg } -> Document msg
layout { page } =
{ title = page.title
, body =
[ column [ spacing 32, padding 20, width (fill |> maximum 780), height fill, centerX ]
[ navbar
, column [ height fill ] page.body
, footer
]
]
}
navbar : Element msg
navbar =
row [ width fill ]
[ el [ Font.size 24, Font.bold ] <| link ( "home", Route.Top )
, row [ alignRight, spacing 20 ]
[ link ( "docs", Route.Docs )
, link ( "a broken link", Route.NotFound )
, externalButtonLink ( "tweet about it", "https://twitter.com/intent/tweet?text=elm-spa is ez pz" )
]
]
link : ( String, Route ) -> Element msg
link ( label, route ) =
Element.link styles.link
{ label = text label
, url = Route.toHref route
}
externalButtonLink : ( String, String ) -> Element msg
externalButtonLink ( label, url ) =
Element.newTabLink styles.button
{ label = text label
, url = url
}
footer : Element msg
footer =
row [] [ text "built with elm " ]
-- STYLES
colors : { blue : Color, white : Color, red : Color }
colors =
{ white = rgb 1 1 1
, red = rgb255 204 85 68
, blue = rgb255 50 100 150
}
styles :
{ link : List (Element.Attribute msg)
, button : List (Element.Attribute msg)
}
styles =
{ link =
[ Font.underline
, Font.color colors.blue
, mouseOver [ alpha 0.6 ]
]
, button =
[ Font.color colors.white
, Background.color colors.red
, Border.rounded 4
, paddingXY 24 10
, mouseOver [ alpha 0.6 ]
]
}

View File

@ -1,98 +0,0 @@
module Global exposing
( Flags
, Model
, Msg
, init
, navigate
, subscriptions
, update
, view
)
import Browser.Navigation as Nav
import Components
import Document exposing (Document)
import Generated.Route as Route exposing (Route)
import Task
import Url exposing (Url)
-- INIT
type alias Flags =
()
type alias Model =
{ flags : Flags
, url : Url
, key : Nav.Key
}
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
( Model
flags
url
key
, Cmd.none
)
-- UPDATE
type Msg
= Navigate Route
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Navigate route ->
( model
, Nav.pushUrl model.key (Route.toHref route)
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view :
{ page : Document msg
, global : Model
, toMsg : Msg -> msg
}
-> Document msg
view { page, global, toMsg } =
Components.layout
{ page = page
}
-- COMMANDS
send : msg -> Cmd msg
send =
Task.succeed >> Task.perform identity
navigate : Route -> Cmd Msg
navigate route =
send (Navigate route)

View File

@ -1,133 +0,0 @@
module Main exposing (main)
import Browser
import Browser.Navigation as Nav exposing (Key)
import Document exposing (Document)
import Element
import Generated.Pages as Pages
import Generated.Route as Route exposing (Route)
import Global
import Url exposing (Url)
main : Program Flags Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlRequest = LinkClicked
, onUrlChange = UrlChanged
}
-- INIT
type alias Flags =
()
type alias Model =
{ key : Key
, url : Url
, global : Global.Model
, page : Pages.Model
}
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( global, globalCmd ) =
Global.init flags url key
( page, pageCmd, pageGlobalCmd ) =
Pages.init (fromUrl url) global
in
( Model key url global page
, Cmd.batch
[ Cmd.map Global globalCmd
, Cmd.map Global pageGlobalCmd
, Cmd.map Page pageCmd
]
)
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url
| Global Global.Msg
| Page Pages.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked (Browser.Internal url) ->
( model, Nav.pushUrl model.key (Url.toString url) )
LinkClicked (Browser.External href) ->
( model, Nav.load href )
UrlChanged url ->
let
( page, pageCmd, globalCmd ) =
Pages.init (fromUrl url) model.global
in
( { model | url = url, page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
Global globalMsg ->
let
( global, globalCmd ) =
Global.update globalMsg model.global
in
( { model | global = global }
, Cmd.map Global globalCmd
)
Page pageMsg ->
let
( page, pageCmd, globalCmd ) =
Pages.update pageMsg model.page model.global
in
( { model | page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ model.global
|> Global.subscriptions
|> Sub.map Global
, model.page
|> (\page -> Pages.subscriptions page model.global)
|> Sub.map Page
]
view : Model -> Browser.Document Msg
view model =
Document.toBrowserDocument <|
Global.view
{ page = Pages.view model.page model.global |> Document.map Page
, global = model.global
, toMsg = Global
}
fromUrl : Url -> Route
fromUrl =
Route.fromUrl >> Maybe.withDefault Route.NotFound

View File

@ -1,82 +0,0 @@
module Page exposing
( Page, Document, Bundle
, upgrade
, static, sandbox, element, component
)
{-|
@docs Page, Document, Bundle
@docs upgrade
@docs static, sandbox, element, component
-}
import Browser
import Document
import Global
import Spa.Advanced as Spa
type alias Document msg =
Document.Document msg
type alias Page flags model msg =
Spa.Page flags model msg Global.Model Global.Msg (Document msg)
type alias Bundle msg =
Spa.Bundle msg (Document msg)
upgrade :
(pageModel -> model)
-> (pageMsg -> msg)
-> Page pageFlags pageModel pageMsg
->
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, bundle : pageModel -> Global.Model -> Bundle msg
}
upgrade =
Spa.upgrade Document.map
static : { view : Document msg } -> Page flags () msg
static =
Spa.static
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> Document msg
}
-> Page flags model msg
sandbox =
Spa.sandbox
element :
{ init : flags -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
, view : model -> Document msg
}
-> Page flags model msg
element =
Spa.element
component :
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
, subscriptions : Global.Model -> model -> Sub msg
, view : Global.Model -> model -> Document msg
}
-> Page flags model msg
component =
Spa.component

View File

@ -1,30 +0,0 @@
module Pages.Docs exposing (Flags, Model, Msg, page)
import Element
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "Docs"
, body = [ Element.text "Docs" ]
}

View File

@ -1,30 +0,0 @@
module Pages.NotFound exposing (Flags, Model, Msg, page)
import Element
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "NotFound"
, body = [ Element.text "NotFound" ]
}

View File

@ -1,30 +0,0 @@
module Pages.Top exposing (Flags, Model, Msg, page)
import Element
import Page exposing (Document, Page)
type alias Flags =
()
type alias Model =
()
type alias Msg =
Never
page : Page Flags Model Msg
page =
Page.static
{ view = view
}
view : Document Msg
view =
{ title = "Top"
, body = [ Element.text "Top" ]
}

View File

@ -1,841 +0,0 @@
# from scratch
> a guide on how to use elm-spa without the cli
## getting setup
1. Install [elm](https://guide.elm-lang.org/install/elm.html)
1. Optional: Install [VS Code](https://code.visualstudio.com/download) and the [elm extension](https://marketplace.visualstudio.com/items?itemName=Elmtooling.elm-ls-vscode)
## creating a new project
Create a new Elm project using the `elm init` command:
```terminal
elm init
```
This will create an `elm.json` file and a `src` folder. Let's install two more packages for our project:
```
elm install elm/url
elm install ryannhg/elm-spa
```
## src/Main.elm
First, we create a new file called `src/Main.elm`- which is the entrypoint for our new Elm application. Let's build it together:
### imports and main
We need to create a `main` function for Elm to call
when our application starts up. `Browser.application` supports
client-side routing, so that's the function we'll want to
call for our single page application.
```elm
module Main exposing (main)
import Browser
import Browser.Navigation as Nav
import Document exposing (Document)
import Global
import Pages
import Url exposing (Url)
main : Program Global.Flags Model Msg
main =
Browser.application
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
, onUrlRequest = LinkChanged
, onUrlChange = UrlChanged
}
```
The `Document`, `Global`, and `Pages` modules haven't been created yet, but `Browser`, `Browser.Navigation`, and `Url` come from the `elm/browser` and `elm/url` packages.
### model and init
Here we'll store four things:
1. The current URL
1. A unique key used for navigation
1. The global state of our app
1. The state of the page we are currently viewing
```elm
type alias Model =
{ url : Url
, key : Nav.Key
, global : Global.Model
, page : Pages.Model
}
```
We'll implement `Global.Model` and `Pages.Model` soon,
but for now let's continue by implementing the `init`
function.
```elm
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( globalModel, globalCmd ) =
Global.init flags url key
( pageModel, pageCmd, pageGlobalCmd ) =
Pages.init url globalModel
in
( Model url key globalModel pagesModel
, Cmd.batch
[ Cmd.map FromGlobal globalCmd
, Cmd.map FromGlobal pageGlobalCmd
, Cmd.map FromPage pageCmd
]
)
```
Here we assume `Global.init` handles the initialization of the global state between pages, and can send a global command like an HTTP request or another side effect.
Similarly, `Pages.init` initializes the current page based on the current URL and the initialize global state. In addition to returning `Cmd Pages.Msg`, pages can also return `Cmd Global.Msg`. This allows them to affect the global state shared between pages.
We'll implement those functions together later!
### msg and update
```elm
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url
| FromGlobal Global.Msg
| FromPage Page.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked (Browser.Internal url) ->
( model
, Nav.pushUrl model.key (Url.toString url)
)
LinkClicked (Browser.External href) ->
( model
, Nav.load href
)
UrlChanged url ->
let
( pageModel, pageCmd, pageGlobalCmd ) =
Pages.init url model.global
in
( { model | url = url, page = pageModel }
, Cmd.batch
[ Cmd.map FromGlobal pageGlobalCmd
, Cmd.map FromPage pageCmd
]
)
FromGlobal globalMsg ->
let
( globalModel, globalCmd ) =
Global.update globalMsg model.global
in
( { model | global = globalModel }
, Cmd.map FromGlobal globalCmd
)
FromPages pageMsg ->
let
( pageModel, pageCmd, pageGlobalCmd ) =
Pages.update pageMsg model.page model.global
in
( { model | page = pageModel }
, Cmd.batch
[ Cmd.map FromGlobal pageGlobalCmd
, Cmd.map FromPage pageCmd
]
)
```
Here we define and handle the four messages we might receive from the user:
1. They click a link, either internal or external, and we navigate to the correct page.
1. The URL changes and we need to initialize the new page.
1. We received an update for the global state between pages.
1. We received an update for a page.
### view + subscriptions
The view and subscriptions for the page are based on the current `Model` of the application:
```elm
view : Model -> Document Msg
view model =
Global.view
{ global = model.global
, fromGlobal = FromGlobal
, page = Document.map FromPage (Pages.view model.page model.global)
}
```
The `view` function calls the global layout, and provides three things:
1. The current global state of the application.
1. A way to upgrade a `Global.Msg` into a `Msg`.
1. The page's view, so it can decide where to render the page.
#### why those three values?
The pages within our app will return `Document Page.Msg`.
But we want components in our global view to be able to send global messages (`Global.Msg`).
In Elm, our function cannot return both `Document Page.Msg` and `Document Global.Msg`. To make sure our view function returns the same type:
1. We call `Document.map FromPage` to convert `Document Pages.Msg` into `Document Msg`
1. We provide `fromGlobal` to `Global.view`, so it can convert `Global.Msg` values into `Msg` values!
Now we are returning `Document Msg` to `Main.view`, and we can handle messages from different sources. Later, we'll see `Global.view`- which will have a more concrete example for how to use those three values.
For now, let's wrap up by handling subscriptions!
```elm
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Sub.map FromGlobal (Global.subscriptions model.global)
, Sub.map FromPage (Pages.subscriptions model.page model.global)
]
```
Finally, we subscribe to events from the global and page modules, in case we want to track things like window resize or keyboard events.
That's it! The source code for src/Main.elm file is available [here](#todo).
## src/Document.elm
Before we get into `Global` and `Pages`, let's define the `Document` module, which defines what our pages should be returning:
```elm
module Document exposing (Document, map)
import Html exposing (Html)
type alias Document msg =
{ title : String
, body : List (Html msg)
}
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
map fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
```
That's all we need for this file! We just redefined `Browser.Document` and created a `map` function a way to go from one `Document` to another. The `map` function is the one we used above in `src/Main.elm`.
## src/Global.elm
The `Global` module initializes, updates, views, and handles subscriptions for the state we want to share across pages.
For example, if a user logs in, we don't want navigating from page to page to log them back out!
For that reason, it's nice to have a `Global.Model` for our application.
```elm
module Global exposing
( Flags
, Model
, Msg
, init
, update
, view
, subscriptions
-- commands
, increment
, navigateTo
)
import Browser.Navigation as Nav
import Document exposing (Document)
import Html exposing (Html)
import Html.Attributes exposing (class)
import Url exposing (Url)
import Route exposing (Route)
import Task
```
### flags, model, and init
```elm
type alias Flags =
()
type alias Model =
{ key : Nav.Key
, counter : Int
}
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ _ key =
( Model key 0
, Cmd.none
)
```
The `Flags` type is the data we pass in from JavaScript when starting our Elm application. Here we are using an empty tuple `()` to say that we won't be taking in any data from JS.
The `Global.Model` needs to store `Nav.Key` so it can navigate between pages. For that reason, we ignore the flags and url, but store `key` to our model.
For this example application, we're also storing a global `counter` value. Later, we'll demonstrate how to change that global counter from both pages and global components!
### msg and update
```elm
type Msg
= NavigatedTo Route
| Increment
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NavigatedTo route ->
( model
, Nav.pushUrl model.key (Route.toHref route)
)
Increment ->
( { model | counter = model.counter + 1 }
, Cmd.none
)
```
Here we handle `NavigatedTo`, a message that comes with a `Route`. A `Route` is just a custom type that holds all the URLs our application accepts. We'll define the `Route` module after this one!
When we get the `NavigatedTo` message from our application, we use the `Route.toHref` function to convert our route to a `String`. Then we can call `Nav.pushUrl` to change the URL for our app.
We also include an implementation for `Increment`. We'll see an example of changing global state soon.
#### global commands
To allow pages to send `Cmd Global.Msg`, we need to expose the commands we want our users to call.
Here we'll define a helper function called `send` that turns a `Global.Msg` into a `Cmd Global.Msg`:
```elm
send : Msg -> Cmd Msg
send msg =
Task.succeed msg |> Task.perform identity
```
From there, we can expose commands easily:
```elm
navigateTo : Route -> Cmd Msg
navigateTo route =
send (NavigatedTo route)
```
```elm
increment : Cmd Msg
increment =
send Increment
```
### view + subscriptions
Our view function received the three pieces of information we passed in from `Main.view`.
```elm
view :
{ global : Model
, toMsg : Msg -> msg
, page : Document msg
}
-> Document msg
view { global, toMsg, page } =
{ title = page.title
, body =
[ Html.div [ class "layout" ]
[ navbar
{ increment = toMsg Increment
, counter = global.counter
}
, Html.div [ class "page" ] page.body
, footer
]
]
}
navbar :
{ increment : msg
, counter : Int
}
-> Html msg
navbar options =
Html.header [ class "navbar" ]
[ Html.text (String.fromInt options.counter)
, Html.button
[ Events.onClick options.increment ]
[ Html.text "+" ]
]
footer : Html msg
footer =
Html.footer [ class "footer" ] [ text "built with elm!" ]
```
The `view` function has access to the current `global` model, `page`, and shared components can even send messages with the help of `toMsg`.
The `page` document's title is used by our view.
By passing in the `page` value, our layout can decide where to render the
page view. Here we render it in between two components: navbar and footer
Our `navbar` is able to access the current `counter` value and can send the `increment` message when the button is clicked.
Here we use `toMsg` to make sure that the return type is `Html msg` instead of `Html Msg`. Using `toMsg` allows us to put the navbar alongside the `page.body` and `footer`- because they all return `Html msg`!
```elm
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.none
```
Our application doesn't require any subscriptions, but you could add them in here if you need them later!
That's `src/Global.elm`. The complete source code is [available here](#todo).
## src/Route.elm
Earlier in our `Global` module, we used `Route` to handle page navigation messages. Let's define a `Route` module for this application, so we can see how it works.
```elm
module Route exposing (Route(..), fromUrl, toHref)
import Url exposing (Url)
import Url.Parser exposing (Parser, (</>), string, s)
```
Our route module will be using a `Url.Parser` from the `elm/url` package to implement `fromUrl`.
### Route
```elm
type Route
= Home
| AboutUs
| Posts
| Post String
| NotFound
```
When we define this `Route` type, we're saying that these are the only five pages a user can ever be on while using our application.
### fromUrl
We can create our `Route` by parsing a `Url`.
```elm
fromUrl : Url -> Route
fromUrl url =
[ Parser.map Home Parser.top
, Parser.map AboutUs (Parser.s "about-us")
, Parser.map Posts (Parser.s "posts")
, Parser.map Post (Parser.s "posts" </> Parser.string)
]
|> Parser.oneOf
|> Parser.run url
|> Maybe.withDefault NotFound
```
Here we try to find a route match. If we can't find one, we default to `NotFound`. That way we always have a `Route` no matter what `Url` we are given.
### toHref
If we want to turn our `AboutUs` route back into `/about-us`, we'll need to define a function to do that for us:
```elm
toHref : Route -> String
toHref route =
case route of
Home ->
"/"
AboutUs ->
"/about-us"
Posts ->
"/posts"
Post id ->
"/posts/" ++ id
NotFound ->
"/not-found"
```
This function allows all our URLs to be handled in one place in the application.
## src/Pages.elm
Now we are ready to take a look at how `ryannhg/elm-spa` allows us to compose together our pages.
Let's define the top-level `Pages` module that handles initializing, updating, viewing, and receiving subscriptions from the current page.
```elm
import Pages exposing
( Model
, Msg
, init
, update
, view
, subscriptions
)
import Global
import Document exposing (Document)
import Page exposing (Page)
import Pages.Home as Home
import Pages.AboutUs as AboutUs
import Pages.Posts as Posts
import Pages.Post as Post
import Pages.NotFound as NotFound
import Route
import Url exposing (Url)
```
### model, msg, and upgraded pages
The user can only be on one page at a time, so we use a custom type to represent which page we are currently viewing.
```elm
type Model
= Home_Model Home.Model
| AboutUs_Model AboutUs.Model
| Posts_Model Posts.Model
| Post_Model Post.Model
| NotFound_Model NotFound.Model
```
Additionally, messages could be sent from any one of the five pages.
```elm
type Msg
= Home_Msg Home.Msg
| AboutUs_Msg AboutUs.Msg
| Posts_Msg Posts.Msg
| Post_Msg Post.Msg
| NotFound_Msg NotFound.Msg
```
What's great about the custom types above? Each variant we created (like `Home_Model` and `Home_Msg`) are actually functions with signatures like this:
```elm
Home_Model : Home.Model -> Model
Home_Msg : Home.Msg -> Msg
AboutUs_Model : AboutUs.Model -> Model
AboutUs_Msg : AboutUs.Msg -> Msg
Posts_Model : Posts.Model -> Model
Posts_Msg : Posts.Msg -> Msg
-- ... same for Post, NotFound
```
What that means is if I call `Home_Model` with a `Home.Model`, I'll get a `Model` back. The same goes for giving `About_Msg` an `AboutUs.Msg`!
All these varaiants are functions that tell each page how they can upgrade to the shared `Model` and `Msg` types we just defined.
With `elm-spa`, we want to "upgrade our pages" using these variants!
```elm
type alias Upgraded pageFlags pageModel pageMsg =
{ init :
pageFlags
-> Global.Model
-> ( Model, Cmd Msg, Cmd Global.Msg )
, update :
pageMsg
-> pageModel
-> Global.Model
-> ( Model, Cmd Msg, Cmd Global.Msg )
, bundle :
pageModel
-> { view : Document Msg
, subscriptions : Sub Msg
}
}
```
Our goal now is to create an `Upgraded` for each page in our application. An "upgraded page" has all the information we need to return the correct values, no matter which page we are using.
It returns a record with three functions. Each of these functions take page-specific input and output the **same type of value**.
Returning the same type is what allows us to easily create the top-level `init`, `update`, `view`, and `subscriptions` functions.
Fortunately, if we provide `Page.upgrade` with those variant functions and a page, `elm-spa` handles the logic for us!
```elm
pages :
{ home : Upgraded Home.Flags Home.Model Home.Msg
, aboutUs : Upgraded AboutUs.Flags AboutUs.Model AboutUs.Msg
, posts : Upgraded Posts.Flags Posts.Model Posts.Msg
, post : Upgraded Post.Flags Post.Model Post.Msg
, notFound : Upgraded NotFound.Flags NotFound.Model NotFound.Msg
}
pages =
{ home = Home.page |> Page.upgrade Home_Model Home_Msg
, aboutUs = AboutUs.page |> Page.upgrade AboutUs_Model AboutUs_Msg
, posts = Posts.page |> Page.upgrade Posts_Model Posts_Msg
, post = Post.page |> Page.upgrade Post_Model Post_Msg
, notFound = NotFound.page |> Page.upgrade NotFound_Model NotFound_Msg
}
```
It's okay if this doesn't make sense yet, let's look at how we use the upgraded `pages` values to create our top-level `Pages` functions:
### init
```elm
init : Url -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
init url =
case Route.fromUrl url of
Route.Home ->
pages.home.init ()
Route.AboutUs ->
pages.aboutUs.init ()
Route.Posts ->
pages.posts.init ()
Route.Post id ->
pages.post.init id
Route.NotFound ->
pages.notFound.init ()
```
### update
```elm
update : Msg -> Model -> Global.Model -> ( Model, Cmd Msg, Cmd Global.Msg )
update msg_ model_ =
case ( msg_, model_ ) of
( Home_Msg msg, Home_Model model ) ->
pages.home.update msg model
( AboutUs_Msg msg, AboutUs_Model model ) ->
pages.aboutUs.update msg model
( Posts_Msg msg, Posts_Model model ) ->
pages.posts.update msg model
( Post_Msg msg, Post_Model model ) ->
pages.post.update msg model
( NotFound_Msg msg, NotFound_Model model ) ->
pages.notFound.update msg model
_ ->
-- msg doesn't match model, no update
( model_, Cmd.none, Cmd.none )
```
### view + subscriptions
```elm
bundle Model -> Global.Model -> { view : Document Msg, subscriptions : Sub Msg }
bundle model_ =
case model_ of
Home_Model model ->
pages.home.bundle model
AboutUs_Model model ->
pages.aboutUs.bundle model
Posts_Model model ->
pages.posts.bundle model
Post_Model model ->
pages.post.bundle model
NotFound_Model model ->
pages.notFound.bundle model
view : Model -> Global.Model -> Document Msg
view model =
bundle model >> .view
subscriptions : Model -> Global.Model -> Sub Msg
subscriptions model =
bundle model >> .subscriptions
```
Each function provides page-specific flags, model, or msg values and returns the same `Model` and `Msg` types that the functions expect.
No need to manually upgrade these by hand.
## src/Document.elm
```elm
module Document exposing (Document, map)
import Html exposing (Html)
type alias Document msg =
{ title : String
, body : List (Html msg)
}
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
map fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
```
## src/Page.elm
The `ryannhg/elm-spa` package works with all different types of values, so it's useful to create a file at the top of the project to make type signatures less redundant and easier to read compiler errors.
Let's start with this:
```elm
module Page exposing
( Page
, static
, sandbox
, element
, component
, upgrade
)
import Document exposing (Document)
import Global
import Spa
type alias Page flags model msg =
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
, update : Global.Msg -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
, bundle :
{ view : Global.Model -> model -> Document msg
, subscriptions : Global.Model -> model -> Sub msg
}
}
static :
{ view : Document msg
}
-> Page flags () msg
static =
Spa.static
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> Document msg
}
-> Page flags model msg
sandbox =
Spa.sandbox
element :
{ init : flags -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> Document msg
, subscriptions : model -> Sub msg
}
-> Page flags model msg
element =
Spa.element
component :
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
, view : Global.Model -> model -> Document msg
, subscriptions : Global.Model -> model -> Sub msg
}
-> Page flags model msg
component =
Spa.component
upgrade
: (pageModel -> model)
-> (pageMsg -> msg)
-> Page flags model msg
-> { init : flags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, update : msg -> model -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, bundle : model -> Global.Model ->
{ view : Document msg
, subscriptions : Sub msg
}
}
upgrade =
Spa.upgrade
```
## src/Page/Home.elm
TODO
## src/Page/AboutUs.elm
TODO
## src/Page/Posts.elm
TODO
## src/Page/Post.elm
TODO
## src/Page/NotFound.elm
TODO

View File

@ -1,28 +0,0 @@
{
"name": "elm-spa-example",
"version": "4.1.0",
"description": "my new elm-spa application",
"main": "public/index.html",
"scripts": {
"start": "npm install && npm run build && npm run dev",
"build": "npm run build:elm-spa && npm run build:elm",
"build:elm-spa": "elm-spa build .",
"build:elm": "elm make src/Main.elm --optimize --output public/dist/elm.js",
"dev": "concurrently --raw --kill-others \"npm run dev:elm-spa\" \"npm run dev:elm\"",
"dev:elm-spa": "chokidar src/Pages -c \"npm run build:elm-spa\"",
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output public/dist/elm.js"
},
"keywords": [
"elm",
"spa"
],
"author": "Ryan Haskell-Glatz",
"license": "ISC",
"devDependencies": {
"chokidar-cli": "2.1.0",
"concurrently": "5.0.0",
"elm": "0.19.1-3",
"elm-live": "4.0.2",
"elm-spa": "4.1.0"
}
}

View File

@ -1,32 +0,0 @@
.fixed--full {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.absolute--full {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.absolute--center {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.bg--overlay {
background: rgba(0,0,0,0.25);
}
.min-width--480 {
min-width: 320px;
}
.bg--white {
background: white;
}

View File

@ -1,172 +0,0 @@
module Components exposing
( footer
, layout
, navbar
)
import Browser exposing (Document)
import Data.Modal as Modal exposing (Modal)
import Data.SignInForm exposing (SignInForm)
import Data.User as User exposing (User)
import Html exposing (..)
import Html.Attributes as Attr exposing (class, href)
import Html.Events as Events
import Generated.Route as Route
-- LAYOUT
layout :
{ page : Document msg
, global :
{ global
| modal : Maybe Modal
, user : Maybe User
}
, actions :
{ onSignOut : msg
, openSignInModal : msg
, closeModal : msg
, attemptSignIn : msg
, onSignInEmailInput : String -> msg
, onSignInPasswordInput : String -> msg
}
}
-> Document msg
layout { page, actions, global } =
{ title = page.title
, body =
[ div [ class "container pad--medium column spacing--large h--fill" ]
[ navbar { user = global.user, actions = actions }
, div [ class "column spacing--large", Attr.style "flex" "1 0 auto" ] page.body
, footer
, global.modal
|> Maybe.map (viewModal actions)
|> Maybe.withDefault (text "")
]
]
}
-- NAVBAR
navbar :
{ user : Maybe User
, actions : { actions | openSignInModal : msg, onSignOut : msg }
}
-> Html msg
navbar ({ actions } as options) =
header [ class "container" ]
[ div [ class "row spacing--between center-y" ]
[ a [ class "link font--h5 font--bold", href "/" ] [ text "home" ]
, div [ class "row spacing--medium center-y" ]
[ a [ class "link", href "/about" ] [ text "about" ]
, a [ class "link", href "/posts" ] [ text "posts" ]
, case options.user of
Just user ->
a [ class "link", href (Route.toHref Route.Profile) ] [ text "profile" ]
Nothing ->
button [ class "button", Events.onClick actions.openSignInModal ] [ text "sign in" ]
]
]
]
-- FOOTER
footer : Html msg
footer =
Html.footer [ class "container py--medium" ]
[ text "built with elm, 2020"
]
-- MODAL
viewModal :
{ actions
| closeModal : msg
, attemptSignIn : msg
, onSignInEmailInput : String -> msg
, onSignInPasswordInput : String -> msg
}
-> Modal
-> Html msg
viewModal actions modal_ =
case modal_ of
Modal.SignInModal { email, password } ->
modal
{ title = "Sign in"
, body =
form [ class "column spacing--medium", Events.onSubmit actions.attemptSignIn ]
[ emailField
{ label = "Email"
, value = email
, onInput = actions.onSignInEmailInput
}
, passwordField
{ label = "Password"
, value = password
, onInput = actions.onSignInPasswordInput
}
, button [ class "button" ] [ text "Sign in" ]
]
, actions = actions
}
modal :
{ title : String
, body : Html msg
, actions : { actions | closeModal : msg }
}
-> Html msg
modal ({ actions } as options) =
div [ class "fixed--full" ]
[ div [ class "absolute--full bg--overlay", Events.onClick actions.closeModal ] []
, div [ class "column spacing--large pad--large absolute--center min-width--480 bg--white" ]
[ div [ class "row spacing--between center-y" ]
[ h3 [ class "font--h3" ] [ text options.title ]
, button [ class "modal__close", Events.onClick actions.closeModal ] [ text "" ]
]
, options.body
]
]
-- FORMS
inputField :
String
-> { label : String, value : String, onInput : String -> msg }
-> Html msg
inputField type_ options =
label [ class "column spacing--small" ]
[ span [] [ text options.label ]
, input [ Attr.type_ type_, Attr.value options.value, Events.onInput options.onInput ] []
]
emailField :
{ label : String, value : String, onInput : String -> msg }
-> Html msg
emailField =
inputField "email"
passwordField :
{ label : String, value : String, onInput : String -> msg }
-> Html msg
passwordField =
inputField "password"

View File

@ -1,28 +0,0 @@
module Data.Modal exposing
( Modal(..)
, signInForm
, updateSignInForm
)
import Data.SignInForm exposing (SignInForm)
type Modal
= SignInModal SignInForm
signInForm : Modal -> Maybe SignInForm
signInForm modal =
case modal of
SignInModal form ->
Just form
updateSignInForm :
(SignInForm -> SignInForm)
-> Modal
-> Modal
updateSignInForm fn modal =
case modal of
SignInModal form ->
SignInModal (fn form)

View File

@ -1,35 +0,0 @@
module Data.SignInForm exposing
( Field(..)
, SignInForm
, empty
, updateEmail
, updatePassword
)
type alias SignInForm =
{ email : String
, password : String
}
type Field
= Email
| Password
empty : SignInForm
empty =
{ email = ""
, password = ""
}
updateEmail : String -> SignInForm -> SignInForm
updateEmail email form =
{ form | email = email }
updatePassword : String -> SignInForm -> SignInForm
updatePassword password form =
{ form | password = password }

View File

@ -1,41 +0,0 @@
module Data.Tab exposing
( Tab
, ourMission
, ourTeam
, ourValues
, toString
)
type Tab
= OurTeam
| OurValues
| OurMission
ourTeam : Tab
ourTeam =
OurTeam
ourValues : Tab
ourValues =
OurValues
ourMission : Tab
ourMission =
OurMission
toString : Tab -> String
toString tab =
case tab of
OurTeam ->
"Our Team"
OurValues ->
"Our Values"
OurMission ->
"Our Mission"

View File

@ -1,16 +0,0 @@
module Data.User exposing (User, fullname)
type alias User =
{ avatar : String
, name :
{ first : String
, last : String
}
, email : String
}
fullname : User -> String
fullname { name } =
name.first ++ " " ++ name.last

View File

@ -1,181 +0,0 @@
module Global exposing
( Flags
, Model
, Msg
, init
, openSignInModal
, signOut
, subscriptions
, update
, view
)
import Browser exposing (Document)
import Browser.Navigation as Nav
import Components
import Data.Modal as Modal exposing (Modal)
import Data.SignInForm as SignInForm exposing (SignInForm)
import Data.User exposing (User)
import Task
import Url exposing (Url)
-- INIT
type alias Flags =
()
type alias Model =
{ flags : Flags
, url : Url
, key : Nav.Key
, user : Maybe User
, modal : Maybe Modal
}
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
( Model
flags
url
key
Nothing
Nothing
, Cmd.none
)
-- UPDATE
type Msg
= AttemptSignIn
| SignOut
| UpdateSignInForm SignInForm.Field String
| OpenModal Modal
| CloseModal
openSignInModal : Cmd Msg
openSignInModal =
send (OpenModal (Modal.SignInModal SignInForm.empty))
signOut : Cmd Msg
signOut =
send SignOut
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- SIGN IN
AttemptSignIn ->
model.modal
|> Maybe.andThen Modal.signInForm
|> Maybe.map (attemptSignIn model)
|> Maybe.withDefault ( model, Cmd.none )
SignOut ->
( { model | user = Nothing }
, Cmd.none
)
UpdateSignInForm field value ->
let
updateFieldWith : String -> SignInForm -> SignInForm
updateFieldWith =
case field of
SignInForm.Email ->
SignInForm.updateEmail
SignInForm.Password ->
SignInForm.updatePassword
in
( model.modal
|> Maybe.map (Modal.updateSignInForm (updateFieldWith value))
|> (\modal -> { model | modal = modal })
, Cmd.none
)
-- MODAL
OpenModal modal ->
( { model | modal = Just modal }
, Cmd.none
)
CloseModal ->
( { model | modal = Nothing }
, Cmd.none
)
attemptSignIn : Model -> SignInForm -> ( Model, Cmd Msg )
attemptSignIn model form =
if form.email == "ryan.nhg@gmail.com" && form.password == "password" then
( { model
| user =
Just
(User
"https://avatars2.githubusercontent.com/u/6187256?s=128&v=4"
{ first = "Ryan", last = "Haskell-Glatz" }
form.email
)
, modal = Nothing
}
, Cmd.none
)
else
( model, Cmd.none )
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view :
{ page : Document msg
, global : Model
, toMsg : Msg -> msg
}
-> Document msg
view { page, global, toMsg } =
let
actions =
{ onSignOut = toMsg <| SignOut
, openSignInModal = toMsg <| OpenModal (Modal.SignInModal SignInForm.empty)
, closeModal = toMsg <| CloseModal
, attemptSignIn = toMsg <| AttemptSignIn
, onSignInEmailInput = toMsg << UpdateSignInForm SignInForm.Email
, onSignInPasswordInput = toMsg << UpdateSignInForm SignInForm.Password
}
in
Components.layout
{ page = page
, global = global
, actions = actions
}
-- UTILS
send : msg -> Cmd msg
send msg =
Task.succeed msg |> Task.perform identity

View File

@ -1,141 +0,0 @@
module Main exposing (main)
import Browser exposing (Document)
import Browser.Navigation as Nav exposing (Key)
import Global
import Html
import Generated.Pages as Pages
import Generated.Route as Route exposing (Route)
import Url exposing (Url)
main : Program Flags Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlRequest = LinkClicked
, onUrlChange = UrlChanged
}
-- INIT
type alias Flags =
()
type alias Model =
{ key : Key
, url : Url
, global : Global.Model
, page : Pages.Model
}
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( global, globalCmd ) =
Global.init flags url key
( page, pageCmd, pageGlobalCmd ) =
Pages.init (fromUrl url) global
in
( Model key url global page
, Cmd.batch
[ Cmd.map Global globalCmd
, Cmd.map Global pageGlobalCmd
, Cmd.map Page pageCmd
]
)
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url
| Global Global.Msg
| Page Pages.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked (Browser.Internal url) ->
( model, Nav.pushUrl model.key (Url.toString url) )
LinkClicked (Browser.External href) ->
( model, Nav.load href )
UrlChanged url ->
let
( page, pageCmd, globalCmd ) =
Pages.init (fromUrl url) model.global
in
( { model | url = url, page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
Global globalMsg ->
let
( global, globalCmd ) =
Global.update globalMsg model.global
in
( { model | global = global }
, Cmd.map Global globalCmd
)
Page pageMsg ->
let
( page, pageCmd, globalCmd ) =
Pages.update pageMsg model.page model.global
in
( { model | page = page }
, Cmd.batch
[ Cmd.map Page pageCmd
, Cmd.map Global globalCmd
]
)
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ model.global
|> Global.subscriptions
|> Sub.map Global
, model.page
|> (\page -> Pages.subscriptions page model.global)
|> Sub.map Page
]
view : Model -> Browser.Document Msg
view model =
let
documentMap :
(msg1 -> msg2)
-> Document msg1
-> Document msg2
documentMap fn doc =
{ title = doc.title
, body = List.map (Html.map fn) doc.body
}
in
Global.view
{ page = Pages.view model.page model.global |> documentMap Page
, global = model.global
, toMsg = Global
}
fromUrl : Url -> Route
fromUrl =
Route.fromUrl >> Maybe.withDefault Route.NotFound

View File

@ -1,81 +0,0 @@
module Page exposing
( Page, Document, Bundle
, upgrade
, static, sandbox, element, component
)
{-|
@docs Page, Document, Bundle
@docs upgrade
@docs static, sandbox, element, component
-}
import Browser
import Global
import Spa
type alias Document msg =
Browser.Document msg
type alias Page flags model msg =
Spa.Page flags model msg Global.Model Global.Msg
type alias Bundle msg =
Spa.Bundle msg
upgrade :
(pageModel -> model)
-> (pageMsg -> msg)
-> Page pageFlags pageModel pageMsg
->
{ init : pageFlags -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, update : pageMsg -> pageModel -> Global.Model -> ( model, Cmd msg, Cmd Global.Msg )
, bundle : pageModel -> Global.Model -> Bundle msg
}
upgrade =
Spa.upgrade
static : { view : Document msg } -> Page flags () msg
static =
Spa.static
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> Document msg
}
-> Page flags model msg
sandbox =
Spa.sandbox
element :
{ init : flags -> ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
, view : model -> Document msg
}
-> Page flags model msg
element =
Spa.element
component :
{ init : Global.Model -> flags -> ( model, Cmd msg, Cmd Global.Msg )
, update : Global.Model -> msg -> model -> ( model, Cmd msg, Cmd Global.Msg )
, subscriptions : Global.Model -> model -> Sub msg
, view : Global.Model -> model -> Document msg
}
-> Page flags model msg
component =
Spa.component

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