Merge branch 'next' of github.com:ryannhg/elm-spa into next

This commit is contained in:
Ryan Haskell-Glatz 2021-04-24 14:03:02 -05:00
commit 1ee15ed79c
100 changed files with 3425 additions and 1263 deletions

View File

@ -1,4 +1,4 @@
# [![elm-spa](https://v6-elm-spa.netlify.app/images/outlined-to-edge.png)](https://elm-spa.dev)
# [![elm-spa](https://next.elm-spa.dev/images/rounded-logo-bg.png)](https://elm-spa.dev)
# **Installation**

View File

@ -1,6 +1,13 @@
[build]
publish = "public"
command = "npm i elm-spa@beta && node scripts/generate-index.js && npx elm-spa build"
command = "npm i elm elm-spa@beta && node scripts/generate-index.js && npx elm-spa build"
# Prevents missing markdown files from redirecting to index.html
[[redirects]]
from = "/content/*"
to = "/content/:splat"
status = 200
force = true
[[redirects]]
from = "/*"

View File

@ -1,4 +1,4 @@
# Guide
# Docs
Welcome to __elm-spa__, a framework for building web applications with [Elm](https://elm-lang.org)!
If you are new to Elm, you should check out [the official guide](https://guide.elm-lang.org), which
@ -44,7 +44,7 @@ npx elm-spa server
So far, we've used [npx](https://www.npmjs.com/package/npx) so we don't need to install __elm-spa__ directly. If you'd like to run commands from the terminal, without the `npx` prefix, you can install __elm-spa__ like this:
```terminal
npm install -g elm-spa@next
npm install -g elm-spa@latest
```
You can verify the install succeeded by running `elm-spa help` from your terminal:
@ -61,11 +61,11 @@ elm-spa build . . . . . one-time production build
elm-spa watch . . . . . . runs build as you code
elm-spa server . . . . . start a live dev server
Visit https://next.elm-spa.dev for more!
Visit https://elm-spa.dev for more!
```
---
__Ready for more?__
Let's check out [the CLI](/guide/cli) to learn more about those five commands!
Let's check out [the CLI](/docs/cli) to learn more about those five commands!

View File

@ -3,16 +3,16 @@
At the end of the last section, we installed the __elm-spa__ CLI using [npm](https://npmjs.org) like this:
```terminal
npm install -g elm-spa@next
npm install -g elm-spa@latest
```
This gave us the ability to run a few commands:
1. __[`elm-spa new`](#elm-spa-new)__ - creates a new project
1. __[`elm-spa server`](#elm-spa-server)__ - runs a dev server as you code
1. __[`elm-spa watch`](#elm-spa-watch)__ - builds as you code
1. __[`elm-spa build`](#elm-spa-build)__ - one-time production build
1. __[`elm-spa add`](#elm-spa-add)__ - adds a page to an existing project
1. [__elm-spa new__](#elm-spa-new) - creates a new project
1. [__elm-spa server__](#elm-spa-server) - runs a dev server as you code
1. [__elm-spa watch__](#elm-spa-watch) - builds as you code
1. [__elm-spa build__](#elm-spa-build) - one-time production build
1. [__elm-spa add__](#elm-spa-add) - adds a page to an existing project
What do these do? This section of the guide dives into more detail on each command!
@ -57,7 +57,7 @@ If you want the automatic compilation on change, but don't need a HTTP server, y
elm-spa watch
```
This will automatically generate and compile on save, but without the server. This is a great choice when combining __elm-spa__ with another tool like [Parcel](https://parceljs.org/elm.html)
This will automatically generate code and compile your Elm files on save, but without the server. This is a great command to combine __elm-spa__ with another tool like [Parcel](https://parceljs.org/elm.html).
## elm-spa build
@ -103,9 +103,3 @@ elm-spa add /example static
elm-spa add /example sandbox
elm-spa add /example element
```
---
__So, what's a page?__
Let's answer that next in the [pages section](./pages)!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,7 @@
# Examples
# Examples
Here are real-world applications using __elm-spa__:
- This site
- Realworld example app
- User featured projects

View File

@ -1 +0,0 @@
# User Authentication

View File

@ -1,228 +0,0 @@
# Pages
In __elm-spa__, every URL connects to a single page. Let's take a closer look at the homepage we created earlier with the `elm-spa new` command:
```elm
module Pages.Home_ exposing (view)
import Html
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
```
This homepage renders the tab `title`, and a HTML `body` onto the page. This is great when you have a static page that just needs to render some elements.
Because the file is named `Home_.elm`, we know it's the homepage. These 8 lines of code are all we need to tell __elm-spa__ we'd like to render this when users visit the homepage.
For real world applications, we'll need pages that can do more. That's where the `Page` module comes in handy.
### Upgrading "Hello World!"
Let's start by introducing the `page` function, marking the start of our journey from "Hello world!" to the real world:
```elm
module Pages.Home_ exposing (page)
-- our other imports
import Page exposing (Page)
page :
Shared.Model
-> Request Params
-> Page () Never
page shared req =
Page.static
{ view = view
}
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]
}
```
Here, our code hasn't changed very much- except now we have this new `page` function that:
1. Takes in two inputs: `Shared.Model` and `Request Params`
2. Returns a `Page () Never` value.
3. Is exposed at the top of our `Pages.Home_` module.
> Without exposing `page`, __elm-spa__ will not understand how to compile your application. Make sure to _always_ expose `page` from modules within the `src/Pages` folder.
This new `page` will always get the latest `Shared.Model` and URL information, which means you don't have to worry about tracking that stuff yourself.
This is great, but there is still more that our `page` function can do other than render a view!
### Beyond static pages
The right page to use is the __simplest page__ that can support what you need! As we move from `Page.static` to `Page.advanced`, we'll have more capabilities, but at the cost of more code.
This section of the guide will introduce you to the functions exposed by the `Page` module, so you have all the information you need.
__[Page.static](#pagestatic)__ - for pages that only render a view.
```elm
Page.static
{ view : View Never
}
```
__[Page.sandbox](#pagesandbox)__ - for pages that need to keep track of state.
```elm
Page.sandbox
{ init : Model
, update : Msg -> Model -> Model
, view : Model -> View Msg
}
```
__[Page.element](#pageelement)__ - for pages that send HTTP requests or continually listen for events from the browser or user.
```elm
Page.element
{ init : ( Model, Cmd Msg )
, update : Msg -> Model -> ( Model, Cmd Msg )
, view : Model -> View Msg
, subscriptions : Model -> Sub Msg
}
```
__[Page.advanced](#pageadvanced)__ - For pages that need to sign in a user or work with other details that should persist between page navigation.
```elm
Page.advanced
{ init : ( Model, Effect Msg )
, update : Msg -> Model -> ( Model, Effect Msg )
, view : Model -> View Msg
, subscriptions : Model -> Sub Msg
}
```
### Working with pages
The `page` function allows us to pass `Shared.Model` and `Request` information to any inner function that needs it.
```elm
page shared req =
Page.sandbox
{ init = init shared -- pass init "shared"
, update = update req -- pass update "req"
, view = view
}
```
Imagine your `init` function needs access to `shared` data, and your `update` function needs URL information from the current `req`.
Because `page` is a function, you can pass those values in where you see fit. This means the type annotations of those inner functions should also update:
__Before__
```elm
page shared req =
Page.sandbox
{ init = init
, update = update
, view = view
}
init : Model
update : Msg -> Model -> Model
view : Model -> View Msg
```
__After__
```elm
page shared req =
Page.sandbox
{ init = init shared
, update = update req
, view = view
}
init : Shared.Model -> Model
update : Request Params -> Msg -> Model -> Model
view : Model -> View Msg
```
Notice how the type annotations of `init` and `update` changed to accept their input? (The `view` function didn't change because it didn't get any new values)
## Page.static
```elm
Page.static
{ view : View Never
}
```
( video introducing concept )
## Page.sandbox
```elm
Page.sandbox
{ init : Model
, update : Msg -> Model -> Model
, view : Model -> View Msg
}
```
( video introducing concept )
## Page.element
```elm
Page.element
{ init : ( Model, Cmd Msg )
, update : Msg -> Model -> ( Model, Cmd Msg )
, view : Model -> View Msg
, subscriptions : Model -> Sub Msg
}
```
( video introducing concept )
## Page.advanced
```elm
Page.advanced
{ init : ( Model, Effect Msg )
, update : Msg -> Model -> ( Model, Effect Msg )
, view : Model -> View Msg
, subscriptions : Model -> Sub Msg
}
```
( video introducing concept )
## Page.protected
Each of the four page types also have a "protected" version, that is guaranteed to receive a `User` or redirect if no user is signed in.
```elm
Page.protected.static
{ view : User -> View Never
}
Page.protected.sandbox
{ init : User -> Model
, update : User -> Msg -> Model -> Model
, view : User -> Model -> View Msg
}
-- Page.protected.element
-- Page.protected.advanced
```
When you are ready, you can learn more about this in the [user authentication example](/examples/authentication).
---
__What's next?__
Let me introduce you to the `Request Params` type we pass into our pages in the [next section on requests](./requests)

View File

@ -1,113 +0,0 @@
# Requests
Every URL that a user visits in your application contains useful information. When __elm-spa__ gets an updated URL, it passes that information to every [Page](/guide/pages) as a `Request` value.
This section of the guide breaks down the [Request](https://package.elm-lang.org/packages/ryannhg/elm-spa/latest/ElmSpa-Request) type exposed by the official Elm package:
```elm
type alias Request params =
{ params : params
, query : Dict String String
, url : Url
, route : Route
, key : Nav.Key
}
```
## URL Parameters
Every request has parameters that you can rely on. If you are on a [dynamic route](/guide/routing#dynamic-routes), you have access to that route's URL parameters:
URL | Params
--- | ---
`/` | `()`
`/about-us` | `()`
`/people/:name` | `{ name : String }`
`/posts/:post/comments/:comment` | `{ post : String, comment : String }`
The first two examples from that table are __static routes__, so there are no dynamic parameters available. The last two examples are guaranteed to have values at `req.params`.
All dynamic parameters are `String` types, so feel free to validate them at the page level.
```elm
greet : Request { name : String } -> String
greet req =
"Hello, " ++ req.params.name ++ "!"
```
__Note:__ When working with [shared state](/guide/shared-state), all requests are `Request ()`.
## Query Parameters
For convenience, query parameters are automatically turned into a `Dict String String`, making it easy to handle common query URL parameters like these:
```
/people?team=design&ascending
```
```elm
Dict.get "team" req.query == Just "design"
Dict.get "ascending" req.query == Just ""
Dict.get "name" req.query == Nothing
```
__Note:__ If you need ever access to the raw URL query string, you can with the `req.url.query` value!
## Raw URLs
If you need the `port`, `hostname`, or anything else it is available at `req.url`, which contains the original [elm/url](https://package.elm-lang.org/packages/elm/url/latest/Url) URL value.
```elm
type alias Url =
{ protocol : Protocol
, host : String
, port_ : Maybe Int
, path : String
, query : Maybe String
, fragment : Maybe String
}
```
This is less common than `req.params` and `req.query`, but can be useful for getting the `hash` at the end of a URL too!
## Getting the current route
The `Request` type also has access to the `Route` value, so you can easily do comparisons against the current route!
```elm
-- "/"
req.route == Gen.Route.Home_
-- "/about-us"
req.route == Gen.Route.AboutUs
-- "/people/ryan"
req.route == Gen.Route.People.Name_ { name = "ryan" }
```
## Programmatic Navigation
Most of the time, navigation in Elm is as easy as giving an `href` attribute to an anchor tag:
```elm
a [ href "/guide" ] [ text "elm-spa guide" ]
```
Other times, you'll want to do __programmatic navigation__ navigating to another page after some event completes. Maybe you want to __redirect__ to a sign in page, or head to the __dashboard after signing in successfully__.
In that case we store `req.key` in order to use `Request.pushRoute` or `Request.replaceRoute`. Here's a quick example of what that looks like:
```elm
type Msg = SignedIn User
update : Request Params -> Msg -> Model -> ( Model, Effect Msg )
update req msg model =
case msg of
SignedIn user ->
( model
, Request.pushRoute Gen.Route.Dashboard req
)
```
When the `SignedIn` message is fired, this code will redirect the user to the `Dashboard` route.

View File

@ -1,106 +0,0 @@
# Routing
One of the best reasons to use __elm-spa__ is the automatic routing! Inspired by popular JS frameworks like _NuxtJS_, we use file names to determine routes in your application.
Every __elm-spa__ project will have a `Pages` folder with all the pages in the application.
URL | File
--- | ---
`/` | `src/Pages/Home_.elm`
`/people` | `src/Pages/People.elm`
`/people/:name` | `src/Pages/People/Name_.elm`
`/about-us` | `src/Pages/AboutUs.elm`
`/settings/users` | `src/Pages/Settings/Users.elm`
In this section, we'll cover the 3 kinds of routes you can find in an __elm-spa__ application.
## The homepage
The `src/Pages/Home_.elm` is a reserved filename that handles requests to /. The easiest way to make a new homepage is with the [`add` command](/guide/cli#adding-a-homepage) covered in the CLI section:
```terminal
elm-spa add /
```
__Note:__ Without the trailing underscore, __elm-spa__ will treat `Home.elm` as a route to `/home`! This is called a "static route", and will be covered next.
## Static routes
Most pages will be __static routes__, meaning the filepath will translate to a single URL.
```terminal
elm-spa add /people
```
This command creates a page at `src/Pages/People.elm` that will be shown when the user visits `/people` in your app!
These are more examples of static routes:
URL | File
--- | ---
`/dashboard` | `src/Pages/Dashboard.elm`
`/people` | `src/Pages/People.elm`
`/about-us` | `src/Pages/AboutUs.elm`
`/settings/users` | `src/Pages/Settings/Users.elm`
### Nested static routes
You can use folders to have __nested static routes__:
```terminal
elm-spa add /settings/users
```
This example creates a file at `src/Pages/Settings/Users.elm`, which will handle all requests to `/settings/user`. You can nest things multiple levels by creating even more nested folders:
```terminal
elm-spa add /settings/user/contact
```
## Dynamic routes
Sometimes a 1:1 mapping is what you need, but other times, its useful to have a route that handles requests to many items.
```terminal
elm-spa add /people/:name
```
This will create a file at `src/Pages/People/Name_.elm`. In __elm-spa__, this is called a __dynamic route__. It will handle requests to any URLs that match `/people/____` and provide the dynamic part in the parameters.
URL | Params
--- | ---
`/people/ryan` | `{ name = "ryan" }`
`/people/alexa` | `{ name = "alexa" }`
`/people/erik` | `{ name = "erik" }`
The __trailing underscore__ at the end of the filename (`Name_.elm`) indicates that this route is __dynamic__. Without the underscore, it would only handle requests to `/people/name`
The name of the route parameter variable (`name` in this example) is determined by the name of the file! If we named the file `Id_.elm`, the dynamic value would be available at `params.id` instead.
Every page gets access to these dynamic parameters, via the [`Request params`](/guide/pages#requests) value passed in. We'll cover that in the next section!
### Nested dynamic routes
Just like we saw with __nested static routes__, you can use nested folders to create nested dynamic routes!
```terminal
elm-spa add /users/:name/posts/:id
```
This creates a file at `src/Users/Name_/Posts/Id_.elm`
URL | Params
--- | ---
`/users/ryan/posts/123` | `{ name = "ryan", id = "123" }`
`/users/alexa/posts/456` | `{ name = "alexa", id = "456" }`
`/users/erik/posts/789` | `{ name = "erik", id = "789" }`
It will handle any request to `/users/___/posts/___`
## Not found page
If a user visits a URL that doesn't have a corresponding page, it will redirect to the `NotFound.elm` page. This is generated for you by default in the `.elm-spa/defaults/Pages` folder. When you are ready to customize it, move it into `src/Pages` and customize it like you would any other page!
In __elm-spa__, this technique is called "ejecting" a default file. Throughout the guide, we'll find more default files that we might want to control in our project.

View File

@ -1,45 +0,0 @@
# Shared State
With __elm-spa__, every time you navigate from one page to another, the `init` function for that page is called. This means that the `Model` for the page you we're previously looking at has been cleared out. Most of the time, that's a good thing!
Other times, it makes sense to __share state between pages__! Maybe you have a signed-in user, an API token, or settings like "dark mode" that you want to persist from one page to another. This section of the guide will show you how to do that!
## Ejecting the shared file
Default files are automatically generated for you in the `.elm-spa/defaults`, and when you need to tweak them, you can move them into your project's `src` folder. This process is known as "ejecting default files", and comes up for advanced features.
__To get started__ with shared state between pages, move the `.elm-spa/defaults/Shared.elm` file into your `src` folder! After you move that file, `src/Shared.elm` will be the place to make changes!
The rest of this section walks through the different functions in the `Shared` module, so you know what's going on.
### init
```elm
init : Flags -> Request () -> Model -> ( Model, Effect Msg )
```
The `init` function is called when your page loads for the first time. It takes in two inputs:
- `Flags` - initial JSON value passed in from `public/main.js
- `Request ()` - a [Request](/guide/request) value with the current URL information
The `init` function returns the initial `Model`, as well as any `Effect`s you'd like to run (like initial HTTP requests, etc)
__Note:__ The [Effect msg] type is just an alias for `Cmd msg`, but adds support for [elm-program-test]()
### update
```elm
update : Request () -> Msg -> Model -> ( Model, Effect Msg )
```
The `update` function allows you to respond when one of your pages or this module send a `Shared.Msg`. Just like pages, you define `Msg` types and handle how they update the shared state here.
### subscriptions
```elm
subscriptions : Request () -> Model -> Sub Msg
```
If you want all pages to listen for keyboard events, window resizing, or other external updates, this `subscriptions` function is a great place to wire those up! It also has access to the current URL request value, so you can conditionally subscribe to events.

View File

@ -1,15 +0,0 @@
# Views
With __elm-spa__, you can choose any Elm view library you like. Whether it's
[elm/html](#), [Elm UI](#), or even your own custom library, the `View` module
has got you covered!
```elm
type alias View msg =
{ title : String
, body : List (Html msg)
}
```
By default, a `View` lets you set the tab title as well as render some `Html` in
the `body` value.

View File

@ -0,0 +1,7 @@
# Guides
Ready to get started with __elm-spa__? Me too!
...
Unfortunately, I haven't uploaded any guides yet...

View File

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

View File

@ -0,0 +1,204 @@
# Pages & routing
This next guide will show you how pages, routing, and the `elm-spa add` command work together to automatically handle URLs in your __elm-spa__ application.
> You can see the source code in the [examples](https://github.com/ryannhg/elm-spa/tree/next/examples/02-pages) folder on GitHub.
### The setup
Just like with the last guide, we can use `elm-spa new` and `elm-spa server` to get a brand new __elm-spa__ project up and running:
```terminal
mkdir 02-pages
cd 02-pages
elm-spa new
elm-spa server
```
This generates the "Hello, world!" homepage from before:
![A browser displaying "Hello world"](/content/images/01-hello-world.png)
### Adding a static page
```terminal
elm-spa add /static static
```
This command adds a page at [http://localhost:1234/static](http://localhost:1234/static) with the `static` template. This is similar to `Home_.elm`, but it has access to `Shared.Model` and `Request` in case we need data from either of those.
Here is the complete `Static.elm` file:
```elm
module Pages.Static exposing (page)
import Page exposing (Page)
import Request exposing (Request)
import Shared
import View exposing (View)
page : Shared.Model -> Request -> Page
page shared req =
Page.static
{ view = view
}
view : View msg
view =
View.placeholder "Static"
```
The `View.placeholder` function just stubs out the `view` function with an empty page that only renders "Static" in the browser.
Visit [http://localhost:1234/static](http://localhost:1234/static) to see it in action!
### Making a layout
Before we continue, I want to make a layout with a navbar so that we can easily navigate between pages without manually editing the URL.
I'll create a file at `src/UI.elm` that looks like this:
```elm
module UI exposing (layout)
import Html exposing (Html)
import Html.Attributes as Attr
layout : List (Html msg) -> List (Html msg)
layout children =
let
viewLink : String -> String -> Html msg
viewLink label url =
Html.a [ Attr.href url ] [ Html.text label ]
in
[ Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ viewLink "Home" "/"
, viewLink "Static" "/static"
]
, Html.main_ [] children
]
]
```
### Using the layout in a page
Because it works from one `List (Html msg)` to another, we can add `UI.layout` in front of the `body` list on both pages:
```elm
-- src/Pages/Home_.elm
view : View msg
view =
{ title = "Homepage"
, body = UI.layout [ Html.text "Homepage" ]
}
```
```elm
-- src/Pages/Static.elm
view : View msg
view =
{ title = "Static"
, body = UI.layout [ Html.text "Static" ]
}
```
### Use routes, not strings
In `src/UI.elm`, we had a function for rendering our navbar links that looked like this:
```elm
viewLink : String -> String -> Html msg
viewLink label url =
Html.a [ Attr.href url ] [ Html.text label ]
```
This function works great but it's possible to provide a URL that our application doesn't have!
```elm
[ viewLink "Home" "/"
, viewLink "Static" "/satic"
]
```
Here, I mistyped the URL `/satic`, but the compiler didn't warn me about it! Let's use the `Route` values generated by __elm-spa__ to improve this experience:
```elm
import Gen.Route as Route exposing (Route)
viewLink : String -> Route -> Html msg
viewLink label route =
Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ]
```
By using the `Gen.Route` module from `.elm-spa/generated`, we can pass in a `Route` instead of a `String`:
```elm
[ viewLink "Home" Route.Home_
, viewLink "Static" Route.Static
]
```
This will prevent typos, but __more importantly__ it allows the Elm compiler to remind us to update the navbar in case we remove `Home_.elm` or `Static.elm` in the future.
Deleting either of those pages changes the generated `Gen.Route` module, so the compiler can let us know that our `UI.layout` function has a broken link before our users do!
### Adding CSS
In `UI.layout`, we used `Attr.class` to provide our HTML with some CSS classes:
```elm
Html.div [ Attr.class "container" ]
[ Html.header [ Attr.class "navbar" ]
[ viewLink "Home" Route.Home_
, viewLink "Static" Route.Static
]
]
```
The `container` and `navbar` classes are used in our code, but not defined in a CSS file. Let's fix that by creating a new CSS file at `public/style.css`:
```css
.container {
max-width: 960px;
margin: 1rem auto;
}
.navbar {
display: flex;
align-items: center;
}
.navbar a {
margin-right: 16px;
}
```
After creating `style.css`, we can import the file in our `public/index.html` entrypoint:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- import our new CSS file -->
<link rel="stylesheet" href="/style.css">
</head>
<body>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>
```
Using the `<link>` tag as shown above (with the leading slash!) imports our CSS file. All files in the `public` folder are available at the root of our web application. That means a file stored at `public/images/dog.png` would be at `http://localhost:1234/images/dog`, without including `public` in the URL at all.

View File

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

View File

@ -0,0 +1 @@
# Local storage

View File

@ -0,0 +1 @@
# Working with NPM

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -2,9 +2,12 @@ const app = Elm.Main.init({ flags: window.__FLAGS__ })
// Handle smoothly scrolling to links
const scrollToHash = () => {
const BREAKPOINT_XL = 1920
const NAVBAR_HEIGHT_PX = window.innerWidth > BREAKPOINT_XL ? 127 : 102
const element = window.location.hash && document.querySelector(window.location.hash)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
// element.scrollIntoView({ behavior: 'smooth' })
window.scroll({ behavior: 'smooth', top: window.pageYOffset + element.getBoundingClientRect().top - NAVBAR_HEIGHT_PX })
} else {
window.scroll({ behavior: 'smooth', top: 0 })
}
@ -28,11 +31,11 @@ window.addEventListener('keypress', (e) => {
// HighlightJS custom element
customElements.define('prism-js', class HighlightJS extends HTMLElement {
constructor () { super() }
connectedCallback () {
constructor() { super() }
connectedCallback() {
const pre = document.createElement('pre')
pre.className = `language-elm`
pre.className = this.language ? `language-${this.language}` : `language-elm`
pre.textContent = this.body
this.appendChild(pre)
@ -42,10 +45,10 @@ customElements.define('prism-js', class HighlightJS extends HTMLElement {
// Dropdown arrow key support
customElements.define('dropdown-arrow-keys', class DropdownArrowKeys extends HTMLElement {
constructor () {
constructor() {
super()
}
connectedCallback () {
connectedCallback() {
const component = this
const arrows = { ArrowUp: -1, ArrowDown: 1 }
const interactiveChildren = () => component.querySelectorAll('input, a, button')

View File

@ -6,7 +6,7 @@
--weight--light: 300;
--weight--semibold: 600;
--weight--bold: 800;
--color--white: #ffffff;
--color--grey-100: #f0f0f0;
--color--grey-200: #cccccc;
@ -15,22 +15,25 @@
--color--grey-700: #333333;
--color--green: #407742;
--color--green-light: #d7ead8;
--size--h1: 4em;
--size--h1: 3em;
--size--h2: 2em;
--size--h3: 1.5em;
--size--h4: 1.2em;
--size--h5: 1.2em;
--size--h6: 0.75em;
--size--paragraph: 1.2em;
--shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
--shadow-dark: 0 0.5em 2em rgb(0, 0, 0, 0.2);
--height--header: 102px;
}
/* Resets */
@media screen and (min-width: 1920px ) {
html { font-size: 20px; }
:root { --height--header: 127px; }
}
body {
@ -39,6 +42,7 @@ body {
font-family: var(--font--body);
font-weight: var(--weight--light);
overflow-y: scroll;
padding-top: var(--height--header);
}
* {
@ -64,6 +68,16 @@ pre {
background-color: white;
}
.aside {
white-space: nowrap;
}
.table-of-contents {
min-width: 14em;
max-width: 14em;
white-space: nowrap;
}
main {
animation: fadeIn 200ms 400ms ease-in forwards;
opacity: 0;
@ -163,12 +177,14 @@ hr { border: 0; }
line-height: 1.4;
}
.markdown p code {
.markdown p code,
.markdown li code {
font-size: 0.92em;
color: var(--color--green);
}
.markdown p code::before { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown p code::after { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown p code::before, .markdown li code::before { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown p code::after, .markdown li code::after { content: '`'; opacity: 0.75; pointer-events: none; user-select: none; }
.markdown blockquote {
padding-left: 1rem;
@ -207,6 +223,8 @@ hr { border: 0; }
background-color: var(--color--grey-700);
color: var(--color--grey-100);
padding: 1rem;
font-size: 0.9em;
font-family: var(--font--monospace);
}
.markdown pre.language-terminal {
@ -236,6 +254,7 @@ hr { border: 0; }
overflow: hidden;
background-color: var(--color--white);
border-color: var(--color--grey-200);
font-size: 0.85em;
}
.markdown tr {
@ -243,9 +262,7 @@ hr { border: 0; }
}
.markdown th,
.markdown td {
padding: 0.75em;
}
.markdown td { padding: 0.75em }
.markdown tbody tr:nth-child(2n + 1) {
background-color: var(--color--grey-100);
@ -315,10 +332,29 @@ hr { border: 0; }
border-color: var(--color--green);
}
/* STICKY SCROLLING */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1;
background: var(--color--grey-100);
box-shadow: 0 1em 1em var(--color--grey-100);
}
.header__logo {
font-size: 1.5em;
}
@media screen and (max-width: 640px) {
.header__logo { font-size: 1.25em }
}
.sticky {
position: sticky;
left: 0;
top: 0;
top: var(--height--header);
}
/* Images & Icons */

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,10 @@ const config = {
output: path.join(__dirname, '..', 'public', 'dist')
}
// Terminal color output
const green = ``
const reset = ``
// Recursively lists all files in the given folder
const listContainedFiles = async (folder) => {
let files = []
@ -54,7 +58,7 @@ const main = () =>
await fs.mkdir(config.output, { recursive: true })
return fs.writeFile(path.join(config.output, 'flags.js'), contents, { encoding: 'utf-8' })
})
.then(_ => console.info(`\n ✓ Indexed the content folder\n`))
.then(_ => console.info(`\n ${green}${reset} Indexed the content folder\n`))
.catch(console.error)
// Run the program

View File

@ -106,7 +106,8 @@ sections : Index -> List Section
sections index =
let
sectionOrder =
[ "Guide"
[ "Docs"
, "Guides"
, "Examples"
]

View File

@ -3,10 +3,11 @@ module Main exposing (main)
import Browser
import Browser.Navigation as Nav exposing (Key)
import Effect
import Gen.Model
import Gen.Pages as Pages
import Gen.Route as Route
import Ports
import Request exposing (Request)
import Request
import Shared
import Url exposing (Url)
import View
@ -40,7 +41,7 @@ init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
( shared, sharedCmd ) =
Shared.init (request { url = url, key = key }) flags
Shared.init (Request.create () url key) flags
( page, effect ) =
Pages.init (Route.fromUrl url) shared url key
@ -69,11 +70,7 @@ update msg model =
case msg of
ClickedLink (Browser.Internal url) ->
( model
, if url.path == model.url.path then
Nav.replaceUrl model.key (Url.toString url)
else
Nav.pushUrl model.key (Url.toString url)
, Nav.pushUrl model.key (Url.toString url)
)
ClickedLink (Browser.External url) ->
@ -82,12 +79,7 @@ update msg model =
)
ChangedUrl url ->
if url.path == model.url.path then
( { model | url = url }
, Ports.onUrlChange ()
)
else
if url.path /= model.url.path then
let
( page, effect ) =
Pages.init (Route.fromUrl url) model.shared url model.key
@ -99,14 +91,31 @@ update msg model =
]
)
else
( { model | url = url }
, Ports.onUrlChange ()
)
Shared sharedMsg ->
let
( shared, sharedCmd ) =
Shared.update (request model) sharedMsg model.shared
Shared.update (Request.create () model.url model.key) sharedMsg model.shared
( page, effect ) =
Pages.init (Route.fromUrl model.url) shared model.url model.key
in
( { model | shared = shared }
, Cmd.map Shared sharedCmd
)
if page == Gen.Model.Redirecting_ then
( { model | shared = shared, page = page }
, Cmd.batch
[ Cmd.map Shared sharedCmd
, Effect.toCmd ( Shared, Page ) effect
]
)
else
( { model | shared = shared }
, Cmd.map Shared sharedCmd
)
Page pageMsg ->
let
@ -137,14 +146,5 @@ subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Pages.subscriptions model.page model.shared model.url model.key |> Sub.map Page
, Shared.subscriptions (request model) model.shared |> Sub.map Shared
, Shared.subscriptions (Request.create () model.url model.key) model.shared |> Sub.map Shared
]
-- REQUESTS
request : { model | url : Url, key : Key } -> Request ()
request model =
Request.create () model.url model.key

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -1,15 +1,15 @@
module Pages.Home_ exposing (Model, Msg, page)
import Gen.Params.Home_ exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import UI
import UI.Layout
import View exposing (View)
page : Shared.Model -> Request Params -> Page Model Msg
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page =
UI.Layout.page
{ view = view
@ -33,19 +33,16 @@ view =
, description = "single page apps made easy"
}
, UI.markdown { withHeaderLinks = False } """
## Build reliable applications.
## A work in progress!
I need to verify that the line height for paragraphs is reasonable, because if it isn't then I'll need to tweak it a bit until it's actually readable.
Only the most readable lines should be included in the __official__ [guide](/guide), ya dig?
This next release of elm-spa isn't _quite_ ready yet. It's currently available in `beta`, and can be installed via __npm__:
Bippity boppity, my guy.
```
npm install -g elm-spa@beta
```
---
---
## Effortless routing.
Use `elm-spa` to automatically wire up routes and pages.
For now, feel free to [read the docs](/docs), see the [incomplete guides](/guides), or check the bulleted list of [example projects](/examples)
that aren't available yet.
"""
]
}

View File

@ -1,15 +1,15 @@
module Pages.NotFound exposing (Model, Msg, page)
import Gen.Params.NotFound exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import UI
import UI.Layout
import View exposing (View)
page : Shared.Model -> Request Params -> Page Model Msg
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page =
UI.Layout.page
{ view = view

View File

@ -36,7 +36,7 @@ type Msg
-- INIT
init : Request () -> Flags -> ( Model, Cmd Msg )
init : Request -> Flags -> ( Model, Cmd Msg )
init _ flags =
( Model
(flags
@ -51,7 +51,7 @@ init _ flags =
-- UPDATE
update : Request () -> Msg -> Model -> ( Model, Cmd Msg )
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update request msg model =
case msg of
NoOp ->
@ -62,6 +62,6 @@ update request msg model =
-- SUBSCRIPTIONS
subscriptions : Request () -> Model -> Sub Msg
subscriptions : Request -> Model -> Sub Msg
subscriptions request model =
Sub.none

View File

@ -160,23 +160,35 @@ markdown options str =
, link = link_
, codeBlock =
\{ body, language } ->
if language == Just "elm" then
Html.Keyed.node "div"
[]
[ ( body
, Html.node "prism-js"
[ Attr.property "body" (Json.string body)
, Attr.property "language" (Json.string "elm")
]
[]
)
]
let
supported =
[ "html", "css", "js", "elm" ]
else
Html.pre [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.code [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.text body ]
]
simplePre =
Html.pre [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.code [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
[ Html.text body ]
]
in
case language of
Just lang ->
if List.member lang supported then
Html.Keyed.node "div"
[]
[ ( body
, Html.node "prism-js"
[ Attr.property "body" (Json.string body)
, Attr.property "language" (Json.string lang)
]
[]
)
]
else
simplePre
Nothing ->
simplePre
}
in
Markdown.Parser.parse str

View File

@ -1,8 +1,8 @@
module UI.Docs exposing (Model, Msg, page)
import Http
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import UI
import UI.Layout
@ -10,7 +10,7 @@ import Url exposing (Url)
import View exposing (View)
page : Shared.Model -> Request params -> Page Model Msg
page : Shared.Model -> Request.With params -> Page.With Model Msg
page shared req =
Page.element
{ init = init req.url
@ -106,7 +106,7 @@ view shared url model =
{ title =
case model.markdown of
Loading ->
"elm-spa"
""
Success content ->
let

View File

@ -13,6 +13,7 @@ module UI.Layout exposing
-}
import Gen.Route as Route exposing (Route)
import Html exposing (Html)
import Html.Attributes as Attr
import Page exposing (Page)
@ -72,9 +73,9 @@ viewDocumentation :
-> List (Html msg)
viewDocumentation options markdownContent view =
[ navbar options
, Html.div [ Attr.class "container pad-lg" ]
[ UI.row.lg [ UI.align.top, UI.padY.lg ]
[ Html.aside [ Attr.class "only-desktop sticky pad-y-lg", Attr.style "width" "13em" ]
, Html.div [ Attr.class "container pad-md" ]
[ UI.row.xl [ UI.align.top, UI.padY.lg ]
[ Html.aside [ Attr.class "only-desktop sticky pad-y-lg aside" ]
[ UI.Sidebar.viewSidebar
{ index = options.shared.index
, url = options.url
@ -83,7 +84,7 @@ viewDocumentation options markdownContent view =
, Html.main_ [ Attr.class "flex" ]
[ UI.row.lg [ UI.align.top ]
[ Html.div [ Attr.class "col flex" ] view
, Html.div [ Attr.class "hidden-mobile sticky pad-y-lg", Attr.style "width" "16em" ]
, Html.div [ Attr.class "hidden-mobile sticky pad-y-lg table-of-contents" ]
[ UI.Sidebar.viewTableOfContents
{ content = markdownContent
, url = options.url
@ -105,23 +106,23 @@ navbar :
-> Html msg
navbar { onMsg, model, shared, url } =
let
navLink : { text : String, url : String } -> Html msg
navLink : { text : String, route : Route } -> Html msg
navLink options =
Html.a
[ Attr.class "link"
, Attr.href options.url
, Attr.classList [ ( "bold text-blue", String.startsWith options.url url.path ) ]
, Attr.href (Route.toHref options.route)
, Attr.classList [ ( "bold text-blue", String.startsWith (Route.toHref options.route) url.path ) ]
]
[ Html.text options.text ]
in
Html.header [ Attr.class "container pad-md" ]
Html.header [ Attr.class "header container pad-y-lg pad-x-md" ]
[ Html.div [ Attr.class "row gap-md spread" ]
[ Html.div [ Attr.class "row align-center gap-md" ]
[ Html.a [ Attr.href "/" ] [ UI.logo ]
[ Html.div [ Attr.class "row align-center gap-lg" ]
[ Html.a [ Attr.class "header__logo", Attr.href "/" ] [ UI.logo ]
, Html.nav [ Attr.class "row gap-md hidden-mobile pad-left-xs" ]
[ navLink { text = "guide", url = "/guide" }
, navLink { text = "docs", url = "/docs" }
, navLink { text = "examples", url = "/examples" }
[ navLink { text = "docs", route = Route.Docs }
, navLink { text = "guides ", route = Route.Guides }
, navLink { text = "examples", route = Route.Examples }
]
]
, Html.div [ Attr.class "row gap-md spread" ]
@ -144,7 +145,7 @@ navbar { onMsg, model, shared, url } =
-- PAGE
page : { view : View Msg } -> Shared.Model -> Request params -> Page Model Msg
page : { view : View Msg } -> Shared.Model -> Request.With params -> Page.With Model Msg
page options shared req =
Page.sandbox
{ init = init

View File

@ -122,8 +122,13 @@ viewSidebar { url, index } =
viewSidebarSection : Section -> Html msg
viewSidebarSection section =
UI.col.sm []
[ Html.a [ Attr.href section.url, Attr.class "h4 bold" ] [ Html.text section.header ]
UI.col.sm [ UI.align.left ]
[ Html.a
[ Attr.href section.url
, Attr.classList [ ( "bold text-blue", url.path == section.url ) ]
, Attr.class "h4 bold underline"
]
[ Html.text section.header ]
, if List.isEmpty section.pages then
Html.text ""

View File

@ -3,7 +3,7 @@
"name": "ryannhg/elm-spa",
"summary": "Single page apps made easy.",
"license": "BSD-3-Clause",
"version": "5.2.0",
"version": "5.3.0",
"exposed-modules": [
"ElmSpa.Request",
"ElmSpa.Page",

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

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

View File

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

View File

@ -3,8 +3,7 @@
"source-directories": [
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
".elm-spa/generated"
],
"elm-version": "0.19.1",
"dependencies": {
@ -13,7 +12,8 @@
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
"elm/url": "1.0.0",
"ryannhg/elm-spa": "5.3.0"
},
"indirect": {
"elm/time": "1.0.0",

View File

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

View File

@ -11,11 +11,14 @@
"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": "5.2.0"
"ryannhg/elm-spa": "5.3.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
}

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<script src="/dist/elm.js"></script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0",
"ryannhg/elm-spa": "5.2.0"
"ryannhg/elm-spa": "5.3.0"
},
"indirect": {
"elm/time": "1.0.0",

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

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

View File

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

27
examples/04-demo/elm.json Normal file
View File

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

View File

@ -0,0 +1,12 @@
<!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="/style.css">
</head>
<body>
<script src="/dist/elm.js"></script>
<script> Elm.Main.init() </script>
</body>
</html>

View File

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

View File

@ -0,0 +1,34 @@
module Auth exposing
( User
, beforeProtectedInit
)
{-|
@docs User
@docs beforeProtectedInit
-}
import ElmSpa.Internals.Page as ElmSpa
import Gen.Route exposing (Route)
import Request exposing (Request)
import Shared
type alias User =
Shared.User
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
let
_ =
Debug.log "user" shared.user
in
case shared.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn

View File

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

View File

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

View File

@ -0,0 +1,66 @@
module Shared exposing
( Flags
, Model
, Msg(..)
, User
, init
, subscriptions
, update
)
import Gen.Route
import Json.Decode as Json
import Request exposing (Request)
type alias User =
{ name : String
}
-- INIT
type alias Flags =
Json.Value
type alias Model =
{ user : Maybe User
}
init : Request -> Flags -> ( Model, Cmd Msg )
init _ _ =
( { user = Nothing }
, Cmd.none
)
-- UPDATE
type Msg
= SignedIn String
| SignedOut
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update req msg model =
case msg of
SignedIn name ->
( { model | user = Just { name = name } }
, Request.pushRoute Gen.Route.Home_ req
)
SignedOut ->
( { model | user = Nothing }
, Cmd.none
)
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Sub.none

View File

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

View File

@ -1,8 +1,8 @@
module ElmSpa.Internals.Page exposing
( Page, static, sandbox, element, advanced
, Protected(..), protected2
, Protected(..), protected3
, Bundle, bundle
, protected
, protected, protected2
)
{-|
@ -15,7 +15,7 @@ module ElmSpa.Internals.Page exposing
# **User Authentication**
@docs Protected, protected2
@docs Protected, protected3
# For generated code
@ -27,7 +27,7 @@ module ElmSpa.Internals.Page exposing
This will be removed before release, included to prevent bumping to 6.0.0 during beta!
@docs protected
@docs protected, protected2
-}
@ -71,15 +71,7 @@ static :
}
-> Page shared route effect view () msg
static none page =
Page
(\_ _ ->
Ok
{ init = \_ -> ( (), none )
, update = \_ _ -> ( (), none )
, view = \_ -> page.view
, subscriptions = \_ -> Sub.none
}
)
Page (\_ _ -> Ok (adapters.static none page))
{-| A page that can keep track of application state.
@ -110,15 +102,7 @@ sandbox :
}
-> Page shared route effect view model msg
sandbox none page =
Page
(\_ _ ->
Ok
{ init = \_ -> ( page.init, none )
, update = \msg model -> ( page.update msg model, none )
, view = page.view
, subscriptions = \_ -> Sub.none
}
)
Page (\_ _ -> Ok (adapters.sandbox none page))
{-| A page that can handle effects like [HTTP requests or subscriptions](https://guide.elm-lang.org/effects/).
@ -152,15 +136,7 @@ element :
}
-> Page shared route effect view model msg
element fromCmd page =
Page
(\_ _ ->
Ok
{ init = \_ -> page.init |> Tuple.mapSecond fromCmd
, update = \msg model -> page.update msg model |> Tuple.mapSecond fromCmd
, view = page.view
, subscriptions = page.subscriptions
}
)
Page (\_ _ -> Ok (adapters.element fromCmd page))
{-| A page that can handles **custom** effects like sending a `Shared.Msg` or other general user-defined effects.
@ -191,101 +167,7 @@ advanced :
}
-> Page shared route effect view model msg
advanced page =
Page
(\_ _ ->
Ok
{ init = always page.init
, update = page.update
, view = page.view
, subscriptions = page.subscriptions
}
)
{-| Deprecated! Will be replaced by [protected2](#protected2)
-}
protected :
{ effectNone : effect
, fromCmd : Cmd msg -> effect
, user : shared -> Request route () -> Maybe user
, route : route
}
->
{ static :
{ view : user -> view
}
-> Page shared route effect view () msg
, sandbox :
{ init : user -> model
, update : user -> msg -> model -> model
, view : user -> model -> view
}
-> Page shared route effect view model msg
, element :
{ init : user -> ( model, Cmd msg )
, update : user -> msg -> model -> ( model, Cmd msg )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
, advanced :
{ init : user -> ( model, effect )
, update : user -> msg -> model -> ( model, effect )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
}
protected options =
let
protect pageWithUser page =
Page
(\shared req ->
case options.user shared req of
Just user ->
Ok (pageWithUser user page)
Nothing ->
Err options.route
)
in
{ static =
protect
(\user page ->
{ init = \_ -> ( (), options.effectNone )
, update = \_ model -> ( model, options.effectNone )
, view = \_ -> page.view user
, subscriptions = \_ -> Sub.none
}
)
, sandbox =
protect
(\user page ->
{ init = \_ -> ( page.init user, options.effectNone )
, update = \msg model -> ( page.update user msg model, options.effectNone )
, view = page.view user
, subscriptions = \_ -> Sub.none
}
)
, element =
protect
(\user page ->
{ init = \_ -> page.init user |> Tuple.mapSecond options.fromCmd
, update = \msg model -> page.update user msg model |> Tuple.mapSecond options.fromCmd
, view = page.view user
, subscriptions = page.subscriptions user
}
)
, advanced =
protect
(\user page ->
{ init = \_ -> page.init user
, update = page.update user
, view = page.view user
, subscriptions = page.subscriptions user
}
)
}
Page (\_ _ -> Ok (adapters.advanced page))
{-| Actions to take when a user visits a `protected` page
@ -311,101 +193,88 @@ type Protected user route
Prefixing any of the four functions above with `protected` will guarantee that the page has access to a user. Here's an example with `sandbox`:
import Page
-- before
Page.sandbox
{ init = init
, update = update
, view = view
}
page : Page Model Msg
page =
Page.protected.sandbox
-- after
Page.protected.sandbox
(\user ->
{ init = init
, update = update
, view = view
}
)
-- init : User -> Model
-- update : User -> Msg -> Model -> Model
-- update : User -> Model -> View Msg
-- other functions have same API
init : Model
update : Msg -> Model -> Model
view : Model -> View Msg
-}
protected2 :
protected3 :
{ effectNone : effect
, fromCmd : Cmd msg -> effect
, beforeInit : shared -> Request route () -> Protected user route
}
->
{ static :
{ view : user -> view
}
(user
->
{ view : view
}
)
-> Page shared route effect view () msg
, sandbox :
{ init : user -> model
, update : user -> msg -> model -> model
, view : user -> model -> view
}
(user
->
{ init : model
, update : msg -> model -> model
, view : model -> view
}
)
-> Page shared route effect view model msg
, element :
{ init : user -> ( model, Cmd msg )
, update : user -> msg -> model -> ( model, Cmd msg )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
(user
->
{ init : ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> view
, subscriptions : model -> Sub msg
}
)
-> Page shared route effect view model msg
, advanced :
{ init : user -> ( model, effect )
, update : user -> msg -> model -> ( model, effect )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
(user
->
{ init : ( model, effect )
, update : msg -> model -> ( model, effect )
, view : model -> view
, subscriptions : model -> Sub msg
}
)
-> Page shared route effect view model msg
}
protected2 options =
protected3 options =
let
protect pageWithUser page =
protect toPage toRecord =
Page
(\shared req ->
case options.beforeInit shared req of
Provide user ->
Ok (pageWithUser user page)
Ok (user |> toRecord |> toPage)
RedirectTo route ->
Err route
)
in
{ static =
protect
(\user page ->
{ init = \_ -> ( (), options.effectNone )
, update = \_ model -> ( model, options.effectNone )
, view = \_ -> page.view user
, subscriptions = \_ -> Sub.none
}
)
, sandbox =
protect
(\user page ->
{ init = \_ -> ( page.init user, options.effectNone )
, update = \msg model -> ( page.update user msg model, options.effectNone )
, view = page.view user
, subscriptions = \_ -> Sub.none
}
)
, element =
protect
(\user page ->
{ init = \_ -> page.init user |> Tuple.mapSecond options.fromCmd
, update = \msg model -> page.update user msg model |> Tuple.mapSecond options.fromCmd
, view = page.view user
, subscriptions = page.subscriptions user
}
)
, advanced =
protect
(\user page ->
{ init = \_ -> page.init user
, update = page.update user
, view = page.view user
, subscriptions = page.subscriptions user
}
)
{ static = protect (adapters.static options.effectNone)
, sandbox = protect (adapters.sandbox options.effectNone)
, element = protect (adapters.element options.fromCmd)
, advanced = protect adapters.advanced
}
@ -527,3 +396,242 @@ type alias PageRecord effect view model msg =
, view : model -> view
, subscriptions : model -> Sub msg
}
adapters :
{ static :
effect
->
{ view : view
}
-> PageRecord effect view () msg
, sandbox :
effect
->
{ init : model
, update : msg -> model -> model
, view : model -> view
}
-> PageRecord effect view model msg
, element :
(Cmd msg -> effect)
->
{ init : ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> view
, subscriptions : model -> Sub msg
}
-> PageRecord effect view model msg
, advanced :
{ init : ( model, effect )
, update : msg -> model -> ( model, effect )
, view : model -> view
, subscriptions : model -> Sub msg
}
-> PageRecord effect view model msg
}
adapters =
{ static =
\none page ->
{ init = \_ -> ( (), none )
, update = \_ _ -> ( (), none )
, view = \_ -> page.view
, subscriptions = \_ -> Sub.none
}
, sandbox =
\none page ->
{ init = \_ -> ( page.init, none )
, update = \msg model -> ( page.update msg model, none )
, view = page.view
, subscriptions = \_ -> Sub.none
}
, element =
\fromCmd page ->
{ init = \_ -> page.init |> Tuple.mapSecond fromCmd
, update = \msg model -> page.update msg model |> Tuple.mapSecond fromCmd
, view = page.view
, subscriptions = page.subscriptions
}
, advanced =
\page ->
{ init = always page.init
, update = page.update
, view = page.view
, subscriptions = page.subscriptions
}
}
-- DEPRECATED - will be removed in v6
{-| Deprecated! Will be replaced by [protected3](#protected3)
-}
protected :
{ effectNone : effect
, fromCmd : Cmd msg -> effect
, user : shared -> Request route () -> Maybe user
, route : route
}
->
{ static :
{ view : user -> view
}
-> Page shared route effect view () msg
, sandbox :
{ init : user -> model
, update : user -> msg -> model -> model
, view : user -> model -> view
}
-> Page shared route effect view model msg
, element :
{ init : user -> ( model, Cmd msg )
, update : user -> msg -> model -> ( model, Cmd msg )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
, advanced :
{ init : user -> ( model, effect )
, update : user -> msg -> model -> ( model, effect )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
}
protected options =
let
protect pageWithUser page =
Page
(\shared req ->
case options.user shared req of
Just user ->
Ok (pageWithUser user page)
Nothing ->
Err options.route
)
in
{ static =
protect
(\user page ->
{ init = \_ -> ( (), options.effectNone )
, update = \_ model -> ( model, options.effectNone )
, view = \_ -> page.view user
, subscriptions = \_ -> Sub.none
}
)
, sandbox =
protect
(\user page ->
{ init = \_ -> ( page.init user, options.effectNone )
, update = \msg model -> ( page.update user msg model, options.effectNone )
, view = page.view user
, subscriptions = \_ -> Sub.none
}
)
, element =
protect
(\user page ->
{ init = \_ -> page.init user |> Tuple.mapSecond options.fromCmd
, update = \msg model -> page.update user msg model |> Tuple.mapSecond options.fromCmd
, view = page.view user
, subscriptions = page.subscriptions user
}
)
, advanced =
protect
(\user page ->
{ init = \_ -> page.init user
, update = page.update user
, view = page.view user
, subscriptions = page.subscriptions user
}
)
}
{-| Deprecated! Will be replaced by [protected3](#protected3)
-}
protected2 :
{ effectNone : effect
, fromCmd : Cmd msg -> effect
, beforeInit : shared -> Request route () -> Protected user route
}
->
{ static :
{ view : user -> view
}
-> Page shared route effect view () msg
, sandbox :
{ init : user -> model
, update : user -> msg -> model -> model
, view : user -> model -> view
}
-> Page shared route effect view model msg
, element :
{ init : user -> ( model, Cmd msg )
, update : user -> msg -> model -> ( model, Cmd msg )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
, advanced :
{ init : user -> ( model, effect )
, update : user -> msg -> model -> ( model, effect )
, view : user -> model -> view
, subscriptions : user -> model -> Sub msg
}
-> Page shared route effect view model msg
}
protected2 options =
let
protect pageWithUser page =
Page
(\shared req ->
case options.beforeInit shared req of
Provide user ->
Ok (pageWithUser user page)
RedirectTo route ->
Err route
)
in
{ static =
protect
(\user page ->
{ init = \_ -> ( (), options.effectNone )
, update = \_ model -> ( model, options.effectNone )
, view = \_ -> page.view user
, subscriptions = \_ -> Sub.none
}
)
, sandbox =
protect
(\user page ->
{ init = \_ -> ( page.init user, options.effectNone )
, update = \msg model -> ( page.update user msg model, options.effectNone )
, view = page.view user
, subscriptions = \_ -> Sub.none
}
)
, element =
protect
(\user page ->
{ init = \_ -> page.init user |> Tuple.mapSecond options.fromCmd
, update = \msg model -> page.update user msg model |> Tuple.mapSecond options.fromCmd
, view = page.view user
, subscriptions = page.subscriptions user
}
)
, advanced =
protect
(\user page ->
{ init = \_ -> page.init user
, update = page.update user
, view = page.view user
, subscriptions = page.subscriptions user
}
)
}

View File

@ -9,7 +9,7 @@ module ElmSpa.Page exposing
# **( These docs are for CLI contributors )**
### If you are using **elm-spa**, check out [the official guide](https://v6.elm-spa.dev/guide) instead!
### If you are using **elm-spa**, check out [the official guide](https://elm-spa.dev/guide) instead!
---

View File

@ -6,7 +6,7 @@ module ElmSpa.Request exposing (Request, create)
# **( These docs are for CLI contributors )**
### If you are using **elm-spa**, check out [the official guide](https://v6.elm-spa.dev/guide) instead!
### If you are using **elm-spa**, check out [the official guide](https://elm-spa.dev/guide) instead!
---

View File

@ -1,17 +1,17 @@
{
"name": "elm-spa",
"version": "6.0.7--beta",
"version": "6.0.15--beta",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "elm-spa",
"version": "6.0.7--beta",
"version": "6.0.15--beta",
"license": "BSD-3-Clause",
"dependencies": {
"chokidar": "3.4.2",
"elm": "0.19.1-3",
"mime": "2.4.6",
"node-elm-compiler": "5.0.5",
"terser": "5.3.8",
"websocket": "1.0.32"
},
@ -1501,8 +1501,7 @@
"node_modules/balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"node_modules/base": {
"version": "0.11.2",
@ -1592,7 +1591,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -1857,8 +1855,7 @@
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"node_modules/convert-source-map": {
"version": "1.7.0",
@ -1887,7 +1884,6 @@
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"dev": true,
"dependencies": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
@ -2116,9 +2112,10 @@
}
},
"node_modules/elm": {
"version": "0.19.1-3",
"resolved": "https://registry.npmjs.org/elm/-/elm-0.19.1-3.tgz",
"integrity": "sha512-6y36ewCcVmTOx8lj7cKJs3bhI5qMfoVEigePZ9PhEUNKpwjjML/pU2u2YSpHVAznuCcojoF6KIsrS1Ci7GtVaQ==",
"version": "0.19.1-5",
"resolved": "https://registry.npmjs.org/elm/-/elm-0.19.1-5.tgz",
"integrity": "sha512-dyBoPvFiNLvxOStQJdyq28gZEjS/enZXdZ5yyCtNtDEMbFJJVQq4pYNRKvhrKKdlxNot6d96iQe1uczoqO5yvA==",
"hasInstallScript": true,
"dependencies": {
"request": "^2.88.0"
},
@ -2573,6 +2570,21 @@
"node": ">=8"
}
},
"node_modules/find-elm-dependencies": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/find-elm-dependencies/-/find-elm-dependencies-2.0.4.tgz",
"integrity": "sha512-x/4w4fVmlD2X4PD9oQ+yh9EyaQef6OtEULdMGBTuWx0Nkppvo2Z/bAiQioW2n+GdRYKypME2b9OmYTw5tw5qDg==",
"dependencies": {
"firstline": "^1.2.0",
"lodash": "^4.17.19"
},
"bin": {
"find-elm-dependencies": "bin/cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -2586,6 +2598,14 @@
"node": ">=8"
}
},
"node_modules/firstline": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/firstline/-/firstline-1.3.1.tgz",
"integrity": "sha512-ycwgqtoxujz1dm0kjkBFOPQMESxB9uKc/PlD951dQDIG+tBXRpYZC2UmJb0gDxopQ1ZX6oyRQN3goRczYu7Deg==",
"engines": {
"node": ">=6.4.0"
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -2631,8 +2651,7 @@
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"node_modules/fsevents": {
"version": "2.1.3",
@ -2706,7 +2725,6 @@
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -2932,7 +2950,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
@ -2941,8 +2958,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-regex": {
"version": "2.1.0",
@ -3189,8 +3205,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
"dev": true
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"node_modules/isobject": {
"version": "3.0.1",
@ -5082,8 +5097,7 @@
"node_modules/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"dev": true
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
@ -5216,7 +5230,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -5227,8 +5240,7 @@
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
},
"node_modules/mixin-deep": {
"version": "1.3.2",
@ -5309,8 +5321,21 @@
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"node_modules/node-elm-compiler": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/node-elm-compiler/-/node-elm-compiler-5.0.5.tgz",
"integrity": "sha512-vapB+VkmKMY1NRy7jjpGjzwWbKmtiRfzbgVoV/eROz5Kx30QvY0Nd5Ua7iST+9utrn1aG8cVToXC6UWdEO5BKQ==",
"dependencies": {
"cross-spawn": "6.0.5",
"find-elm-dependencies": "^2.0.4",
"lodash": "^4.17.19",
"temp": "^0.9.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.2.3",
@ -5493,7 +5518,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"dependencies": {
"wrappy": "1"
}
@ -5621,7 +5645,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -5630,7 +5653,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
"dev": true,
"engines": {
"node": ">=4"
}
@ -6218,7 +6240,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
@ -6260,7 +6281,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
"dev": true,
"dependencies": {
"shebang-regex": "^1.0.0"
},
@ -6272,7 +6292,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -6722,6 +6741,40 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/temp/node_modules/mkdirp": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
"integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
"dependencies": {
"minimist": "^1.2.5"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/temp/node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@ -7244,7 +7297,6 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@ -7284,8 +7336,7 @@
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"node_modules/write-file-atomic": {
"version": "3.0.3",
@ -8678,8 +8729,7 @@
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"base": {
"version": "0.11.2",
@ -8753,7 +8803,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -8972,8 +9021,7 @@
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"convert-source-map": {
"version": "1.7.0",
@ -8999,7 +9047,6 @@
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"dev": true,
"requires": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
@ -9183,9 +9230,8 @@
}
},
"elm": {
"version": "0.19.1-3",
"resolved": "https://registry.npmjs.org/elm/-/elm-0.19.1-3.tgz",
"integrity": "sha512-6y36ewCcVmTOx8lj7cKJs3bhI5qMfoVEigePZ9PhEUNKpwjjML/pU2u2YSpHVAznuCcojoF6KIsrS1Ci7GtVaQ==",
"version": "https://registry.npmjs.org/elm/-/elm-0.19.1-5.tgz",
"integrity": "sha512-dyBoPvFiNLvxOStQJdyq28gZEjS/enZXdZ5yyCtNtDEMbFJJVQq4pYNRKvhrKKdlxNot6d96iQe1uczoqO5yvA==",
"requires": {
"request": "^2.88.0"
}
@ -9561,6 +9607,15 @@
"to-regex-range": "^5.0.1"
}
},
"find-elm-dependencies": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/find-elm-dependencies/-/find-elm-dependencies-2.0.4.tgz",
"integrity": "sha512-x/4w4fVmlD2X4PD9oQ+yh9EyaQef6OtEULdMGBTuWx0Nkppvo2Z/bAiQioW2n+GdRYKypME2b9OmYTw5tw5qDg==",
"requires": {
"firstline": "^1.2.0",
"lodash": "^4.17.19"
}
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -9571,6 +9626,11 @@
"path-exists": "^4.0.0"
}
},
"firstline": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/firstline/-/firstline-1.3.1.tgz",
"integrity": "sha512-ycwgqtoxujz1dm0kjkBFOPQMESxB9uKc/PlD951dQDIG+tBXRpYZC2UmJb0gDxopQ1ZX6oyRQN3goRczYu7Deg=="
},
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -9604,8 +9664,7 @@
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.3",
@ -9658,7 +9717,6 @@
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -9833,7 +9891,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
@ -9842,8 +9899,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ip-regex": {
"version": "2.1.0",
@ -10033,8 +10089,7 @@
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
"dev": true
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"isobject": {
"version": "3.0.1",
@ -11606,8 +11661,7 @@
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"dev": true
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"lodash.memoize": {
"version": "4.1.2",
@ -11712,7 +11766,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -11720,8 +11773,7 @@
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
},
"mixin-deep": {
"version": "1.3.2",
@ -11789,8 +11841,18 @@
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"node-elm-compiler": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/node-elm-compiler/-/node-elm-compiler-5.0.5.tgz",
"integrity": "sha512-vapB+VkmKMY1NRy7jjpGjzwWbKmtiRfzbgVoV/eROz5Kx30QvY0Nd5Ua7iST+9utrn1aG8cVToXC6UWdEO5BKQ==",
"requires": {
"cross-spawn": "6.0.5",
"find-elm-dependencies": "^2.0.4",
"lodash": "^4.17.19",
"temp": "^0.9.0"
}
},
"node-gyp-build": {
"version": "4.2.3",
@ -11933,7 +11995,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"requires": {
"wrappy": "1"
}
@ -12030,14 +12091,12 @@
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-key": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
"dev": true
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
},
"path-parse": {
"version": "1.0.6",
@ -12515,8 +12574,7 @@
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
},
"set-blocking": {
"version": "2.0.0",
@ -12551,7 +12609,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
"dev": true,
"requires": {
"shebang-regex": "^1.0.0"
}
@ -12559,8 +12616,7 @@
"shebang-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
"dev": true
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM="
},
"shellwords": {
"version": "0.1.1",
@ -12928,6 +12984,33 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"requires": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"dependencies": {
"mkdirp": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
"integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
"requires": {
"minimist": "^1.2.5"
}
},
"rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"requires": {
"glob": "^7.1.3"
}
}
}
},
"terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@ -13352,7 +13435,6 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dev": true,
"requires": {
"isexe": "^2.0.0"
}
@ -13383,8 +13465,7 @@
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"write-file-atomic": {
"version": "3.0.3",

View File

@ -1,6 +1,6 @@
{
"name": "elm-spa",
"version": "6.0.7--beta",
"version": "6.0.15--beta",
"description": "single page apps made easy",
"bin": "dist/src/index.js",
"scripts": {
@ -40,8 +40,8 @@
},
"dependencies": {
"chokidar": "3.4.2",
"elm": "0.19.1-3",
"mime": "2.4.6",
"node-elm-compiler": "5.0.5",
"terser": "5.3.8",
"websocket": "1.0.32"
}

View File

@ -34,7 +34,7 @@ export default {
await File.create(outputFilepath, contents)
return ` ${bold('New page created at:')}\n ${outputFilepath}`
return ` ${bold('New page created at:')}\n ${outputFilepath}\n`
}
}

View File

@ -2,15 +2,20 @@ import path from "path"
import { exists } from "../file"
import config from '../config'
import * as File from '../file'
import { createInterface } from 'readline'
import RouteTemplate from '../templates/routes'
import PagesTemplate from '../templates/pages'
import PageTemplate from '../templates/page'
import RequestTemplate from '../templates/request'
import ModelTemplate from '../templates/model'
import MsgTemplate from '../templates/msg'
import ChildProcess from 'child_process'
import ParamsTemplate from '../templates/params'
import * as Process from '../process'
import { bold, underline, colors, reset, check, dim, dot } from "../terminal"
import terser from 'terser'
import { bold, underline, colors, reset, check, dim, dot, warn, error } from "../terminal"
import { isStandardPage, isStaticPage, isStaticView, options, PageKind } from "../templates/utils"
import { createMissingAddTemplates } from "./_common"
const elm = require('node-elm-compiler')
export const build = ({ env, runElmMake } : { env : Environment, runElmMake: boolean }) => () =>
Promise.all([
@ -22,39 +27,39 @@ export const build = ({ env, runElmMake } : { env : Environment, runElmMake: boo
const createMissingDefaultFiles = async () => {
type Action
= [ 'DELETE_FROM_DEFAULTS', string[] ]
| [ 'CREATE_IN_DEFAULTS', string[] ]
| [ 'DO_NOTHING', string[] ]
= ['DELETE_FROM_DEFAULTS', string[]]
| ['CREATE_IN_DEFAULTS', string[]]
| ['DO_NOTHING', string[]]
const toAction = async (filepath : string[]) : Promise<Action> => {
const [ inDefaults, inSrc ] = await Promise.all([
const toAction = async (filepath: string[]): Promise<Action> => {
const [inDefaults, inSrc] = await Promise.all([
exists(path.join(config.folders.defaults.dest, ...filepath)),
exists(path.join(config.folders.src, ...filepath))
])
if (inSrc && inDefaults) {
return [ 'DELETE_FROM_DEFAULTS', filepath ]
return ['DELETE_FROM_DEFAULTS', filepath]
} else if (!inSrc) {
return [ 'CREATE_IN_DEFAULTS', filepath ]
return ['CREATE_IN_DEFAULTS', filepath]
} else {
return [ 'DO_NOTHING', filepath ]
return ['DO_NOTHING', filepath]
}
}
const actions = await Promise.all(config.defaults.map(toAction))
const performDefaultFileAction = ([ action, relative ] : Action) : Promise<any> =>
const performDefaultFileAction = ([action, relative]: Action): Promise<any> =>
action === 'CREATE_IN_DEFAULTS' ? createDefaultFile(relative)
: action === 'DELETE_FROM_DEFAULTS' ? deleteFromDefaults(relative)
: Promise.resolve()
: action === 'DELETE_FROM_DEFAULTS' ? deleteFromDefaults(relative)
: Promise.resolve()
const createDefaultFile = async (relative : string[]) =>
const createDefaultFile = async (relative: string[]) =>
File.copyFile(
path.join(config.folders.defaults.src, ...relative),
path.join(config.folders.defaults.dest, ...relative)
)
const deleteFromDefaults = async (relative : string[]) =>
const deleteFromDefaults = async (relative: string[]) =>
File.remove(path.join(config.folders.defaults.dest, ...relative))
return Promise.all(actions.map(performDefaultFileAction))
@ -67,22 +72,22 @@ type FilepathSegments = {
entry: PageEntry
}
const getFilepathSegments = async (entries: PageEntry[]) : Promise<FilepathSegments[]> => {
const getFilepathSegments = async (entries: PageEntry[]): Promise<FilepathSegments[]> => {
const contents = await Promise.all(entries.map(e => File.read(e.filepath)))
return Promise.all(entries.map(async (entry, i) => {
const c = contents[i]
const kind : PageKind = await (
const kind: PageKind = await (
isStandardPage(c) ? Promise.resolve('page')
: isStaticPage(c) ? Promise.resolve('static-page')
: isStaticView(c) ? Promise.resolve('view')
: Promise.reject(invalidExportsMessage(entry))
: isStaticPage(c) ? Promise.resolve('static-page')
: isStaticView(c) ? Promise.resolve('view')
: Promise.reject(invalidExportsMessage(entry))
)
return { kind, entry }
}))
}
const invalidExportsMessage = (entry : PageEntry) => {
const invalidExportsMessage = (entry: PageEntry) => {
const moduleName = `${bold}Pages.${entry.segments.join('.')}${reset}`
const cyan = (str: string) => `${colors.cyan}${str}${reset}`
@ -104,22 +109,25 @@ const createGeneratedFiles = async () => {
const segments = entries.map(e => e.segments)
const filepathSegments = await getFilepathSegments(entries)
const kindForPage = (p : string[]) : PageKind =>
const kindForPage = (p: string[]): PageKind =>
filepathSegments
.filter(item => item.entry.segments.join('.') == p.join('.'))
.map(fps => fps.kind)[0] || 'page'
const paramFiles = segments.map(filepath => ({
filepath: [ 'Gen', 'Params', ...filepath ],
filepath: ['Gen', 'Params', ...filepath],
contents: ParamsTemplate(filepath, options(kindForPage))
}))
const filesToCreate = [
...paramFiles,
{ filepath: [ 'Gen', 'Route' ], contents: RouteTemplate(segments, options(kindForPage)) },
{ filepath: [ 'Gen', 'Pages' ], contents: PagesTemplate(segments, options(kindForPage)) },
{ filepath: [ 'Gen', 'Model' ], contents: ModelTemplate(segments, options(kindForPage)) },
{ filepath: [ 'Gen', 'Msg' ], contents: MsgTemplate(segments, options(kindForPage)) }
{ filepath: ['Page'], contents: PageTemplate() },
{ filepath: ['Request'], contents: RequestTemplate() },
{ filepath: ['Gen', 'Route'], contents: RouteTemplate(segments, options(kindForPage)) },
{ filepath: ['Gen', 'Route'], contents: RouteTemplate(segments, options(kindForPage)) },
{ filepath: ['Gen', 'Pages'], contents: PagesTemplate(segments, options(kindForPage)) },
{ filepath: ['Gen', 'Model'], contents: ModelTemplate(segments, options(kindForPage)) },
{ filepath: ['Gen', 'Msg'], contents: MsgTemplate(segments, options(kindForPage)) }
]
return Promise.all(filesToCreate.map(({ filepath, contents }) =>
@ -132,120 +140,173 @@ type PageEntry = {
segments: string[];
}
const getAllPageEntries = async () : Promise<PageEntry[]> => {
const scanPageFilesIn = async (folder : string) => {
const getAllPageEntries = async (): Promise<PageEntry[]> => {
const scanPageFilesIn = async (folder: string) => {
const items = await File.scan(folder)
return items.map(s =>({
return items.map(s => ({
filepath: s,
segments: s.substring(folder.length + 1, s.length - '.elm'.length).split(path.sep)
}))
}
return Promise.all([
scanPageFilesIn(config.folders.pages.src),
scanPageFilesIn(config.folders.pages.defaults)
]).then(([ left, right ]) => left.concat(right))
]).then(([left, right]) => left.concat(right))
}
type Environment = 'production' | 'development'
const output = path.join(config.folders.dist, 'elm.js')
const outputFilepath = path.join(config.folders.dist, 'elm.js')
const compileMainElm = (env: Environment) => async () => {
await ensureElmIsInstalled(env)
const compileMainElm = (env : Environment) => async () => {
const start = Date.now()
const elmMake = async () => {
const flags = env === 'development' ? '--debug' : '--optimize'
const inDevelopment = env === 'development'
const inProduction = env === 'production'
const isSrcMainElmDefined = await File.exists(path.join(config.folders.src, 'Main.elm'))
const input = isSrcMainElmDefined
const isSrcMainElmDefined = await File.exists(path.join(config.folders.src, 'Main.elm'))
const inputFilepath = isSrcMainElmDefined
? path.join(config.folders.src, 'Main.elm')
: path.join(config.folders.defaults.dest, 'Main.elm')
if (await File.exists(config.folders.dist) === false) {
await File.mkdir(config.folders.dist)
}
return Process.run(`${config.binaries.elm} make ${input} --output=${output} --report=json ${flags}`)
.catch(colorElmError)
return elm.compileToString(inputFilepath, {
output: outputFilepath,
report: 'json',
debug: inDevelopment,
optimize: inProduction,
})
.catch((error: Error) => {
try { return colorElmError(JSON.parse(error.message.split('\n')[1])) }
catch {
const { RED, green } = colors
return Promise.reject([
`${RED}!${reset} elm-spa failed to understand an error`,
`Please report the output below to ${green}https://github.com/ryannhg/elm-spa/issues${reset}`,
`-----`,
error,
`-----`,
`${RED}!${reset} elm-spa failed to understand an error`,
`Please send the output above to ${green}https://github.com/ryannhg/elm-spa/issues${reset}`,
``
].join('\n\n'))
}
})
}
const red = colors.RED
const green = colors.green
type ElmError = {
path: string
problems: Problem[]
}
const colorElmError = (err : string) => {
let errors = []
type Problem = {
title: string
message: (Message | string)[]
}
try {
errors = JSON.parse(err).errors as Error[] || []
} catch (e) {
return Promise.reject([
`${red}Something went wrong with elm-spa.${reset}`,
`Please report this entire error to ${green}https://github.com/ryannhg/elm-spa/issues${reset}`,
`-----`,
err,
`-----`
].join('\n\n'))
}
type Message = {
bold: boolean
underline: boolean
color: keyof typeof colors
string: string
}
const strIf = (str : string) => (cond : boolean) : string => cond ? str : ''
const colorElmError = (output : { errors: ElmError[] }) => {
const { errors } = output
const strIf = (str: string) => (cond: boolean): string => cond ? str : ''
const boldIf = strIf(bold)
const underlineIf = strIf(underline)
type Error = {
path : string
problems: Problem[]
}
const repeat = (str: string, num: number, min = 3) => [...Array(num < 0 ? min : num)].map(_ => str).join('')
type Problem = {
title : string
message : (Message | string)[]
}
type Message = {
bold : boolean
underline : boolean
color : keyof typeof colors
string : string
}
const repeat = (str : string, num : number, min = 3) => [...Array(num < 0 ? min : num)].map(_ => str).join('')
const errorToString = (error : Error) : string => {
const problemToString = (problem : Problem) : string => {
const errorToString = (error: ElmError): string => {
const problemToString = (problem: Problem): string => {
const path = error.path.substr(process.cwd().length + 1)
return [
`${colors.cyan}-- ${problem.title} ${repeat('-', 63 - problem.title.length - path.length)} ${path}${reset}`,
problem.message.map(messageToString).join('')
].join('\n\n')
}
const messageToString = (line : Message | string) =>
const messageToString = (line: Message | string) =>
typeof line === 'string'
? line
: [ boldIf(line.bold), underlineIf(line.underline), colors[line.color] || '', line.string, reset ].join('')
: [boldIf(line.bold), underlineIf(line.underline), colors[line.color] || '', line.string, reset].join('')
return error.problems.map(problemToString).join('\n\n')
}
return errors.length
? Promise.reject(errors.map(errorToString).join('\n\n\n'))
: err
return Promise.reject(errors.map(err => errorToString(err)).join('\n\n\n'))
}
const success = () => `${check} Build successful! ${dim}(${Date.now() - start}ms)${reset}`
const minify = () =>
Process.run(`${config.binaries.terser} ${output} --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | ${config.binaries.terser} --mangle --output=${output}`)
const minify = (rawCode: string) =>
terser.minify(rawCode, { compress: { pure_funcs: `F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9`.split(','), pure_getters: true, keep_fargs: false, unsafe_comps: true, unsafe: true } })
.then(intermediate => terser.minify(intermediate.code || '', { mangle: true }))
.then(minified => File.create(outputFilepath, minified.code || ''))
return (env === 'development')
? elmMake()
.then(_ => success()).catch(error => error)
: elmMake().then(minify)
.then(_ => [ success() + '\n' ])
}
.then(rawJsCode => File.create(outputFilepath, rawJsCode))
.then(_ => success())
.catch(error => error)
: elmMake()
.then(minify)
.then(_ => [success() + '\n'])
}
const ensureElmIsInstalled = async (environment : Environment) => {
await new Promise((resolve, reject) => {
ChildProcess.exec('elm', (err) => {
if (err) {
if (environment === 'production') {
attemptToInstallViaNpm(resolve, reject)
} else {
offerToInstallForDeveloper(resolve, reject)
}
} else {
resolve(undefined)
}
})
})
}
const attemptToInstallViaNpm = (resolve: (value: unknown) => void, reject: (reason: unknown) => void) => {
process.stdout.write(`\n ${bold}Awesome!${reset} Installing Elm via NPM... `)
ChildProcess.exec(`npm install --global elm@latest-0.19.1`, (err) => {
if (err) {
console.info(error)
reject(` The automatic install didn't work...\n Please visit ${colors.green}https://guide.elm-lang.org/install/elm${reset} to install Elm.\n`)
} else {
console.info(check)
console.info(` Elm is now installed!`)
resolve(undefined)
}
})
}
const offerToInstallForDeveloper = (resolve: (value: unknown) => void, reject: (reason: unknown) => void) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
rl.question(`\n${warn} Elm hasn't been installed yet.\n\n May I ${colors.cyan}install${reset} it for you? ${dim}[y/n]${reset} `, answer => {
if (answer.toLowerCase() === 'n') {
reject(` ${bold}No changes made!${reset}\n Please visit ${colors.green}https://guide.elm-lang.org/install/elm${reset} to install Elm.`)
} else {
attemptToInstallViaNpm(resolve, reject)
}
})
}
export default {
build: build({ env: 'production', runElmMake: true }),
gen: build({ env: 'production', runElmMake: false })

View File

@ -22,5 +22,5 @@ Other commands:
${bold(`elm-spa ${cyan(`gen`)}`)} . . . . generates code without elm make
${bold(`elm-spa ${cyan(`watch`)}`)} . . . . runs elm-spa gen as you code
Visit ${green(`https://next.elm-spa.dev`)} for more!
Visit ${green(`https://elm-spa.dev`)} for more!
`

View File

@ -10,6 +10,6 @@ export default {
const dest = process.cwd()
File.copy(config.folders.init, dest)
try { fs.renameSync(path.join(dest, '_gitignore'), path.join(dest, '.gitignore')) } catch (_) {}
return ` ${bold}New project created in:${reset}\n ${process.cwd()}`
return ` ${bold}New project created in:${reset}\n ${process.cwd()}\n`
}
}

View File

@ -31,17 +31,12 @@ const config = {
public: path.join(cwd, 'public'),
dist: path.join(cwd, 'public', 'dist'),
},
binaries: {
elm: `npx elm`,
terser: `npx terser`
},
defaults: [
[ 'Auth.elm' ],
[ 'Effect.elm' ],
[ 'Main.elm' ],
[ 'Shared.elm' ],
[ `Pages`, `${reserved.notFound}.elm` ],
[ 'Page.elm' ],
[ 'Request.elm' ],
[ 'View.elm' ]
]
}

View File

@ -0,0 +1,39 @@
module Auth exposing
( User
, beforeProtectedInit
)
{-|
@docs User
@docs beforeProtectedInit
-}
import ElmSpa.Internals.Page as ElmSpa
import Gen.Route exposing (Route)
import Request exposing (Request)
import Shared
{-| Replace the "()" with your actual User type
-}
type alias User =
()
{-| This function will run before any `protected` pages.
Here, you can provide logic on where to redirect if a user is not signed in. Here's an example:
case shared.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn
-}
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
ElmSpa.RedirectTo Gen.Route.NotFound

View File

@ -3,6 +3,7 @@ module Main exposing (main)
import Browser
import Browser.Navigation as Nav exposing (Key)
import Effect
import Gen.Model
import Gen.Pages as Pages
import Gen.Route as Route
import Request
@ -93,10 +94,22 @@ update msg model =
let
( shared, sharedCmd ) =
Shared.update (Request.create () model.url model.key) sharedMsg model.shared
( page, effect ) =
Pages.init (Route.fromUrl model.url) shared model.url model.key
in
( { model | shared = shared }
, Cmd.map Shared sharedCmd
)
if page == Gen.Model.Redirecting_ then
( { model | shared = shared, page = page }
, Cmd.batch
[ Cmd.map Shared sharedCmd
, Effect.toCmd ( Shared, Page ) effect
]
)
else
( { model | shared = shared }
, Cmd.map Shared sharedCmd
)
Page pageMsg ->
let

View File

@ -1,128 +0,0 @@
module Page exposing
( Page
, static, sandbox, element, advanced
, protected
)
{-|
@docs Page
@docs static, sandbox, element, advanced
-}
import Effect exposing (Effect)
import ElmSpa.Internals.Page as ElmSpa
import Gen.Route exposing (Route)
import Request exposing (Request)
import Shared
import View exposing (View)
-- PROTECTED PAGES
{-| Replace "()" with your actual User type
-}
type alias User =
()
{-| This function will run before any `protected` pages.
Here, you can provide logic on where to redirect if a user is not signed in. Here's an example:
case shared.user of
Just user ->
ElmSpa.Provide user
Nothing ->
ElmSpa.RedirectTo Gen.Route.SignIn
-}
beforeProtectedInit : Shared.Model -> Request () -> ElmSpa.Protected User Route
beforeProtectedInit shared req =
ElmSpa.RedirectTo Gen.Route.NotFound
-- PAGES
type alias Page model msg =
ElmSpa.Page Shared.Model Route (Effect msg) (View msg) model msg
static :
{ view : View Never
}
-> Page () Never
static =
ElmSpa.static Effect.none
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> View msg
}
-> Page model msg
sandbox =
ElmSpa.sandbox Effect.none
element :
{ init : ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
-> Page model msg
element =
ElmSpa.element Effect.fromCmd
advanced :
{ init : ( model, Effect msg )
, update : msg -> model -> ( model, Effect msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
-> Page model msg
advanced =
ElmSpa.advanced
protected :
{ static :
{ view : User -> View msg
}
-> Page () msg
, sandbox :
{ init : User -> model
, update : User -> msg -> model -> model
, view : User -> model -> View msg
}
-> Page model msg
, element :
{ init : User -> ( model, Cmd msg )
, update : User -> msg -> model -> ( model, Cmd msg )
, view : User -> model -> View msg
, subscriptions : User -> model -> Sub msg
}
-> Page model msg
, advanced :
{ init : User -> ( model, Effect msg )
, update : User -> msg -> model -> ( model, Effect msg )
, view : User -> model -> View msg
, subscriptions : User -> model -> Sub msg
}
-> Page model msg
}
protected =
ElmSpa.protected2
{ effectNone = Effect.none
, fromCmd = Effect.fromCmd
, beforeInit = beforeProtectedInit
}

View File

@ -3,6 +3,6 @@ module Pages.NotFound exposing (view)
import View exposing (View)
view : View Never
view : View msg
view =
View.placeholder "Page not found."

View File

@ -23,18 +23,18 @@ type Msg
= NoOp
init : Request () -> Flags -> ( Model, Cmd Msg )
init : Request -> Flags -> ( Model, Cmd Msg )
init _ _ =
( {}, Cmd.none )
update : Request () -> Msg -> Model -> ( Model, Cmd Msg )
update : Request -> Msg -> Model -> ( Model, Cmd Msg )
update _ msg model =
case msg of
NoOp ->
( model, Cmd.none )
subscriptions : Request () -> Model -> Sub Msg
subscriptions : Request -> Model -> Sub Msg
subscriptions _ _ =
Sub.none

View File

@ -13,7 +13,7 @@
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0",
"ryannhg/elm-spa": "5.2.0"
"ryannhg/elm-spa": "5.3.0"
},
"indirect": {
"elm/time": "1.0.0",

View File

@ -4,7 +4,7 @@ import Html
import View exposing (View)
view : View Never
view : View msg
view =
{ title = "Homepage"
, body = [ Html.text "Hello, world!" ]

View File

@ -1,9 +0,0 @@
import { exec } from 'child_process'
export const run = (cmd : string) : Promise<unknown> =>
new Promise((resolve, reject) =>
exec(cmd, (err, stdout, stderr) => err
? reject(stderr.split('npm ERR!')[0] || stderr)
: resolve(stdout)
)
)

View File

@ -1,10 +1,10 @@
export default (page : string[]) : string => `
export default (page: string[]): string => `
module Pages.${page.join('.')} exposing (view)
import View exposing (View)
view : View Never
view : View msg
view =
View.placeholder "${page.join('.')}"

View File

@ -2,13 +2,14 @@ module Pages.{{module}} exposing (Model, Msg, page)
import Effect exposing (Effect)
import Gen.Params.{{module}} exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import View exposing (View)
import Page
page : Shared.Model -> Request Params -> Page Model Msg
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.advanced
{ init = init

View File

@ -1,13 +1,13 @@
module Pages.{{module}} exposing (Model, Msg, page)
import Gen.Params.{{module}} exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import View exposing (View)
page : Shared.Model -> Request Params -> Page Model Msg
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.element
{ init = init

View File

@ -1,13 +1,13 @@
module Pages.{{module}} exposing (Model, Msg, page)
import Gen.Params.{{module}} exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Page
import Request
import Shared
import View exposing (View)
page : Shared.Model -> Request Params -> Page Model Msg
page : Shared.Model -> Request.With Params -> Page.With Model Msg
page shared req =
Page.sandbox
{ init = init

View File

@ -2,18 +2,18 @@ module Pages.{{module}} exposing (page)
import Gen.Params.{{module}} exposing (Params)
import Page exposing (Page)
import Request exposing (Request)
import Request
import Shared
import View exposing (View)
page : Shared.Model -> Request Params -> Page () Never
page : Shared.Model -> Request.With Params -> Page
page shared req =
Page.static
{ view = view
}
view : View Never
view : View msg
view =
View.placeholder "{{module}}"

View File

@ -0,0 +1,126 @@
export default (): string => `
module Page exposing
( Page, With
, static, sandbox, element, advanced
, protected
)
{-|
@docs Page, With
@docs static, sandbox, element, advanced
@docs protected
-}
import Auth exposing (User)
import Effect exposing (Effect)
import ElmSpa.Internals.Page as ElmSpa
import Gen.Route exposing (Route)
import Request exposing (Request)
import Shared
import View exposing (View)
-- PAGES
type alias Page =
With () Never
type alias With model msg =
ElmSpa.Page Shared.Model Route (Effect msg) (View msg) model msg
static :
{ view : View Never
}
-> Page
static =
ElmSpa.static Effect.none
sandbox :
{ init : model
, update : msg -> model -> model
, view : model -> View msg
}
-> With model msg
sandbox =
ElmSpa.sandbox Effect.none
element :
{ init : ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
-> With model msg
element =
ElmSpa.element Effect.fromCmd
advanced :
{ init : ( model, Effect msg )
, update : msg -> model -> ( model, Effect msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
-> With model msg
advanced =
ElmSpa.advanced
-- PROTECTED PAGES
protected :
{ static :
(User
->
{ view : View msg
}
)
-> With () msg
, sandbox :
(User
->
{ init : model
, update : msg -> model -> model
, view : model -> View msg
}
)
-> With model msg
, element :
(User
->
{ init : ( model, Cmd msg )
, update : msg -> model -> ( model, Cmd msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
)
-> With model msg
, advanced :
(User
->
{ init : ( model, Effect msg )
, update : msg -> model -> ( model, Effect msg )
, view : model -> View msg
, subscriptions : model -> Sub msg
}
)
-> With model msg
}
protected =
ElmSpa.protected3
{ effectNone = Effect.none
, fromCmd = Effect.fromCmd
, beforeInit = Auth.beforeProtectedInit
}
`.trimLeft()

View File

@ -1,4 +1,17 @@
module Request exposing (Request, create, pushRoute, replaceRoute)
export default (): string => `
module Request exposing
( Request, With
, create
, pushRoute, replaceRoute
)
{-|
@docs Request, With
@docs create
@docs pushRoute, replaceRoute
-}
import Browser.Navigation exposing (Key)
import ElmSpa.Request as ElmSpa
@ -6,20 +19,26 @@ import Gen.Route as Route exposing (Route)
import Url exposing (Url)
type alias Request params =
type alias Request =
With ()
type alias With params =
ElmSpa.Request Route params
create : params -> Url -> Key -> Request params
create : params -> Url -> Key -> With params
create params url key =
ElmSpa.create (Route.fromUrl url) params url key
pushRoute : Route -> Request params -> Cmd msg
pushRoute : Route -> With params -> Cmd msg
pushRoute route req =
Browser.Navigation.pushUrl req.key (Route.toHref route)
replaceRoute : Route -> Request params -> Cmd msg
replaceRoute : Route -> With params -> Cmd msg
replaceRoute route req =
Browser.Navigation.replaceUrl req.key (Route.toHref route)
`.trimLeft()

View File

@ -1,6 +1,31 @@
import config from "../config"
import { routeTypeDefinition, indent, routeParserList, paramsImports, Options, routeToHref } from "./utils"
export default (pages : string[][], _options : Options) : string => `
const routeParserOrder = (pages: string[][]) =>
[...pages].sort(sorter)
const isHomepage = (list: string[]) => list.join('.') === config.reserved.homepage
const isDynamic = (piece: string) => piece.endsWith('_')
const alphaSorter = (a: string, b: string) => a < b ? -1 : b < a ? 1 : 0
const sorter = (a: string[], b: string[]): (-1 | 1 | 0) => {
if (isHomepage(a)) return -1
if (isHomepage(b)) return 1
if (a.length < b.length) return -1
if (a.length > b.length) return 1
for (let i in a) {
const [isA, isB] = [isDynamic(a[i]), isDynamic(b[i])]
if (isA && isB) return alphaSorter(a[i], b[i])
if (isA) return 1
if (isB) return -1
}
return 0
}
export default (pages: string[][], _options: Options): string => `
module Gen.Route exposing
( Route(..)
, fromUrl
@ -22,7 +47,7 @@ fromUrl =
routes : List (Parser (Route -> a) a)
routes =
${indent(routeParserList(pages), 1)}
${indent(routeParserList(routeParserOrder(pages)), 1)}
toHref : Route -> String

View File

@ -316,9 +316,15 @@ const pageModelArguments = (path: string[], options : Options) : string => {
}
}
// Used in place of sophisticated AST parsing
const exposes = (keyword: string) => (elmSourceCode: string): boolean =>
new RegExp(`module\\s(\\S)+\\sexposing(\\s)+\\([^\\)]*${keyword}[^\\)]*\\)`, 'm').test(elmSourceCode)
const exposes = (value : string) => (str : string) : boolean => {
const regex = new RegExp('^module\\s+[^\\s]+\\s+exposing\\s+\\(([^)]+)\\)')
const match = (str.match(regex) || [])[1]
if (match) {
return match.split(',').filter(a => a).map(a => a.trim()).includes(value)
} else {
return false
}
}
export const exposesModel = exposes('Model')
export const exposesMsg = exposes('Msg')
@ -334,4 +340,4 @@ export const isStaticPage = (src : string) =>
exposesPageFunction(src)
export const isStaticView = (src : string) =>
exposesViewFunction(src)
exposesViewFunction(src)

View File

@ -184,18 +184,18 @@ describe.each([['Model'], ['Msg']])
module Layout exposing
( ${name}
)
`)).toBe(true)
`.trim())).toBe(true)
expect(fn(`
module Layout exposing
( OtherImport
, ${name}
)
`)).toBe(true)
`.trim())).toBe(true)
expect(fn(`
module Layout exposing
( ${name}
, OtherImport
)
`)).toBe(true)
`.trim())).toBe(true)
})
})