move over from private repo
20
.github/ISSUE_TEMPLATE/new-example.md
vendored
@ -1,20 +0,0 @@
|
||||
---
|
||||
name: New Example
|
||||
about: Create an example for the official site!
|
||||
title: 'Featured Example: [name]'
|
||||
labels: examples
|
||||
assignees: ryannhg
|
||||
|
||||
---
|
||||
|
||||
### Name
|
||||
|
||||
### Short Description (Less than 10 words)
|
||||
|
||||
### Github Username
|
||||
|
||||
### Screenshot (will be 360px wide)
|
||||
|
||||
### Demo URL
|
||||
|
||||
### Source Code URL
|
28
.github/workflows/nodejs.yml
vendored
@ -1,28 +0,0 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run test:ci
|
30
LICENSE
@ -1,30 +0,0 @@
|
||||
Copyright (c) 2020-present, Ryan Haskell-Glatz
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
* Neither the name of Ryan Haskell-Glatz nor the names of other
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
18
README.md
@ -1,12 +1,16 @@
|
||||
# elm-spa
|
||||
|
||||
![Build](https://github.com/ryannhg/elm-spa/workflows/Build/badge.svg?branch=master)
|
||||
# [![elm-spa](https://sad-kirch-e164e1.netlify.app/images/logo.png)](https://v6.elm-spa.dev)
|
||||
|
||||
|
||||
## single page apps made easy
|
||||
# **Installation**
|
||||
|
||||
```
|
||||
npm install -g elm-spa@latest
|
||||
```bash
|
||||
npm install -g elm-spa@6.0.0
|
||||
```
|
||||
|
||||
Learn more at [the offical guide](https://elm-spa.dev/guide)!
|
||||
## **Learn more**
|
||||
|
||||
__Visit the official site__ at [elm-spa.dev](https://v6.elm-spa.dev) for more examples, guides, and other documentation.
|
||||
|
||||
## **The Elm package**
|
||||
|
||||
__If you are using elm-spa__, there's no read through the [Elm package](https://package.elm-lang.org/packages/ryannhg/elm-spa/6.0.0/) documentation. The package exists to constrain the CLI, and serves as a reference for future contributions.
|
67
docs/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
# elm-spa.dev
|
||||
> 🌳 built with [elm-spa](https://elm-spa.dev)
|
||||
|
||||
![Screenshot of the site](./public/images/screenshot.png)
|
||||
|
||||
## dependencies
|
||||
|
||||
This project requires the latest LTS version of [Node.js](https://nodejs.org/)
|
||||
|
||||
```bash
|
||||
npm install -g elm-spa
|
||||
```
|
||||
|
||||
## running locally
|
||||
|
||||
```bash
|
||||
elm-spa server # starts this app at http:/localhost:1234
|
||||
```
|
||||
|
||||
### other commands
|
||||
|
||||
```bash
|
||||
elm-spa add # add a new page to the application
|
||||
elm-spa build # one-time production build
|
||||
elm-spa watch # builds code as you go (without the server)
|
||||
```
|
||||
|
||||
## learn more
|
||||
|
||||
You can learn more at [elm-spa.dev](https://elm-spa.dev)
|
||||
|
||||
|
||||
## guide
|
||||
|
||||
- Overview
|
||||
- Features
|
||||
- Quickstart
|
||||
- Installation
|
||||
- CLI
|
||||
- Creating new projects
|
||||
- Adding new pages
|
||||
- Production build
|
||||
- Developing locally
|
||||
- Using other dev servers
|
||||
- Basics
|
||||
- Routing
|
||||
- Pages
|
||||
- Not Found (Pages/NotFound.elm)
|
||||
- Shared (Shared)
|
||||
- Components
|
||||
- View (View)
|
||||
- Static Assets
|
||||
- Advanced
|
||||
- Custom Development Server
|
||||
- Parcel
|
||||
- Webpack
|
||||
- Deployment & hosting
|
||||
- Netlify
|
||||
- Github pages
|
||||
- User authentication
|
||||
- Storing tokens
|
||||
- Redirecting pages
|
||||
- Local storage
|
||||
- Page Transitions (Main)
|
||||
- Customizing Main.elm
|
||||
- REST APIs
|
||||
- GraphQL
|
@ -1,33 +1,34 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
"src",
|
||||
".elm-spa/defaults",
|
||||
".elm-spa/generated",
|
||||
"../src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"dillonkearns/elm-markdown": "5.1.1",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"rtfeldman/elm-hex": "1.0.0"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {
|
||||
"avh4/elm-program-test": "3.2.0",
|
||||
"elm-explorations/test": "1.2.2"
|
||||
},
|
||||
"indirect": {
|
||||
"avh4/elm-fifo": "1.0.4",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/random": "1.0.0"
|
||||
}
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
90
docs/public/content/guide.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Guide
|
||||
|
||||
Welcome to __elm-spa__, a framework for building web applications with [Elm](https://elm-lang.org)!
|
||||
If you are new to Elm, you should check out [the official guide](https://guide.elm-lang.org), which
|
||||
is a great introduction to the language.
|
||||
|
||||
The goal of _this_ guide is to help you solve common problems you might run into with real world single-page web applications.
|
||||
|
||||
## Features
|
||||
|
||||
Here are a few benefits to using elm-spa:
|
||||
1. __Automatic routing__ - routes for your web app are automatically generated based on file names. No need to maintain URL routing logic or wire pages together manually.
|
||||
1. __Simple shared state__ - comes with a straightforward way to share data within and between pages. You can also make pages as simple or complex as you need!
|
||||
1. __Zero configuration__ - Includes a hot-reloading dev server, build tool, and everything you need in one CLI tool! No need for webpack, uglify, or other NPM packages.
|
||||
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Creating your first project
|
||||
|
||||
You can create a new __elm-spa__ project from scratch my creating a new folder:
|
||||
|
||||
```terminal
|
||||
mkdir my-new-project && cd my-new-project
|
||||
```
|
||||
|
||||
And then running this command in your terminal:
|
||||
|
||||
```terminal
|
||||
npx elm-spa new
|
||||
```
|
||||
|
||||
This will create a brand new project in the `my-new-project` folder! It should only contain these three files:
|
||||
|
||||
```bash
|
||||
my-new-project/
|
||||
- .gitignore # folders to ignore in git
|
||||
- elm.json # project dependencies
|
||||
- src/
|
||||
- public/
|
||||
- index.html # entrypoint to your application
|
||||
```
|
||||
|
||||
### Running the dev server
|
||||
|
||||
Running this command will start a development server at `http://localhost:1234`:
|
||||
|
||||
```terminal
|
||||
npx elm-spa server
|
||||
```
|
||||
|
||||
### Adding your first page
|
||||
|
||||
To add a homepage, run the `elm-spa add` command:
|
||||
|
||||
```terminal
|
||||
npx elm-spa add /
|
||||
```
|
||||
|
||||
This will create a new page at `./src/Pages/Home_.elm`. Try editing the text in that file's `view` function, it will automatically change in the browser!
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
So far, we've been using the [npx command](https://www.npmjs.com/package/npx) built into Node.js to run the `elm-spa` CLI. If we would rather use the CLI without this prefix, we can install __elm-spa__ globally:
|
||||
|
||||
```terminal
|
||||
npm install -g elm-spa@latest
|
||||
```
|
||||
|
||||
This will ensure we have the latest version of elm-spa available in our terminal. You can make sure it works by calling `elm-spa` directly:
|
||||
|
||||
```terminal
|
||||
elm-spa help
|
||||
|
||||
elm-spa – version 6.0.0
|
||||
|
||||
Commands:
|
||||
elm-spa new . . . . . . . . . create a new project
|
||||
elm-spa add <url> . . . . . . . . create a new page
|
||||
elm-spa build . . . . . . one-time production build
|
||||
elm-spa watch . . . . . . . runs build as you code
|
||||
elm-spa server . . . . . . start a live dev server
|
||||
|
||||
Visit https://next.elm-spa.dev for more!
|
||||
```
|
||||
|
||||
If you see this message, you can run all the CLI commands without needing to prefix them with `npx`!
|
||||
|
||||
__Next up:__ [The CLI](/guide/cli)
|
136
docs/public/content/guide/cli.md
Normal file
@ -0,0 +1,136 @@
|
||||
# The CLI
|
||||
|
||||
The [official __elm-spa__ CLI tool](https://npmjs.org/elm-spa) has a few commands to help you build single page applications. As we saw in [the previous section](/guide/overview), you can use the CLI from your terminal by running:
|
||||
|
||||
```terminal
|
||||
npm install -g elm-spa@latest
|
||||
```
|
||||
|
||||
At any time running `elm-spa` or `elm-spa help` will show you the available commands:
|
||||
|
||||
```
|
||||
elm-spa new . . . . . . . . . create a new project
|
||||
elm-spa add <url> . . . . . . . . create a new page
|
||||
elm-spa build . . . . . . one-time production build
|
||||
elm-spa watch . . . . . . . runs build as you code
|
||||
elm-spa server . . . . . . start a live dev server
|
||||
```
|
||||
|
||||
## Creating new projects
|
||||
|
||||
The `new` command creates a new project in the current folder:
|
||||
|
||||
```terminal
|
||||
elm-spa new
|
||||
```
|
||||
|
||||
This command will only create a few files, so don't worry about getting overwhelmed with new files in your repo! Other than a `.gitignore`, there are only 2 new files created.
|
||||
|
||||
File | Description
|
||||
--- | ---
|
||||
`elm.json` | Your project's dependencies.
|
||||
`src` | An empty folder for your Elm code.
|
||||
`public/index.html` | The entrypoint to your application.
|
||||
|
||||
```
|
||||
your-project/
|
||||
- elm.json
|
||||
- src/
|
||||
- public/
|
||||
- index.html
|
||||
```
|
||||
|
||||
The `public` folder is a place for static assets! For example, a file at `./public/style.css` will be available at `/style.css` in your web browser.
|
||||
|
||||
## Adding pages
|
||||
|
||||
The next section will dive into deeper detail, but __elm-spa__ directly maps file names to URLs.
|
||||
|
||||
URL | File Location
|
||||
--- | ---
|
||||
`/` | `src/Pages/Home_.elm`
|
||||
`/about-us` | `src/Pages/AboutUs.elm`
|
||||
`/people/ryan` | `src/Pages/People/Ryan.elm`
|
||||
|
||||
The `elm-spa add` command makes it easy to scaffold out new pages in your application!
|
||||
|
||||
### Adding a homepage
|
||||
|
||||
Here's how you can add a homepage with the `elm-spa add` command:
|
||||
|
||||
```terminal
|
||||
elm-spa add /
|
||||
```
|
||||
|
||||
### Adding static pages
|
||||
|
||||
You can add [static routes](/guide/basics/routing#static-routes) with the add command also:
|
||||
|
||||
```terminal
|
||||
elm-spa add /settings
|
||||
```
|
||||
|
||||
This command will create a new page at `src/Pages/Settings.elm`, and be available at `/settings`.
|
||||
|
||||
### Adding dynamic pages
|
||||
|
||||
In the [next section](/guide/basics/routing), you'll learn more about static and dynamic pages, which can handle dynamic URL parameters to make life easy. For example, if we wanted a "Person Detail" page, we could do something like this:
|
||||
|
||||
```terminal
|
||||
elm-spa add /people/:name
|
||||
```
|
||||
|
||||
This creates a new page at `src/Pages/People/Name_.elm`. The underscore (`_`) at the end of the filename indicates a __dynamic__ route! This dynamic route handles requests to pages like these:
|
||||
|
||||
URL | Params
|
||||
--- | ---
|
||||
`/people/ryan` | `{ name = "ryan" }`
|
||||
`/people/erik` | `{ name = "erik" }`
|
||||
`/people/alexa` | `{ name = "alexa" }`
|
||||
|
||||
The name of the file (`Name_.elm`) determines the variable name.
|
||||
|
||||
### Removing pages
|
||||
|
||||
Removing pages with __elm-spa__ is as simple as __deleting the file__
|
||||
|
||||
```terminal
|
||||
rm src/Pages/Settings.elm
|
||||
```
|
||||
|
||||
You can do this however you prefer, but there isn't an `elm-spa remove` command in the CLI.
|
||||
|
||||
|
||||
## Developing locally
|
||||
|
||||
The __elm-spa__ CLI comes with a hot-reloading development server built in. As you save files in the `src` and `public` folders, your local site will automatically refresh.
|
||||
|
||||
```terminal
|
||||
elm-spa server
|
||||
```
|
||||
|
||||
By default, the server will start on port 1234. You can specify a different port with the `PORT` environment variable:
|
||||
|
||||
```terminal
|
||||
PORT=8000 elm-spa server
|
||||
```
|
||||
|
||||
__Note:__ The `server` command is not designed for production use! To
|
||||
|
||||
### Prefer webpack or parcel?
|
||||
|
||||
You can use the `watch` command to build assets without running the development server. This will build your application, and allow you to use something like [Parcel](https://parceljs.org/elm.html) or [webpack](https://github.com/elm-community/elm-webpack-loader).
|
||||
|
||||
```terminal
|
||||
elm-spa watch
|
||||
```
|
||||
|
||||
## Building for production
|
||||
|
||||
When you are ready you ship your application, the `build` command will create a minified and optimized JS file for production.
|
||||
|
||||
```terminal
|
||||
elm-spa build
|
||||
```
|
||||
|
||||
For more information about deployments and hosting, you should check out the [Hosting & Development](/guide/hosting) section!
|
63
docs/public/content/guide/pages.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Pages
|
||||
|
||||
Every route in your Elm application will be connected to a `Page` file. These files
|
||||
all have the same general shape:
|
||||
|
||||
```elm
|
||||
module Pages._____ exposing (Model, Msg, page)
|
||||
|
||||
page : Shared.Model -> Request Params -> Page Model Msg
|
||||
```
|
||||
|
||||
This section of the guide will introduce you to the __four__ kinds of pages you might
|
||||
need to use in any Elm application.
|
||||
|
||||
> It's important that _every_ page exposes `Model`, `Msg`, and `page`. If any of these three are missing or renamed, the generated code will not work.
|
||||
|
||||
## Static pages
|
||||
|
||||
If you only need to render some HTML on the page, use `Page.static`:
|
||||
|
||||
```elm
|
||||
Page.static
|
||||
{ view : View Msg
|
||||
}
|
||||
```
|
||||
|
||||
## Sandbox pages
|
||||
|
||||
Need to keep track of local state, like the current tab? Check out `Page.sandbox`!
|
||||
|
||||
```elm
|
||||
Page.sandbox
|
||||
{ init : Model
|
||||
, update : Msg -> Model -> Model
|
||||
, view : Model -> View Msg
|
||||
}
|
||||
```
|
||||
|
||||
## Element pages
|
||||
|
||||
If you want to send [HTTP requests](https://guide.elm-lang.org/effects/http.html) or subscribe to other external events, you're ready for `Page.element`:
|
||||
|
||||
```elm
|
||||
Page.element
|
||||
{ init : ( Model, Cmd Msg )
|
||||
, update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
, view : Model -> View Msg
|
||||
, subscriptions : Model -> Sub Msg
|
||||
}
|
||||
```
|
||||
|
||||
## Shared pages
|
||||
|
||||
When it comes time to update the global state shared from page to page, you can upgrade to `Page.shared`:
|
||||
|
||||
```elm
|
||||
Page.shared
|
||||
{ init : ( Model, Cmd Msg, List Shared.Msg )
|
||||
, update : Msg -> Model -> ( Model, Cmd Msg, List Shared.Msg )
|
||||
, view : Model -> View Msg
|
||||
, subscriptions : Model -> Sub Msg
|
||||
}
|
||||
```
|
97
docs/public/content/guide/requests.md
Normal file
@ -0,0 +1,97 @@
|
||||
# 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
|
||||
, 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 URLs like this:
|
||||
|
||||
```
|
||||
/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!
|
||||
|
||||
## 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 you'll need access to `req.key` in order to use `Nav.pushUrl` or `Nav.replaceUrl`. 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
|
||||
, Nav.pushUrl req.key "/dashboard"
|
||||
)
|
||||
```
|
||||
|
||||
When the `SignedIn` message is fired, this code will redirect the user to the dashboard. Feel free to check out the [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation) package docs for more in-depth examples.
|
106
docs/public/content/guide/routing.md
Normal file
@ -0,0 +1,106 @@
|
||||
# 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` file is a reserved page 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 at the end of this sentence.
|
||||
|
||||
## 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.
|
45
docs/public/content/guide/shared-state.md
Normal file
@ -0,0 +1,45 @@
|
||||
# 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.
|
15
docs/public/content/guide/views.md
Normal file
@ -0,0 +1,15 @@
|
||||
# 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.
|
BIN
docs/public/favicon.png
Normal file
After Width: | Height: | Size: 82 KiB |
406
docs/public/highlight.pack.js
Normal file
@ -0,0 +1,406 @@
|
||||
/*
|
||||
Highlight.js 10.4.1 (e96b915a)
|
||||
License: BSD-3-Clause
|
||||
Copyright (c) 2006-2020, Ivan Sagalaev
|
||||
*/
|
||||
var hljs=function(){"use strict";function e(t){
|
||||
return t instanceof Map?t.clear=t.delete=t.set=()=>{
|
||||
throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{
|
||||
throw Error("set is read-only")
|
||||
}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var s=t[n]
|
||||
;"object"!=typeof s||Object.isFrozen(s)||e(s)})),t}var t=e,n=e;t.default=n
|
||||
;class s{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data}
|
||||
ignoreMatch(){this.ignore=!0}}function r(e){
|
||||
return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")
|
||||
}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
|
||||
;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}function i(e){
|
||||
return e.nodeName.toLowerCase()}var o=Object.freeze({__proto__:null,
|
||||
escapeHTML:r,inherit:a,nodeStream:e=>{const t=[];return function e(n,s){
|
||||
for(let r=n.firstChild;r;r=r.nextSibling)3===r.nodeType?s+=r.nodeValue.length:1===r.nodeType&&(t.push({
|
||||
event:"start",offset:s,node:r}),s=e(r,s),i(r).match(/br|hr|img|input/)||t.push({
|
||||
event:"stop",offset:s,node:r}));return s}(e,0),t},mergeStreams:(e,t,n)=>{
|
||||
let s=0,a="";const o=[];function l(){
|
||||
return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:"start"===t[0].event?e:t:e.length?e:t
|
||||
}function c(e){
|
||||
a+="<"+i(e)+[].map.call(e.attributes,(e=>" "+e.nodeName+'="'+r(e.value)+'"')).join("")+">"
|
||||
}function u(e){a+="</"+i(e)+">"}function g(e){("start"===e.event?c:u)(e.node)}
|
||||
for(;e.length||t.length;){let t=l()
|
||||
;if(a+=r(n.substring(s,t[0].offset)),s=t[0].offset,t===e){o.reverse().forEach(u)
|
||||
;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===s)
|
||||
;o.reverse().forEach(c)
|
||||
}else"start"===t[0].event?o.push(t[0].node):o.pop(),g(t.splice(0,1)[0])}
|
||||
return a+r(n.substr(s))}});const l=e=>!!e.kind;class c{constructor(e,t){
|
||||
this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
|
||||
this.buffer+=r(e)}openNode(e){if(!l(e))return;let t=e.kind
|
||||
;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){
|
||||
l(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
|
||||
this.buffer+=`<span class="${e}">`}}class u{constructor(){this.rootNode={
|
||||
children:[]},this.stack=[this.rootNode]}get top(){
|
||||
return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
|
||||
this.top.children.push(e)}openNode(e){const t={kind:e,children:[]}
|
||||
;this.add(t),this.stack.push(t)}closeNode(){
|
||||
if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){
|
||||
for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}
|
||||
walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){
|
||||
return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),
|
||||
t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){
|
||||
"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{
|
||||
u._collapse(e)})))}}class g extends u{constructor(e){super(),this.options=e}
|
||||
addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}
|
||||
addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root
|
||||
;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){
|
||||
return new c(this,this.options).value()}finalize(){return!0}}function d(e){
|
||||
return e?"string"==typeof e?e:e.source:null}
|
||||
const h="[a-zA-Z]\\w*",f="[a-zA-Z_]\\w*",p="\\b\\d+(\\.\\d+)?",m="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",b="\\b(0b[01]+)",x={
|
||||
begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'",
|
||||
illegal:"\\n",contains:[x]},v={className:"string",begin:'"',end:'"',
|
||||
illegal:"\\n",contains:[x]},_={
|
||||
begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
|
||||
},w=(e,t,n={})=>{const s=a({className:"comment",begin:e,end:t,contains:[]},n)
|
||||
;return s.contains.push(_),s.contains.push({className:"doctag",
|
||||
begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),s
|
||||
},N=w("//","$"),y=w("/\\*","\\*/"),R=w("#","$");var k=Object.freeze({
|
||||
__proto__:null,IDENT_RE:h,UNDERSCORE_IDENT_RE:f,NUMBER_RE:p,C_NUMBER_RE:m,
|
||||
BINARY_NUMBER_RE:b,
|
||||
RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
|
||||
SHEBANG:(e={})=>{const t=/^#![ ]*\//
|
||||
;return e.binary&&(e.begin=((...e)=>e.map((e=>d(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)),
|
||||
a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
|
||||
0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:x,APOS_STRING_MODE:E,
|
||||
QUOTE_STRING_MODE:v,PHRASAL_WORDS_MODE:_,COMMENT:w,C_LINE_COMMENT_MODE:N,
|
||||
C_BLOCK_COMMENT_MODE:y,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number",
|
||||
begin:p,relevance:0},C_NUMBER_MODE:{className:"number",begin:m,relevance:0},
|
||||
BINARY_NUMBER_MODE:{className:"number",begin:b,relevance:0},CSS_NUMBER_MODE:{
|
||||
className:"number",
|
||||
begin:p+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",
|
||||
relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",
|
||||
begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[x,{begin:/\[/,end:/\]/,
|
||||
relevance:0,contains:[x]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0
|
||||
},UNDERSCORE_TITLE_MODE:{className:"title",begin:f,relevance:0},METHOD_GUARD:{
|
||||
begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{
|
||||
"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
|
||||
t.data._beginMatch!==e[1]&&t.ignoreMatch()}})})
|
||||
;const M=["of","and","for","in","not","or","if","then","parent","list","value"]
|
||||
;function O(e){function t(t,n){
|
||||
return RegExp(d(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class n{
|
||||
constructor(){
|
||||
this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}
|
||||
addRule(e,t){
|
||||
t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),
|
||||
this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){
|
||||
0===this.regexes.length&&(this.exec=()=>null)
|
||||
;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(((e,t="|")=>{
|
||||
const n=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;let s=0,r=""
|
||||
;for(let a=0;a<e.length;a++){s+=1;const i=s;let o=d(e[a])
|
||||
;for(a>0&&(r+=t),r+="(";o.length>0;){const e=n.exec(o);if(null==e){r+=o;break}
|
||||
r+=o.substring(0,e.index),
|
||||
o=o.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?r+="\\"+(Number(e[1])+i):(r+=e[0],
|
||||
"("===e[0]&&s++)}r+=")"}return r})(e),!0),this.lastIndex=0}exec(e){
|
||||
this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)
|
||||
;if(!t)return null
|
||||
;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),s=this.matchIndexes[n]
|
||||
;return t.splice(0,n),Object.assign(t,s)}}class s{constructor(){
|
||||
this.rules=[],this.multiRegexes=[],
|
||||
this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
|
||||
if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n
|
||||
;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),
|
||||
t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){
|
||||
return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){
|
||||
this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){
|
||||
const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex
|
||||
;let n=t.exec(e)
|
||||
;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{
|
||||
const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}
|
||||
return n&&(this.regexIndex+=n.position+1,
|
||||
this.regexIndex===this.count&&this.considerAll()),n}}function r(e,t){
|
||||
"."===e.input[e.index-1]&&t.ignoreMatch()}
|
||||
if(e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.")
|
||||
;return e.classNameAliases=a(e.classNameAliases||{}),function n(i,o){const l=i
|
||||
;if(i.compiled)return l
|
||||
;i.compiled=!0,i.__beforeBegin=null,i.keywords=i.keywords||i.beginKeywords
|
||||
;let c=null
|
||||
;if("object"==typeof i.keywords&&(c=i.keywords.$pattern,delete i.keywords.$pattern),
|
||||
i.keywords&&(i.keywords=((e,t)=>{const n={}
|
||||
;return"string"==typeof e?s("keyword",e):Object.keys(e).forEach((t=>{s(t,e[t])
|
||||
})),n;function s(e,s){t&&(s=s.toLowerCase()),s.split(" ").forEach((t=>{
|
||||
const s=t.split("|");n[s[0]]=[e,A(s[0],s[1])]}))}
|
||||
})(i.keywords,e.case_insensitive)),
|
||||
i.lexemes&&c)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ")
|
||||
;return l.keywordPatternRe=t(i.lexemes||c||/\w+/,!0),
|
||||
o&&(i.beginKeywords&&(i.begin="\\b("+i.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
|
||||
i.__beforeBegin=r),
|
||||
i.begin||(i.begin=/\B|\b/),l.beginRe=t(i.begin),i.endSameAsBegin&&(i.end=i.begin),
|
||||
i.end||i.endsWithParent||(i.end=/\B|\b/),
|
||||
i.end&&(l.endRe=t(i.end)),l.terminator_end=d(i.end)||"",
|
||||
i.endsWithParent&&o.terminator_end&&(l.terminator_end+=(i.end?"|":"")+o.terminator_end)),
|
||||
i.illegal&&(l.illegalRe=t(i.illegal)),
|
||||
void 0===i.relevance&&(i.relevance=1),i.contains||(i.contains=[]),
|
||||
i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map((t=>a(e,{
|
||||
variants:null},t)))),e.cached_variants?e.cached_variants:L(e)?a(e,{
|
||||
starts:e.starts?a(e.starts):null
|
||||
}):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{n(e,l)
|
||||
})),i.starts&&n(i.starts,o),l.matcher=(e=>{const t=new s
|
||||
;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
|
||||
}))),e.terminator_end&&t.addRule(e.terminator_end,{type:"end"
|
||||
}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function L(e){
|
||||
return!!e&&(e.endsWithParent||L(e.starts))}function A(e,t){
|
||||
return t?Number(t):(e=>M.includes(e.toLowerCase()))(e)?0:1}function j(e){
|
||||
const t={props:["language","code","autodetect"],data:()=>({detectedLanguage:"",
|
||||
unknownLanguage:!1}),computed:{className(){
|
||||
return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){
|
||||
if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),
|
||||
this.unknownLanguage=!0,r(this.code);let t
|
||||
;return this.autoDetect?(t=e.highlightAuto(this.code),
|
||||
this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),
|
||||
this.detectedLanguage=this.language),t.value},autoDetect(){
|
||||
return!(this.language&&(e=this.autodetect,!e&&""!==e));var e},
|
||||
ignoreIllegals:()=>!0},render(e){return e("pre",{},[e("code",{
|
||||
class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{
|
||||
Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}
|
||||
const I=r,S=a,{nodeStream:T,mergeStreams:B}=o,P=Symbol("nomatch");return(e=>{
|
||||
const n=[],r=Object.create(null),a=Object.create(null),i=[];let o=!0
|
||||
;const l=/(^(<[^>]+>|\t|)+|\n)/gm,c="Could not find the language '{}', did you forget to load/include a language module?",u={
|
||||
disableAutodetect:!0,name:"Plain text",contains:[]};let d={
|
||||
noHighlightRe:/^(no-?highlight)$/i,
|
||||
languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
|
||||
tabReplace:null,useBR:!1,languages:null,__emitter:g};function h(e){
|
||||
return d.noHighlightRe.test(e)}function f(e,t,n,s){const r={code:t,language:e}
|
||||
;N("before:highlight",r);const a=r.result?r.result:p(r.language,r.code,n,s)
|
||||
;return a.code=r.code,N("after:highlight",a),a}function p(e,t,n,a){const i=t
|
||||
;function l(e,t){const n=_.case_insensitive?t[0].toLowerCase():t[0]
|
||||
;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}
|
||||
function u(){null!=y.subLanguage?(()=>{if(""===M)return;let e=null
|
||||
;if("string"==typeof y.subLanguage){
|
||||
if(!r[y.subLanguage])return void k.addText(M)
|
||||
;e=p(y.subLanguage,M,!0,R[y.subLanguage]),R[y.subLanguage]=e.top
|
||||
}else e=m(M,y.subLanguage.length?y.subLanguage:null)
|
||||
;y.relevance>0&&(L+=e.relevance),k.addSublanguage(e.emitter,e.language)
|
||||
})():(()=>{if(!y.keywords)return void k.addText(M);let e=0
|
||||
;y.keywordPatternRe.lastIndex=0;let t=y.keywordPatternRe.exec(M),n="";for(;t;){
|
||||
n+=M.substring(e,t.index);const s=l(y,t);if(s){const[e,r]=s
|
||||
;k.addText(n),n="",L+=r;const a=_.classNameAliases[e]||e;k.addKeyword(t[0],a)
|
||||
}else n+=t[0];e=y.keywordPatternRe.lastIndex,t=y.keywordPatternRe.exec(M)}
|
||||
n+=M.substr(e),k.addText(n)})(),M=""}function g(e){
|
||||
return e.className&&k.openNode(_.classNameAliases[e.className]||e.className),
|
||||
y=Object.create(e,{parent:{value:y}}),y}function h(e,t,n){let r=((e,t)=>{
|
||||
const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(r){if(e["on:end"]){
|
||||
const n=new s(e);e["on:end"](t,n),n.ignore&&(r=!1)}if(r){
|
||||
for(;e.endsParent&&e.parent;)e=e.parent;return e}}
|
||||
if(e.endsWithParent)return h(e.parent,t,n)}function f(e){
|
||||
return 0===y.matcher.regexIndex?(M+=e[0],1):(S=!0,0)}function b(e){
|
||||
const t=e[0],n=i.substr(e.index),s=h(y,e,n);if(!s)return P;const r=y
|
||||
;r.skip?M+=t:(r.returnEnd||r.excludeEnd||(M+=t),u(),r.excludeEnd&&(M=t));do{
|
||||
y.className&&k.closeNode(),y.skip||y.subLanguage||(L+=y.relevance),y=y.parent
|
||||
}while(y!==s.parent)
|
||||
;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),
|
||||
g(s.starts)),r.returnEnd?0:t.length}let x={};function E(t,r){const a=r&&r[0]
|
||||
;if(M+=t,null==a)return u(),0
|
||||
;if("begin"===x.type&&"end"===r.type&&x.index===r.index&&""===a){
|
||||
if(M+=i.slice(r.index,r.index+1),!o){const t=Error("0 width match regex")
|
||||
;throw t.languageName=e,t.badRule=x.rule,t}return 1}
|
||||
if(x=r,"begin"===r.type)return function(e){
|
||||
const t=e[0],n=e.rule,r=new s(n),a=[n.__beforeBegin,n["on:begin"]]
|
||||
;for(const n of a)if(n&&(n(e,r),r.ignore))return f(t)
|
||||
;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),
|
||||
n.skip?M+=t:(n.excludeBegin&&(M+=t),
|
||||
u(),n.returnBegin||n.excludeBegin||(M=t)),g(n),n.returnBegin?0:t.length}(r)
|
||||
;if("illegal"===r.type&&!n){
|
||||
const e=Error('Illegal lexeme "'+a+'" for mode "'+(y.className||"<unnamed>")+'"')
|
||||
;throw e.mode=y,e}if("end"===r.type){const e=b(r);if(e!==P)return e}
|
||||
if("illegal"===r.type&&""===a)return 1
|
||||
;if(j>1e5&&j>3*r.index)throw Error("potential infinite loop, way more iterations than matches")
|
||||
;return M+=a,a.length}const _=v(e);if(!_)throw console.error(c.replace("{}",e)),
|
||||
Error('Unknown language: "'+e+'"');const w=O(_);let N="",y=a||w
|
||||
;const R={},k=new d.__emitter(d);(()=>{const e=[]
|
||||
;for(let t=y;t!==_;t=t.parent)t.className&&e.unshift(t.className)
|
||||
;e.forEach((e=>k.openNode(e)))})();let M="",L=0,A=0,j=0,S=!1;try{
|
||||
for(y.matcher.considerAll();;){
|
||||
j++,S?S=!1:y.matcher.considerAll(),y.matcher.lastIndex=A
|
||||
;const e=y.matcher.exec(i);if(!e)break;const t=E(i.substring(A,e.index),e)
|
||||
;A=e.index+t}return E(i.substr(A)),k.closeAllNodes(),k.finalize(),N=k.toHTML(),{
|
||||
relevance:L,value:N,language:e,illegal:!1,emitter:k,top:y}}catch(t){
|
||||
if(t.message&&t.message.includes("Illegal"))return{illegal:!0,illegalBy:{
|
||||
msg:t.message,context:i.slice(A-100,A+100),mode:t.mode},sofar:N,relevance:0,
|
||||
value:I(i),emitter:k};if(o)return{illegal:!1,relevance:0,value:I(i),emitter:k,
|
||||
language:e,top:y,errorRaised:t};throw t}}function m(e,t){
|
||||
t=t||d.languages||Object.keys(r);const n=(e=>{const t={relevance:0,
|
||||
emitter:new d.__emitter(d),value:I(e),illegal:!1,top:u}
|
||||
;return t.emitter.addText(e),t})(e),s=t.filter(v).filter(w).map((t=>p(t,e,!1)))
|
||||
;s.unshift(n);const a=s.sort(((e,t)=>{
|
||||
if(e.relevance!==t.relevance)return t.relevance-e.relevance
|
||||
;if(e.language&&t.language){if(v(e.language).supersetOf===t.language)return 1
|
||||
;if(v(t.language).supersetOf===e.language)return-1}return 0})),[i,o]=a,l=i
|
||||
;return l.second_best=o,l}function b(e){
|
||||
return d.tabReplace||d.useBR?e.replace(l,(e=>"\n"===e?d.useBR?"<br>":e:d.tabReplace?e.replace(/\t/g,d.tabReplace):e)):e
|
||||
}function x(e){let t=null;const n=(e=>{let t=e.className+" "
|
||||
;t+=e.parentNode?e.parentNode.className:"";const n=d.languageDetectRe.exec(t)
|
||||
;if(n){const t=v(n[1])
|
||||
;return t||(console.warn(c.replace("{}",n[1])),console.warn("Falling back to no-highlight mode for this block.",e)),
|
||||
t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||v(e)))})(e)
|
||||
;if(h(n))return;N("before:highlightBlock",{block:e,language:n
|
||||
}),d.useBR?(t=document.createElement("div"),
|
||||
t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n")):t=e
|
||||
;const s=t.textContent,r=n?f(n,s,!0):m(s),i=T(t);if(i.length){
|
||||
const e=document.createElement("div");e.innerHTML=r.value,r.value=B(i,T(e),s)}
|
||||
r.value=b(r.value),N("after:highlightBlock",{block:e,result:r
|
||||
}),e.innerHTML=r.value,e.className=((e,t,n)=>{const s=t?a[t]:n,r=[e.trim()]
|
||||
;return e.match(/\bhljs\b/)||r.push("hljs"),
|
||||
e.includes(s)||r.push(s),r.join(" ").trim()
|
||||
})(e.className,n,r.language),e.result={language:r.language,re:r.relevance,
|
||||
relavance:r.relevance},r.second_best&&(e.second_best={
|
||||
language:r.second_best.language,re:r.second_best.relevance,
|
||||
relavance:r.second_best.relevance})}const E=()=>{if(E.called)return;E.called=!0
|
||||
;const e=document.querySelectorAll("pre code");n.forEach.call(e,x)}
|
||||
;function v(e){return e=(e||"").toLowerCase(),r[e]||r[a[e]]}
|
||||
function _(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{a[e]=t
|
||||
}))}function w(e){const t=v(e);return t&&!t.disableAutodetect}function N(e,t){
|
||||
const n=e;i.forEach((e=>{e[n]&&e[n](t)}))}Object.assign(e,{highlight:f,
|
||||
highlightAuto:m,
|
||||
fixMarkup:e=>(console.warn("fixMarkup is deprecated and will be removed entirely in v11.0"),
|
||||
console.warn("Please see https://github.com/highlightjs/highlight.js/issues/2534"),
|
||||
b(e)),highlightBlock:x,configure:e=>{
|
||||
e.useBR&&(console.warn("'useBR' option is deprecated and will be removed entirely in v11.0"),
|
||||
console.warn("Please see https://github.com/highlightjs/highlight.js/issues/2559")),
|
||||
d=S(d,e)},initHighlighting:E,initHighlightingOnLoad:()=>{
|
||||
window.addEventListener("DOMContentLoaded",E,!1)},registerLanguage:(t,n)=>{
|
||||
let s=null;try{s=n(e)}catch(e){
|
||||
if(console.error("Language definition for '{}' could not be registered.".replace("{}",t)),
|
||||
!o)throw e;console.error(e),s=u}
|
||||
s.name||(s.name=t),r[t]=s,s.rawDefinition=n.bind(null,e),
|
||||
s.aliases&&_(s.aliases,{languageName:t})},listLanguages:()=>Object.keys(r),
|
||||
getLanguage:v,registerAliases:_,requireLanguage:e=>{
|
||||
console.warn("requireLanguage is deprecated and will be removed entirely in the future."),
|
||||
console.warn("Please see https://github.com/highlightjs/highlight.js/pull/2844")
|
||||
;const t=v(e);if(t)return t
|
||||
;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},
|
||||
autoDetection:w,inherit:S,addPlugin:e=>{i.push(e)},vuePlugin:j(e).VuePlugin
|
||||
}),e.debugMode=()=>{o=!1},e.safeMode=()=>{o=!0},e.versionString="10.4.1"
|
||||
;for(const e in k)"object"==typeof k[e]&&t(k[e]);return Object.assign(e,k),e
|
||||
})({})}()
|
||||
;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);hljs.registerLanguage("xml",(()=>{"use strict";function e(e){
|
||||
return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
|
||||
function a(...n){return n.map((n=>e(n))).join("")}function s(...n){
|
||||
return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
|
||||
const t=a(/[A-Z_]/,a("(",/[A-Z0-9_.-]+:/,")?"),/[A-Z0-9_.-]*/),i={
|
||||
className:"symbol",begin:"&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;"},c={begin:"\\s",
|
||||
contains:[{className:"meta-keyword",begin:"#?[a-z_][a-z1-9_-]+",illegal:"\\n"}]
|
||||
},r=e.inherit(c,{begin:"\\(",end:"\\)"}),l=e.inherit(e.APOS_STRING_MODE,{
|
||||
className:"meta-string"}),g=e.inherit(e.QUOTE_STRING_MODE,{
|
||||
className:"meta-string"}),m={endsWithParent:!0,illegal:/</,relevance:0,
|
||||
contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{
|
||||
begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{
|
||||
begin:/"/,end:/"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{
|
||||
begin:/[^\s"'=<>`]+/}]}]}]};return{name:"HTML, XML",
|
||||
aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],
|
||||
case_insensitive:!0,contains:[{className:"meta",begin:"<![a-z]",end:">",
|
||||
relevance:10,contains:[c,g,l,r,{begin:"\\[",end:"\\]",contains:[{
|
||||
className:"meta",begin:"<![a-z]",end:">",contains:[c,r,g,l]}]}]
|
||||
},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<!\\[CDATA\\[",
|
||||
end:"\\]\\]>",relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,
|
||||
relevance:10},{className:"tag",begin:"<style(?=\\s|>)",end:">",keywords:{
|
||||
name:"style"},contains:[m],starts:{end:"</style>",returnEnd:!0,
|
||||
subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>)",end:">",
|
||||
keywords:{name:"script"},contains:[m],starts:{end:/<\/script>/,returnEnd:!0,
|
||||
subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/
|
||||
},{className:"tag",begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{
|
||||
className:"name",begin:t,relevance:0,starts:m}]},{className:"tag",
|
||||
begin:a(/<\//,n(a(t,/>/))),contains:[{className:"name",begin:t,relevance:0},{
|
||||
begin:/>/,relevance:0}]}]}}})());hljs.registerLanguage("css",(()=>{"use strict";return e=>{
|
||||
var n="[a-zA-Z-][a-zA-Z0-9_-]*",a={
|
||||
begin:/([*]\s?)?(?:[A-Z_.\-\\]+|--[a-zA-Z0-9_-]+)\s*(\/\*\*\/)?:/,
|
||||
returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",
|
||||
begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,
|
||||
contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",
|
||||
begin:/[\w-]+/},{begin:/\(/,end:/\)/,
|
||||
contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]
|
||||
},e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{
|
||||
className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]
|
||||
}}]};return{name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,
|
||||
contains:[e.C_BLOCK_COMMENT_MODE,{className:"selector-id",
|
||||
begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:"\\."+n},{
|
||||
className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",
|
||||
contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},{className:"selector-pseudo",
|
||||
begin:/:(:)?[a-zA-Z0-9_+()"'.-]+/},{begin:"@(page|font-face)",
|
||||
lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",
|
||||
illegal:/:/,returnBegin:!0,contains:[{className:"keyword",
|
||||
begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,
|
||||
relevance:0,keywords:"and or not only",contains:[{begin:/[a-z-]+:/,
|
||||
className:"attribute"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]
|
||||
}]},{className:"selector-tag",begin:n,relevance:0},{begin:/\{/,end:/\}/,
|
||||
illegal:/\S/,contains:[e.C_BLOCK_COMMENT_MODE,{begin:/;/},a]}]}}})());hljs.registerLanguage("javascript",(()=>{"use strict"
|
||||
;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],s=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
|
||||
;function r(e){return i("(?=",e,")")}function i(...e){return e.map((e=>{
|
||||
return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return t=>{
|
||||
const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,
|
||||
isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,s=e.input[a]
|
||||
;"<"!==s?">"===s&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
|
||||
;return-1!==e.input.indexOf(a,n)})(e,{after:a
|
||||
})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n.join(" "),
|
||||
literal:a.join(" "),built_in:s.join(" ")
|
||||
},b="\\.([0-9](_?[0-9])*)",g="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",d={
|
||||
className:"number",variants:[{
|
||||
begin:`(\\b(${g})((${b})|\\.)?|(${b}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{
|
||||
begin:`\\b(${g})\\b((${b})\\b|\\.)?|(${b})\\b`},{
|
||||
begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
|
||||
begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
|
||||
begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
|
||||
begin:"\\b0[0-7]+n?\\b"}],relevance:0},E={className:"subst",begin:"\\$\\{",
|
||||
end:"\\}",keywords:l,contains:[]},u={begin:"html`",end:"",starts:{end:"`",
|
||||
returnEnd:!1,contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},_={
|
||||
begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
|
||||
contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"css"}},m={className:"string",
|
||||
begin:"`",end:"`",contains:[t.BACKSLASH_ESCAPE,E]},N={className:"comment",
|
||||
variants:[t.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",
|
||||
begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",end:"\\}",relevance:0
|
||||
},{className:"variable",begin:c+"(?=\\s*(-)|$)",endsParent:!0,relevance:0},{
|
||||
begin:/(?=[^\n])\s/,relevance:0}]}]
|
||||
}),t.C_BLOCK_COMMENT_MODE,t.C_LINE_COMMENT_MODE]
|
||||
},y=[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,u,_,m,d,t.REGEXP_MODE]
|
||||
;E.contains=y.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(y)
|
||||
});const f=[].concat(N,E.contains),A=f.concat([{begin:/\(/,end:/\)/,keywords:l,
|
||||
contains:["self"].concat(f)}]),p={className:"params",begin:/\(/,end:/\)/,
|
||||
excludeBegin:!0,excludeEnd:!0,keywords:l,contains:A};return{name:"Javascript",
|
||||
aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:A},
|
||||
illegal:/#(?![$_A-z])/,contains:[t.SHEBANG({label:"shebang",binary:"node",
|
||||
relevance:5}),{label:"use_strict",className:"meta",relevance:10,
|
||||
begin:/^\s*['"]use (strict|asm)['"]/
|
||||
},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,u,_,m,N,d,{
|
||||
begin:i(/[{,\n]\s*/,r(i(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))),
|
||||
relevance:0,contains:[{className:"attr",begin:c+r("\\s*:"),relevance:0}]},{
|
||||
begin:"("+t.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
|
||||
keywords:"return throw case",contains:[N,t.REGEXP_MODE,{className:"function",
|
||||
begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+t.UNDERSCORE_IDENT_RE+")\\s*=>",
|
||||
returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{
|
||||
begin:t.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0
|
||||
},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:A}]}]
|
||||
},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{
|
||||
variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag,
|
||||
end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0,
|
||||
contains:["self"]}]}],relevance:0},{className:"function",
|
||||
beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l,
|
||||
contains:["self",t.inherit(t.TITLE_MODE,{begin:c}),p],illegal:/%/},{
|
||||
beginKeywords:"while if switch catch for"},{className:"function",
|
||||
begin:t.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
|
||||
returnBegin:!0,contains:[p,t.inherit(t.TITLE_MODE,{begin:c})]},{variants:[{
|
||||
begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class",
|
||||
beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{
|
||||
beginKeywords:"extends"},t.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,
|
||||
end:/[{;]/,excludeEnd:!0,contains:[t.inherit(t.TITLE_MODE,{begin:c}),"self",p]
|
||||
},{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set",
|
||||
contains:[t.inherit(t.TITLE_MODE,{begin:c}),{begin:/\(\)/},p]},{begin:/\$[(.]/}]
|
||||
}}})());hljs.registerLanguage("elm",(()=>{"use strict";return e=>{const n={
|
||||
variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
|
||||
className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},s={begin:"\\(",end:"\\)",
|
||||
illegal:'"',contains:[{className:"type",
|
||||
begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},n]};return{name:"Elm",
|
||||
keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",
|
||||
contains:[{beginKeywords:"port effect module",end:"exposing",
|
||||
keywords:"port effect module where command subscription exposing",
|
||||
contains:[s,n],illegal:"\\W\\.|;"},{begin:"import",end:"$",
|
||||
keywords:"import as exposing",contains:[s,n],illegal:"\\W\\.|;"},{begin:"type",
|
||||
end:"$",keywords:"type alias",contains:[i,s,{begin:/\{/,end:/\}/,
|
||||
contains:s.contains},n]},{beginKeywords:"infix infixl infixr",end:"$",
|
||||
contains:[e.C_NUMBER_MODE,n]},{begin:"port",end:"$",keywords:"port",contains:[n]
|
||||
},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."
|
||||
},e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,i,e.inherit(e.TITLE_MODE,{
|
||||
begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}],illegal:/;/}}})());
|
BIN
docs/public/images/logo.png
Normal file
After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
BIN
docs/public/images/screenshot.png
Normal file
After Width: | Height: | Size: 255 KiB |
20
docs/public/index.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nunito:ital,wght@0,300;0,600;0,800;1,300&family=Nunito+Sans:wght@800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.5.0/build/styles/a11y-dark.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="https://nope.rhg.dev/dist/3.0.0/core.min.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="shortcut icon" href="/favicon.png" type="image/x-png">
|
||||
</head>
|
||||
<body>
|
||||
<script src="/highlight.pack.js"></script>
|
||||
<script src="/dist/elm.js"></script>
|
||||
<script src="/dist/flags.js"></script>
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
102
docs/public/main.js
Normal file
@ -0,0 +1,102 @@
|
||||
const app = Elm.Main.init({ flags: window.__FLAGS__ })
|
||||
|
||||
// Handle smoothly scrolling to links
|
||||
const scrollToHash = () => {
|
||||
const element = window.location.hash && document.querySelector(window.location.hash)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
} else {
|
||||
window.scroll({ behavior: 'smooth', top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
app.ports.onUrlChange.subscribe(_ => setTimeout(scrollToHash, 400))
|
||||
setTimeout(scrollToHash, 200)
|
||||
|
||||
// Quick search shortcut (/)
|
||||
window.addEventListener('keypress', (e) => {
|
||||
if (e.key === '/') {
|
||||
const el = document.getElementById('quick-search')
|
||||
if (el && el !== document.activeElement) {
|
||||
el.focus()
|
||||
el.select()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// HighlightJS custom element
|
||||
customElements.define('highlight-js', class HighlightJS extends HTMLElement {
|
||||
constructor () { super() }
|
||||
connectedCallback () {
|
||||
const pre = document.createElement('pre')
|
||||
const code = document.createElement('code')
|
||||
|
||||
pre.className = `language-elm`
|
||||
code.innerText = this.body
|
||||
|
||||
pre.appendChild(code)
|
||||
|
||||
this.appendChild(pre)
|
||||
window.hljs.highlightBlock(pre)
|
||||
}
|
||||
})
|
||||
|
||||
// Dropdown arrow key support
|
||||
customElements.define('dropdown-arrow-keys', class DropdownArrowKeys extends HTMLElement {
|
||||
constructor () {
|
||||
super()
|
||||
}
|
||||
connectedCallback () {
|
||||
const component = this
|
||||
const arrows = { ArrowUp: -1, ArrowDown: 1 }
|
||||
const interactiveChildren = () => component.querySelectorAll('input, a, button')
|
||||
|
||||
const onBlur = (e) => window.requestAnimationFrame(_ => {
|
||||
const active = document.activeElement
|
||||
const siblings = interactiveChildren()
|
||||
let foundFocusedSibling = false
|
||||
|
||||
e.target.removeEventListener('blur', onBlur)
|
||||
|
||||
siblings.forEach(sibling => {
|
||||
if (sibling === active) {
|
||||
sibling.addEventListener('blur', onBlur)
|
||||
foundFocusedSibling = true
|
||||
}
|
||||
})
|
||||
if (foundFocusedSibling === false) {
|
||||
component.dispatchEvent(new CustomEvent('clearDropdown'))
|
||||
siblings.forEach(el => el.addEventListener('focus', _ => el.addEventListener('blur', onBlur)))
|
||||
}
|
||||
})
|
||||
|
||||
interactiveChildren().forEach(el => el.addEventListener('blur', onBlur))
|
||||
|
||||
component.addEventListener('keydown', (e) => {
|
||||
const delta = arrows[e.key]
|
||||
if (delta) {
|
||||
e.preventDefault()
|
||||
const interactive = interactiveChildren()
|
||||
const count = interactive.length
|
||||
const active = document.activeElement
|
||||
if (count < 2) return
|
||||
|
||||
interactive.forEach((el, i) => {
|
||||
if (active == el) {
|
||||
const next = interactive[(i + delta + count) % count]
|
||||
next.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
const el = document.getElementById('quick-search')
|
||||
if (e.key === 'Escape' && el === document.activeElement) {
|
||||
if (el) el.blur()
|
||||
}
|
||||
})
|
426
docs/public/style.css
Normal file
@ -0,0 +1,426 @@
|
||||
:root {
|
||||
--font--display: 'Nunito Sans', sans-serif;
|
||||
--font--body: 'Nunito', sans-serif;
|
||||
--font--monospace: 'Fira Code', monospace;
|
||||
|
||||
--weight--light: 300;
|
||||
--weight--semibold: 600;
|
||||
--weight--bold: 800;
|
||||
|
||||
--color--white: #ffffff;
|
||||
--color--grey-100: #f0f0f0;
|
||||
--color--grey-200: #cccccc;
|
||||
--color--grey-300: #aaaaaa;
|
||||
--color--grey-500: #696969;
|
||||
--color--grey-700: #333333;
|
||||
--color--green: #407742;
|
||||
--color--green-light: #d7ead8;
|
||||
|
||||
--size--h1: 4em;
|
||||
--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);
|
||||
}
|
||||
|
||||
|
||||
/* Resets */
|
||||
@media screen and (min-width: 1920px ) {
|
||||
html { font-size: 20px; }
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color--grey-100);
|
||||
color: var(--color--grey-700);
|
||||
font-family: var(--font--body);
|
||||
font-weight: var(--weight--light);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
* {
|
||||
outline-color: dodgerblue;
|
||||
outline-offset: 0.25em;
|
||||
}
|
||||
|
||||
input {
|
||||
line-height: normal;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font--monospace);
|
||||
font-variant-ligatures: normal;
|
||||
}
|
||||
|
||||
pre {
|
||||
line-height: 1.45;
|
||||
border: solid 1px var(--color--grey-200);
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
main .markdown {
|
||||
animation: fadeIn 200ms 300ms ease-in forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
hr { border: 0; }
|
||||
|
||||
.container { max-width: 64em; }
|
||||
|
||||
/* Typography */
|
||||
|
||||
.h1 {
|
||||
font-family: var(--font--display);
|
||||
font-weight: var(--weight--bold);
|
||||
font-size: var(--size--h1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
font-family: var(--font--display);
|
||||
font-weight: var(--weight--bold);
|
||||
font-size: var(--size--h2);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.h3 {
|
||||
font-family: var(--font--display);
|
||||
font-weight: var(--weight--bold);
|
||||
font-size: var(--size--h3);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.h4 {
|
||||
font-family: var(--font--body);
|
||||
font-weight: var(--weight--bold);
|
||||
font-size: var(--size--h4);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.h5 {
|
||||
font-family: var(--font--body);
|
||||
font-weight: var(--weight--light);
|
||||
font-size: var(--size--h5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.h6 {
|
||||
font-family: var(--font--body);
|
||||
font-weight: var(--weight--light);
|
||||
font-size: var(--size--h6);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.p, li {
|
||||
font-family: var(--font--body);
|
||||
font-weight: var(--weight--light);
|
||||
font-size: var(--size--paragraph);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
|
||||
.markdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 36em;
|
||||
}
|
||||
|
||||
.markdown > h1:not(:first-child),
|
||||
.markdown > h2:not(:first-child),
|
||||
.markdown > h3:not(:first-child) {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.markdown > h4:not(:first-child) {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown > h1:first-child { margin-bottom: 2rem }
|
||||
|
||||
.markdown > *:not(:last-child) { margin-bottom: 1.2rem }
|
||||
|
||||
.markdown code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown table code {
|
||||
white-space: pre;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown p code {
|
||||
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 blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: solid 4px var(--color--green);
|
||||
background-color: var(--color--white);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color--grey-500);
|
||||
}
|
||||
|
||||
.markdown blockquote::before {
|
||||
content: 'Note:';
|
||||
display: block;
|
||||
/* font-size: var(--size--paragraph); */
|
||||
line-height: 1.4;
|
||||
|
||||
padding-bottom: 0.25em;
|
||||
font-weight: bold;
|
||||
color: var(--color--grey-700);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.markdown ul, .markdown ol {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
position: relative;
|
||||
border-color: var(--color--grey-700);
|
||||
box-shadow: inset 0 0.5em 1.5em rgba(0, 0, 0, 0.4);
|
||||
text-shadow: 0 0.125em 0.5em rgba(0, 0, 0, 0.5);
|
||||
background-color: var(--color--grey-700);
|
||||
color: var(--color--grey-100);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.markdown pre.language-terminal {
|
||||
position: relative;
|
||||
padding-left: 2.25em;
|
||||
}
|
||||
|
||||
.markdown pre.language-terminal::before {
|
||||
font-family: var(--font--monospace);
|
||||
content: '$ ';
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
left: 1em;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.markdown .table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background-color: var(--color--white);
|
||||
border-color: var(--color--grey-200);
|
||||
}
|
||||
|
||||
.markdown tr {
|
||||
border-color: var(--color--grey-200);
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
padding: 0.75em;
|
||||
}
|
||||
|
||||
.markdown tbody tr:nth-child(2n + 1) {
|
||||
background-color: var(--color--grey-100);
|
||||
}
|
||||
|
||||
.markdown__link:hover::after {
|
||||
content: ' 🔗';
|
||||
display: inline-block;
|
||||
font-size: 0.5em;
|
||||
transform: translate(50%, -25%);
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: var(--weight--bold);
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
|
||||
.text-100 { color: var(--color--grey-100) }
|
||||
.text-500 { color: var(--color--grey-500) }
|
||||
.text-700 { color: var(--color--grey-700) }
|
||||
.text-blue { color: var(--color--green) }
|
||||
|
||||
.bg-white { background-color: var(--color--white) }
|
||||
.bg-100 { background-color: var(--color--grey-100) }
|
||||
.bg-500 { background-color: var(--color--grey-500) }
|
||||
.bg-700 { background-color: var(--color--grey-700) }
|
||||
|
||||
.border-left {
|
||||
border-left: solid 3px var(--color--grey-200);
|
||||
}
|
||||
|
||||
.border-thin {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Links & Buttons */
|
||||
.link, .underline {
|
||||
border-bottom: solid 2px var(--color--grey-200);
|
||||
transition: border 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.dropdown__link {
|
||||
padding: 1rem;
|
||||
transition: background-color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.dropdown__link:focus .underline,
|
||||
.dropdown__link:hover .underline {
|
||||
border-color: var(--color--green);
|
||||
}
|
||||
|
||||
.dropdown__link:hover,
|
||||
.dropdown__link:focus {
|
||||
background-color: var(--color--green-light);
|
||||
}
|
||||
|
||||
.dropdown__link strong {
|
||||
color: var(--color--green);
|
||||
}
|
||||
|
||||
.markdown__link:hover {
|
||||
border-bottom: solid 2px var(--color--grey-200);
|
||||
}
|
||||
|
||||
.link:hover, .link:focus {
|
||||
border-color: var(--color--green);
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Images & Icons */
|
||||
|
||||
.logo {
|
||||
background-image: url('/images/logo.svg');
|
||||
background-size: contain;
|
||||
min-width: 5em;
|
||||
min-height: 5em;
|
||||
}
|
||||
|
||||
.logo--small {
|
||||
min-width: 1.5em;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.logo__text {
|
||||
white-space: nowrap;
|
||||
font-weight: var(--weight--bold);
|
||||
font-family: var(--font--display);
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search__input {
|
||||
border-radius: 1.25em;
|
||||
min-width: 0;
|
||||
width: 15em;
|
||||
padding: 0.5em 1em;
|
||||
padding-right: 3.25em;
|
||||
padding-left: 2.75em;
|
||||
border: 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.search__icon {
|
||||
/* display: none; */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0em;
|
||||
transform: translate(1em, -50%);
|
||||
}
|
||||
|
||||
.search__kbd {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0em;
|
||||
transform: translate(-1.5em, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
padding: 0.5em 0.7em;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
color: var(--color--grey-300);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font--monospace);
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.icon--search {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><ellipse cx="40" cy="40" rx="30" ry="30" fill="none" stroke="rgb(180, 180, 180)" stroke-width="10" /><line x1="66" y1="66" x2="85" y2="85" stroke-linecap="round" stroke="rgb(180, 180, 180)" stroke-width="10" /></svg>');
|
||||
}
|
||||
|
||||
.link__icon {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.fa-elm {
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 323.141 322.95"><g><polygon fill="rgb(51,51,51)" points="161.649,152.782 231.514,82.916 91.783,82.916"/><polygon fill="rgb(51,51,51)" points="8.867,0 79.241,70.375 232.213,70.375 161.838,0"/><rect fill="rgb(51,51,51)" x="192.99" y="107.392" transform="matrix(0.7071 0.7071 -0.7071 0.7071 186.4727 -127.2386)" width="107.676" height="108.167"/><polygon fill="rgb(51,51,51)" points="323.298,143.724 323.298,0 179.573,0"/><polygon fill="rgb(51,51,51)" points="152.781,161.649 0,8.868 0,314.432"/><polygon fill="rgb(51,51,51)" points="255.522,246.655 323.298,314.432 323.298,178.879"/><polygon fill="rgb(51,51,51)" points="161.649,170.517 8.869,323.298 314.43,323.298"/> </g> </svg>');
|
||||
}
|
||||
|
||||
.link__icon.fa-npm:hover {
|
||||
color: indianred;
|
||||
}
|
||||
|
||||
.link__icon.fa-github:hover {
|
||||
color: mediumseagreen;
|
||||
}
|
||||
|
||||
.link__icon.fa-elm:hover {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 323.141 322.95"><g><polygon fill="dodgerblue" points="161.649,152.782 231.514,82.916 91.783,82.916"/><polygon fill="dodgerblue" points="8.867,0 79.241,70.375 232.213,70.375 161.838,0"/><rect fill="dodgerblue" x="192.99" y="107.392" transform="matrix(0.7071 0.7071 -0.7071 0.7071 186.4727 -127.2386)" width="107.676" height="108.167"/><polygon fill="dodgerblue" points="323.298,143.724 323.298,0 179.573,0"/><polygon fill="dodgerblue" points="152.781,161.649 0,8.868 0,314.432"/><polygon fill="dodgerblue" points="255.522,246.655 323.298,314.432 323.298,178.879"/><polygon fill="dodgerblue" points="161.649,170.517 8.869,323.298 314.43,323.298"/> </g> </svg>');
|
||||
}
|
||||
|
||||
.shadow { box-shadow: var(--shadow) }
|
||||
.shadow-dark { box-shadow: var(--shadow-dark);}
|
||||
.rounded { border-radius: 5px; }
|
||||
.faint { opacity: 0.6; }
|
||||
|
||||
.toc {
|
||||
padding: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
61
docs/scripts/generate-index.js
Normal file
@ -0,0 +1,61 @@
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
const config = {
|
||||
content: path.join(__dirname, '..', 'public', 'content'),
|
||||
output: path.join(__dirname, '..', 'public', 'dist')
|
||||
}
|
||||
|
||||
// Recursively lists all files in the given folder
|
||||
const listContainedFiles = async (folder) => {
|
||||
let files = []
|
||||
const items = await fs.readdir(folder)
|
||||
|
||||
await Promise.all(items.map(async item => {
|
||||
const filepath = path.join(folder, item)
|
||||
const stat = await fs.stat(filepath)
|
||||
if (stat.isDirectory()) {
|
||||
const innerFiles = await listContainedFiles(filepath)
|
||||
files = files.concat(innerFiles)
|
||||
} else {
|
||||
files.push(filepath)
|
||||
}
|
||||
}))
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// The entrypoint to my script
|
||||
const main = () =>
|
||||
listContainedFiles(config.content)
|
||||
.then(files =>
|
||||
Promise.all(files.map(async f => {
|
||||
const url = f.substring(config.content.length, f.length - '.md'.length)
|
||||
const content = await fs.readFile(f, { encoding: 'utf-8' })
|
||||
const headers =
|
||||
content.split('\n')
|
||||
.reduce((acc, line) => {
|
||||
if (line.startsWith('# ')) {
|
||||
acc[line.substring(2)] = 1
|
||||
} else if (line.startsWith('## ')) {
|
||||
acc[line.substring(3)] = 2
|
||||
} else if (line.startsWith('### ')) {
|
||||
acc[line.substring(4)] = 3
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return { url, headers }
|
||||
}))
|
||||
)
|
||||
.then(json => `window.__FLAGS__ = ${JSON.stringify(json, null, 2)}`)
|
||||
.then(async contents => {
|
||||
await fs.mkdir(config.output, { recursive: true })
|
||||
return fs.writeFile(path.join(config.output, 'flags.js'), contents, { encoding: 'utf-8' })
|
||||
})
|
||||
.then(_ => console.info(`\n ✓ Indexed the content folder\n`))
|
||||
.catch(console.error)
|
||||
|
||||
// Run the program
|
||||
main()
|
83
docs/src/Domain/Index.elm
Normal file
@ -0,0 +1,83 @@
|
||||
module Domain.Index exposing
|
||||
( Index, decoder
|
||||
, Link, search
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Index, decoder
|
||||
@docs Link, search
|
||||
|
||||
-}
|
||||
|
||||
import Dict exposing (Dict)
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as Json
|
||||
import Utils.String
|
||||
|
||||
|
||||
type alias Index =
|
||||
List IndexedPage
|
||||
|
||||
|
||||
decoder : Json.Decoder Index
|
||||
decoder =
|
||||
let
|
||||
indexedPageDecoder : Json.Decoder IndexedPage
|
||||
indexedPageDecoder =
|
||||
Json.map2 IndexedPage
|
||||
(Json.field "url" Json.string)
|
||||
(Json.field "headers" (Json.dict Json.int))
|
||||
in
|
||||
Json.list indexedPageDecoder
|
||||
|
||||
|
||||
type alias IndexedPage =
|
||||
{ url : String
|
||||
, headers : Dict String Int
|
||||
}
|
||||
|
||||
|
||||
type alias Link =
|
||||
{ html : Html Never
|
||||
, label : String
|
||||
, url : String
|
||||
, level : Int
|
||||
}
|
||||
|
||||
|
||||
terms : Index -> List ( String, String, Int )
|
||||
terms =
|
||||
List.concatMap
|
||||
(\page ->
|
||||
page.headers
|
||||
|> Dict.toList
|
||||
|> List.map
|
||||
(\( header, level ) ->
|
||||
( header
|
||||
, page.url
|
||||
++ (if level == 1 then
|
||||
""
|
||||
|
||||
else
|
||||
"#" ++ Utils.String.toId header
|
||||
)
|
||||
, level
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
search : String -> Index -> List Link
|
||||
search query index =
|
||||
index
|
||||
|> terms
|
||||
|> List.map
|
||||
(\( label, url, level ) ->
|
||||
{ label = label
|
||||
, url = url
|
||||
, level = level
|
||||
, html = Utils.String.format query label
|
||||
}
|
||||
)
|
||||
|> List.filter (\link -> Utils.String.caseInsensitiveContains query link.label)
|
154
docs/src/Main.elm
Normal file
@ -0,0 +1,154 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Nav exposing (Key)
|
||||
import Gen.Pages as Pages
|
||||
import Gen.Route as Route
|
||||
import Ports
|
||||
import Request exposing (Request)
|
||||
import Shared
|
||||
import Url exposing (Url)
|
||||
import View
|
||||
|
||||
|
||||
main : Program Shared.Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
, onUrlChange = ChangedUrl
|
||||
, onUrlRequest = ClickedLink
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ url : Url
|
||||
, key : Key
|
||||
, shared : Shared.Model
|
||||
, page : Pages.Model
|
||||
}
|
||||
|
||||
|
||||
init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
( shared, sharedCmd ) =
|
||||
Shared.init (request { url = url, key = key }) flags
|
||||
|
||||
( page, pageCmd, sharedPageCmd ) =
|
||||
Pages.init (Route.fromUrl url) shared url key
|
||||
in
|
||||
( Model url key shared page
|
||||
, Cmd.batch
|
||||
[ Cmd.map Shared sharedCmd
|
||||
, Cmd.map Shared sharedPageCmd
|
||||
, Cmd.map Page pageCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= ChangedUrl Url
|
||||
| ClickedLink Browser.UrlRequest
|
||||
| Shared Shared.Msg
|
||||
| Page Pages.Msg
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ClickedLink (Browser.Internal url) ->
|
||||
( model
|
||||
, if url.path == model.url.path then
|
||||
Nav.replaceUrl model.key (Url.toString url)
|
||||
|
||||
else
|
||||
Nav.pushUrl model.key (Url.toString url)
|
||||
)
|
||||
|
||||
ClickedLink (Browser.External url) ->
|
||||
( model
|
||||
, Nav.load url
|
||||
)
|
||||
|
||||
ChangedUrl url ->
|
||||
if url.path == model.url.path then
|
||||
( { model | url = url }
|
||||
, Ports.onUrlChange ()
|
||||
)
|
||||
|
||||
else
|
||||
let
|
||||
( page, pageCmd, sharedPageCmd ) =
|
||||
Pages.init (Route.fromUrl url) model.shared url model.key
|
||||
in
|
||||
( { model | url = url, page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Shared sharedPageCmd
|
||||
, Ports.onUrlChange ()
|
||||
]
|
||||
)
|
||||
|
||||
Shared sharedMsg ->
|
||||
let
|
||||
( shared, sharedCmd ) =
|
||||
Shared.update (request model) sharedMsg model.shared
|
||||
in
|
||||
( { model | shared = shared }
|
||||
, Cmd.map Shared sharedCmd
|
||||
)
|
||||
|
||||
Page pageMsg ->
|
||||
let
|
||||
( page, pageCmd, sharedPageCmd ) =
|
||||
Pages.update pageMsg model.page model.shared model.url model.key
|
||||
in
|
||||
( { model | page = page }
|
||||
, Cmd.batch
|
||||
[ Cmd.map Page pageCmd
|
||||
, Cmd.map Shared sharedPageCmd
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Browser.Document Msg
|
||||
view model =
|
||||
Pages.view model.page model.shared model.url model.key
|
||||
|> View.map Page
|
||||
|> View.toBrowserDocument
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ Pages.subscriptions model.page model.shared model.url model.key |> Sub.map Page
|
||||
, Shared.subscriptions (request model) 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/Guide.elm
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
19
docs/src/Pages/Guide/Section_.elm
Normal file
@ -0,0 +1,19 @@
|
||||
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/Guide/Section_/Article_.elm
Normal file
@ -0,0 +1,19 @@
|
||||
module Pages.Guide.Section_.Article_ 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
|
52
docs/src/Pages/Home_.elm
Normal file
@ -0,0 +1,52 @@
|
||||
module Pages.Home_ exposing (Model, Msg, page)
|
||||
|
||||
import Gen.Params.Home_ exposing (Params)
|
||||
import Page exposing (Page)
|
||||
import Request exposing (Request)
|
||||
import Shared
|
||||
import UI
|
||||
import UI.Layout
|
||||
import Url exposing (Url)
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
page : Shared.Model -> Request Params -> Page Model Msg
|
||||
page =
|
||||
UI.Layout.page
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
UI.Layout.Model
|
||||
|
||||
|
||||
type alias Msg =
|
||||
UI.Layout.Msg
|
||||
|
||||
|
||||
view : View Msg
|
||||
view =
|
||||
{ title = "elm-spa"
|
||||
, body =
|
||||
[ UI.hero
|
||||
{ title = "elm-spa"
|
||||
, description = "single page apps made easy"
|
||||
}
|
||||
, UI.markdown { withHeaderLinks = False } """
|
||||
## Build reliable applications.
|
||||
|
||||
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?
|
||||
|
||||
Bippity boppity, my guy.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Effortless routing.
|
||||
|
||||
Use `elm-spa` to automatically wire up routes and pages.
|
||||
"""
|
||||
]
|
||||
}
|
38
docs/src/Pages/NotFound.elm
Normal file
@ -0,0 +1,38 @@
|
||||
module Pages.NotFound exposing (Model, Msg, page)
|
||||
|
||||
import Gen.Params.NotFound exposing (Params)
|
||||
import Page exposing (Page)
|
||||
import Request exposing (Request)
|
||||
import Shared
|
||||
import UI
|
||||
import UI.Layout
|
||||
import Url exposing (Url)
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
page : Shared.Model -> Request Params -> Page Model Msg
|
||||
page =
|
||||
UI.Layout.page
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
UI.Layout.Model
|
||||
|
||||
|
||||
type alias Msg =
|
||||
UI.Layout.Msg
|
||||
|
||||
|
||||
view : View Msg
|
||||
view =
|
||||
{ title = "404 · elm-spa"
|
||||
, body =
|
||||
[ UI.hero
|
||||
{ title = "404"
|
||||
, description = "that page wasn't found."
|
||||
}
|
||||
, UI.markdown { withHeaderLinks = False } "### Well, that's a shame...\n\nHow about the [homepage?](/)"
|
||||
]
|
||||
}
|
6
docs/src/Ports.elm
Normal file
@ -0,0 +1,6 @@
|
||||
port module Ports exposing (onUrlChange)
|
||||
|
||||
import Json.Decode as Json
|
||||
|
||||
|
||||
port onUrlChange : () -> Cmd msg
|
63
docs/src/Shared.elm
Normal file
@ -0,0 +1,63 @@
|
||||
module Shared exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, subscriptions
|
||||
, update
|
||||
)
|
||||
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Dict exposing (Dict)
|
||||
import Domain.Index exposing (Index)
|
||||
import Json.Decode as Json
|
||||
import Request exposing (Request)
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
Json.Value
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ index : Index
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= NoOp
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
init : Request () -> Flags -> ( Model, Cmd Msg )
|
||||
init _ flags =
|
||||
( Model
|
||||
(flags
|
||||
|> Json.decodeValue Domain.Index.decoder
|
||||
|> Result.withDefault []
|
||||
)
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
update : Request () -> Msg -> Model -> ( Model, Cmd Msg )
|
||||
update request msg model =
|
||||
case msg of
|
||||
NoOp ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Request () -> Model -> Sub Msg
|
||||
subscriptions request model =
|
||||
Sub.none
|
358
docs/src/UI.elm
Normal file
@ -0,0 +1,358 @@
|
||||
module UI exposing
|
||||
( Html, none, row, col
|
||||
, h1, h2, h3, h4, h5, h6, markdown
|
||||
, pad, padX, padY, align
|
||||
, link
|
||||
, logo, icons, iconLink
|
||||
, gutter, hero
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Html, none, el, row, col
|
||||
@docs h1, h2, h3, h4, h5, h6, markdown
|
||||
@docs pad, padX, padY, align
|
||||
@docs link
|
||||
@docs logo, icons, iconLink
|
||||
|
||||
-}
|
||||
|
||||
import Html
|
||||
import Html.Attributes as Attr
|
||||
import Html.Keyed
|
||||
import Json.Encode as Json
|
||||
import Markdown.Block
|
||||
import Markdown.Html
|
||||
import Markdown.Parser
|
||||
import Markdown.Renderer
|
||||
import UI.Searchbar
|
||||
import Url exposing (Url)
|
||||
import Utils.String
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type alias Html msg =
|
||||
Html.Html msg
|
||||
|
||||
|
||||
none : Html msg
|
||||
none =
|
||||
Html.text ""
|
||||
|
||||
|
||||
link : { text : String, url : String } -> Html msg
|
||||
link options =
|
||||
link_
|
||||
{ destination = options.url
|
||||
, title = Nothing
|
||||
}
|
||||
[ Html.text options.text
|
||||
]
|
||||
|
||||
|
||||
link_ : { destination : String, title : Maybe String } -> List (Html msg) -> Html msg
|
||||
link_ options =
|
||||
Html.a
|
||||
([ Attr.class "link", Attr.href options.destination ]
|
||||
++ (if String.startsWith "http" options.destination then
|
||||
[ Attr.target "_blank"
|
||||
]
|
||||
|
||||
else
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- TYPOGRAPHY
|
||||
|
||||
|
||||
h1 : String -> Html msg
|
||||
h1 str =
|
||||
Html.h1 [ Attr.class "h1" ] [ Html.text str ]
|
||||
|
||||
|
||||
h2 : String -> Html msg
|
||||
h2 str =
|
||||
Html.h2 [ Attr.class "h2" ] [ Html.text str ]
|
||||
|
||||
|
||||
h3 : String -> Html msg
|
||||
h3 str =
|
||||
Html.h3 [ Attr.class "h3" ] [ Html.text str ]
|
||||
|
||||
|
||||
h4 : String -> Html msg
|
||||
h4 str =
|
||||
Html.h4 [ Attr.class "h4" ] [ Html.text str ]
|
||||
|
||||
|
||||
h5 : String -> Html msg
|
||||
h5 str =
|
||||
Html.h5 [ Attr.class "h5" ] [ Html.text str ]
|
||||
|
||||
|
||||
h6 : String -> Html msg
|
||||
h6 str =
|
||||
Html.h6 [ Attr.class "h6" ] [ Html.text str ]
|
||||
|
||||
|
||||
paragraphs : List String -> Html msg
|
||||
paragraphs strs =
|
||||
strs
|
||||
|> List.map (Html.text >> List.singleton >> Html.p [ Attr.class "p" ])
|
||||
|> Html.div [ Attr.class "col gap-md" ]
|
||||
|
||||
|
||||
gutter : Html msg
|
||||
gutter =
|
||||
Html.div [ Attr.style "height" "25vh" ] []
|
||||
|
||||
|
||||
markdown : { withHeaderLinks : Bool } -> String -> Html msg
|
||||
markdown options str =
|
||||
let
|
||||
default =
|
||||
Markdown.Renderer.defaultHtmlRenderer
|
||||
|
||||
renderer =
|
||||
{ default
|
||||
| heading =
|
||||
\props ->
|
||||
let
|
||||
id : String
|
||||
id =
|
||||
Utils.String.toId props.rawText
|
||||
|
||||
content : List (Html msg)
|
||||
content =
|
||||
contentWith ("#" ++ id)
|
||||
|
||||
contentWith : String -> List (Html msg)
|
||||
contentWith url =
|
||||
if options.withHeaderLinks then
|
||||
[ Html.a [ Attr.class "markdown__link", Attr.href url ] props.children ]
|
||||
|
||||
else
|
||||
props.children
|
||||
in
|
||||
case props.level of
|
||||
Markdown.Block.H1 ->
|
||||
Html.h1 [ Attr.id id, Attr.class "h1" ] (contentWith "")
|
||||
|
||||
Markdown.Block.H2 ->
|
||||
Html.h2 [ Attr.id id, Attr.class "h2" ] content
|
||||
|
||||
Markdown.Block.H3 ->
|
||||
Html.h3 [ Attr.id id, Attr.class "h3" ] content
|
||||
|
||||
Markdown.Block.H4 ->
|
||||
Html.h4 [ Attr.id id, Attr.class "h4" ] content
|
||||
|
||||
Markdown.Block.H5 ->
|
||||
Html.h5 [ Attr.id id, Attr.class "h5" ] content
|
||||
|
||||
Markdown.Block.H6 ->
|
||||
Html.h6 [ Attr.class "h6" ] content
|
||||
, paragraph = Html.p [ Attr.class "p" ]
|
||||
, table = \children -> Html.div [ Attr.class "table" ] [ Html.table [] children ]
|
||||
, link = link_
|
||||
, codeBlock =
|
||||
\{ body, language } ->
|
||||
if language == Just "elm" then
|
||||
Html.Keyed.node "div"
|
||||
[]
|
||||
[ ( body
|
||||
, Html.node "highlight-js"
|
||||
[ Attr.property "body" (Json.string body)
|
||||
, Attr.property "language" (Json.string "elm")
|
||||
]
|
||||
[]
|
||||
)
|
||||
]
|
||||
|
||||
else
|
||||
Html.pre [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
|
||||
[ Html.code [ Attr.class ("language-" ++ (language |> Maybe.withDefault "none")) ]
|
||||
[ Html.text body ]
|
||||
]
|
||||
}
|
||||
in
|
||||
Markdown.Parser.parse str
|
||||
|> Result.mapError (\_ -> "Failed to parse.")
|
||||
|> Result.andThen (Markdown.Renderer.render renderer)
|
||||
|> Result.withDefault []
|
||||
|> Html.div [ Attr.class "markdown" ]
|
||||
|
||||
|
||||
|
||||
-- LAYOUT
|
||||
|
||||
|
||||
row :
|
||||
{ xs : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, sm : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, md : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, lg : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, xl : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
}
|
||||
row =
|
||||
{ xs = \attrs -> Html.div (Attr.class "row gap-xs" :: attrs)
|
||||
, sm = \attrs -> Html.div (Attr.class "row gap-sm" :: attrs)
|
||||
, md = \attrs -> Html.div (Attr.class "row gap-md" :: attrs)
|
||||
, lg = \attrs -> Html.div (Attr.class "row gap-lg" :: attrs)
|
||||
, xl = \attrs -> Html.div (Attr.class "row gap-xl" :: attrs)
|
||||
}
|
||||
|
||||
|
||||
col :
|
||||
{ xs : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, sm : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, md : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, lg : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
, xl : List (Attribute msg) -> List (Html msg) -> Html msg
|
||||
}
|
||||
col =
|
||||
{ xs = \attrs -> Html.div (Attr.class "col gap-xs" :: attrs)
|
||||
, sm = \attrs -> Html.div (Attr.class "col gap-sm" :: attrs)
|
||||
, md = \attrs -> Html.div (Attr.class "col gap-md" :: attrs)
|
||||
, lg = \attrs -> Html.div (Attr.class "col gap-lg" :: attrs)
|
||||
, xl = \attrs -> Html.div (Attr.class "col gap-xl" :: attrs)
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- ATTRS
|
||||
|
||||
|
||||
type alias Attribute msg =
|
||||
Html.Attribute msg
|
||||
|
||||
|
||||
pad :
|
||||
{ xs : Attribute msg
|
||||
, sm : Attribute msg
|
||||
, md : Attribute msg
|
||||
, lg : Attribute msg
|
||||
, xl : Attribute msg
|
||||
}
|
||||
pad =
|
||||
{ xs = Attr.class "pad-xs"
|
||||
, sm = Attr.class "pad-sm"
|
||||
, md = Attr.class "pad-md"
|
||||
, lg = Attr.class "pad-lg"
|
||||
, xl = Attr.class "pad-xl"
|
||||
}
|
||||
|
||||
|
||||
padX :
|
||||
{ xs : Attribute msg
|
||||
, sm : Attribute msg
|
||||
, md : Attribute msg
|
||||
, lg : Attribute msg
|
||||
, xl : Attribute msg
|
||||
}
|
||||
padX =
|
||||
{ xs = Attr.class "pad-x-xs"
|
||||
, sm = Attr.class "pad-x-sm"
|
||||
, md = Attr.class "pad-x-md"
|
||||
, lg = Attr.class "pad-x-lg"
|
||||
, xl = Attr.class "pad-x-xl"
|
||||
}
|
||||
|
||||
|
||||
padY :
|
||||
{ xs : Attribute msg
|
||||
, sm : Attribute msg
|
||||
, md : Attribute msg
|
||||
, lg : Attribute msg
|
||||
, xl : Attribute msg
|
||||
}
|
||||
padY =
|
||||
{ xs = Attr.class "pad-y-xs"
|
||||
, sm = Attr.class "pad-y-sm"
|
||||
, md = Attr.class "pad-y-md"
|
||||
, lg = Attr.class "pad-y-lg"
|
||||
, xl = Attr.class "pad-y-xl"
|
||||
}
|
||||
|
||||
|
||||
align :
|
||||
{ center : Attribute msg
|
||||
, top : Attribute msg
|
||||
, left : Attribute msg
|
||||
, right : Attribute msg
|
||||
, bottom : Attribute msg
|
||||
, centerX : Attribute msg
|
||||
, centerY : Attribute msg
|
||||
}
|
||||
align =
|
||||
{ center = Attr.class "align-center"
|
||||
, top = Attr.class "align-top"
|
||||
, left = Attr.class "align-left"
|
||||
, right = Attr.class "align-right"
|
||||
, bottom = Attr.class "align-bottom"
|
||||
, centerX = Attr.class "align-center-x"
|
||||
, centerY = Attr.class "align-center-y"
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- HERO
|
||||
|
||||
|
||||
hero : { title : String, description : String } -> Html msg
|
||||
hero options =
|
||||
Html.div [ Attr.class "pad-y-xl" ]
|
||||
[ Html.div [ Attr.class "row gap-md pad-y-xl" ]
|
||||
[ Html.div [ Attr.class "logo" ] []
|
||||
, Html.div [ Attr.class "col gap-xs" ]
|
||||
[ h1 options.title
|
||||
, Html.div [ Attr.class "text-500" ] [ Html.h2 [ Attr.class "h5" ] [ Html.text options.description ] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- LOGO
|
||||
|
||||
|
||||
logo : Html msg
|
||||
logo =
|
||||
Html.div [ Attr.class "row gap-sm" ]
|
||||
[ Html.div [ Attr.class "logo logo--small" ] []
|
||||
, Html.div [ Attr.class "logo__text" ] [ Html.text "elm-spa" ]
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- ICONS
|
||||
|
||||
|
||||
type Icon
|
||||
= Icon String
|
||||
|
||||
|
||||
icons :
|
||||
{ github : Icon
|
||||
, npm : Icon
|
||||
, elm : Icon
|
||||
}
|
||||
icons =
|
||||
{ github = Icon "fa-github"
|
||||
, npm = Icon "fa-npm"
|
||||
, elm = Icon "fa-elm"
|
||||
}
|
||||
|
||||
|
||||
iconLink : { text : String, icon : Icon, url : String } -> Html msg
|
||||
iconLink options =
|
||||
let
|
||||
(Icon class) =
|
||||
options.icon
|
||||
in
|
||||
Html.a [ Attr.href options.url, Attr.target "_blank", Attr.attribute "aria-label" options.text ]
|
||||
[ Html.i [ Attr.class ("link__icon fab " ++ class) ] []
|
||||
]
|
144
docs/src/UI/Docs.elm
Normal file
@ -0,0 +1,144 @@
|
||||
module UI.Docs exposing (Model, Msg, page)
|
||||
|
||||
import Gen.Route as Route
|
||||
import Http
|
||||
import Page exposing (Page)
|
||||
import Request exposing (Request)
|
||||
import Shared
|
||||
import UI
|
||||
import UI.Layout
|
||||
import Url exposing (Url)
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
page : Shared.Model -> Request params -> Page Model Msg
|
||||
page shared req =
|
||||
Page.element
|
||||
{ init = init req.url
|
||||
, update = update
|
||||
, view = view shared req.url
|
||||
, subscriptions = \_ -> Sub.none
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ layout : UI.Layout.Model
|
||||
, markdown : Fetchable String
|
||||
}
|
||||
|
||||
|
||||
type Fetchable data
|
||||
= Loading
|
||||
| Success data
|
||||
| Failure String
|
||||
|
||||
|
||||
withDefault : value -> Fetchable value -> value
|
||||
withDefault fallback fetchable =
|
||||
case fetchable of
|
||||
Success value ->
|
||||
value
|
||||
|
||||
_ ->
|
||||
fallback
|
||||
|
||||
|
||||
toMaybe : Fetchable value -> Maybe value
|
||||
toMaybe fetchable =
|
||||
case fetchable of
|
||||
Success value ->
|
||||
Just value
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
init : Url -> ( Model, Cmd Msg )
|
||||
init url =
|
||||
( Model UI.Layout.init Loading
|
||||
, Http.get
|
||||
{ url = "/content" ++ url.path ++ ".md"
|
||||
, expect = Http.expectString GotMarkdown
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= Layout UI.Layout.Msg
|
||||
| GotMarkdown (Result Http.Error String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Layout layoutMsg ->
|
||||
( { model | layout = UI.Layout.update layoutMsg model.layout }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
GotMarkdown (Ok markdown) ->
|
||||
( { model | markdown = Success markdown }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
GotMarkdown (Err _) ->
|
||||
( { model | markdown = Failure "Couldn't find that section of the guide..." }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
valuew =
|
||||
Route.NotFound
|
||||
|
||||
|
||||
view : Shared.Model -> Url -> Model -> View Msg
|
||||
view shared url model =
|
||||
{ title =
|
||||
case model.markdown of
|
||||
Loading ->
|
||||
"elm-spa"
|
||||
|
||||
Success content ->
|
||||
let
|
||||
firstLine =
|
||||
content
|
||||
|> String.lines
|
||||
|> List.head
|
||||
|> Maybe.withDefault "Guide"
|
||||
in
|
||||
String.dropLeft 2 firstLine ++ " | elm-spa"
|
||||
|
||||
Failure _ ->
|
||||
"Uh oh. | elm-spa"
|
||||
, body =
|
||||
UI.Layout.viewDocumentation
|
||||
{ shared = shared
|
||||
, url = url
|
||||
, onMsg = Layout
|
||||
, model = model.layout
|
||||
}
|
||||
(withDefault "" model.markdown)
|
||||
[ case model.markdown of
|
||||
Loading ->
|
||||
UI.none
|
||||
|
||||
Failure reason ->
|
||||
UI.markdown { withHeaderLinks = False } ("# Uh oh.\n\n" ++ reason)
|
||||
|
||||
Success markdown ->
|
||||
UI.markdown { withHeaderLinks = True } markdown
|
||||
, UI.gutter
|
||||
]
|
||||
}
|
161
docs/src/UI/Layout.elm
Normal file
@ -0,0 +1,161 @@
|
||||
module UI.Layout exposing
|
||||
( Model, init
|
||||
, Msg, update
|
||||
, viewDefault, viewDocumentation
|
||||
, page
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Model, init
|
||||
@docs Msg, update
|
||||
@docs viewDefault, viewDocumentation
|
||||
|
||||
-}
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Html.Events as Events
|
||||
import Page exposing (Page, shared)
|
||||
import Request exposing (Request)
|
||||
import Shared
|
||||
import UI
|
||||
import UI.Searchbar
|
||||
import UI.Sidebar
|
||||
import Url exposing (Url)
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ query : String
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ query = ""
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= OnQueryChange String
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
OnQueryChange query ->
|
||||
{ model | query = query }
|
||||
|
||||
|
||||
viewDefault :
|
||||
{ model : Model
|
||||
, onMsg : Msg -> msg
|
||||
, shared : Shared.Model
|
||||
, url : Url
|
||||
}
|
||||
-> List (Html msg)
|
||||
-> List (Html msg)
|
||||
viewDefault options view =
|
||||
[ navbar options
|
||||
, Html.main_ [ Attr.class "container pad-x-md" ] view
|
||||
]
|
||||
|
||||
|
||||
viewDocumentation :
|
||||
{ model : Model
|
||||
, onMsg : Msg -> msg
|
||||
, shared : Shared.Model
|
||||
, url : Url
|
||||
}
|
||||
-> String
|
||||
-> List (Html msg)
|
||||
-> List (Html msg)
|
||||
viewDocumentation options markdownContent view =
|
||||
[ navbar options
|
||||
, Html.div [ Attr.class "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" ]
|
||||
[ UI.Sidebar.viewSidebar
|
||||
{ index = options.shared.index
|
||||
, url = options.url
|
||||
}
|
||||
]
|
||||
, Html.main_ [ Attr.class "col flex" ] view
|
||||
, Html.div [ Attr.class "hidden-mobile sticky pad-y-lg", Attr.style "width" "16em" ]
|
||||
[ UI.Sidebar.viewTableOfContents
|
||||
{ content = markdownContent
|
||||
, url = options.url
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
navbar :
|
||||
{ model : Model
|
||||
, onMsg : Msg -> msg
|
||||
, shared : Shared.Model
|
||||
, url : Url
|
||||
}
|
||||
-> Html msg
|
||||
navbar { onMsg, model, shared, url } =
|
||||
let
|
||||
navLink : { text : String, url : String } -> Html msg
|
||||
navLink options =
|
||||
Html.a
|
||||
[ Attr.class "link"
|
||||
, Attr.href options.url
|
||||
, Attr.classList [ ( "bold text-blue", String.startsWith options.url url.path ) ]
|
||||
]
|
||||
[ Html.text options.text ]
|
||||
in
|
||||
Html.header [ Attr.class "container pad-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.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" }
|
||||
]
|
||||
]
|
||||
, Html.div [ Attr.class "row gap-md spread" ]
|
||||
[ Html.nav [ Attr.class "row gap-md hidden-mobile" ]
|
||||
[ UI.iconLink { text = "GitHub Repo", icon = UI.icons.github, url = "https://github.com/ryannhg/elm-spa" }
|
||||
, UI.iconLink { text = "NPM Package", icon = UI.icons.npm, url = "https://npmjs.org/elm-spa" }
|
||||
, UI.iconLink { text = "Elm Package", icon = UI.icons.elm, url = "https://package.elm-lang.org/packages/ryannhg/elm-spa/latest" }
|
||||
]
|
||||
, UI.Searchbar.view
|
||||
{ index = shared.index
|
||||
, query = model.query
|
||||
, onQueryChange = onMsg << OnQueryChange
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- PAGE
|
||||
|
||||
|
||||
page : { view : View Msg } -> Shared.Model -> Request params -> Page Model Msg
|
||||
page options shared req =
|
||||
Page.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view =
|
||||
\model ->
|
||||
{ title = options.view.title
|
||||
, body =
|
||||
viewDefault
|
||||
{ shared = shared
|
||||
, url = req.url
|
||||
, model = model
|
||||
, onMsg = identity
|
||||
}
|
||||
options.view.body
|
||||
}
|
||||
}
|
68
docs/src/UI/Searchbar.elm
Normal file
@ -0,0 +1,68 @@
|
||||
module UI.Searchbar exposing (view)
|
||||
|
||||
import Domain.Index exposing (Index, Link)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Html.Events as Events
|
||||
import Json.Decode as Json
|
||||
|
||||
|
||||
view :
|
||||
{ index : Index
|
||||
, query : String
|
||||
, onQueryChange : String -> msg
|
||||
}
|
||||
-> Html msg
|
||||
view options =
|
||||
Html.node "dropdown-arrow-keys"
|
||||
[ Events.on "clearDropdown" (Json.succeed (options.onQueryChange ""))
|
||||
]
|
||||
[ Html.label [ Attr.class "search relative z-2", Attr.attribute "aria-label" "Search" ]
|
||||
[ Html.input
|
||||
[ Attr.id "quick-search"
|
||||
, Attr.class "search__input"
|
||||
, Attr.type_ "search"
|
||||
, Attr.placeholder "Search"
|
||||
, Attr.value options.query
|
||||
, Events.onInput options.onQueryChange
|
||||
]
|
||||
[]
|
||||
, Html.div [ Attr.class "search__icon icon icon--search" ] []
|
||||
, Html.kbd [ Attr.class "search__kbd" ] [ Html.text "/" ]
|
||||
, if String.length options.query > 2 then
|
||||
case Domain.Index.search options.query options.index of
|
||||
[] ->
|
||||
viewDropdownWindow
|
||||
[ Html.span [ Attr.class "faint pad-md" ] [ Html.text "No matches found." ]
|
||||
]
|
||||
|
||||
matches ->
|
||||
viewMatches matches
|
||||
|
||||
else
|
||||
Html.text ""
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewMatches : List Link -> Html msg
|
||||
viewMatches matches =
|
||||
viewDropdownWindow
|
||||
(matches
|
||||
|> List.sortBy (\link -> ( link.level, link.label |> String.length ))
|
||||
|> List.map
|
||||
(\match ->
|
||||
Html.a [ Attr.class "dropdown__link", Attr.href match.url ]
|
||||
[ Html.span [ Attr.class "underline" ] [ Html.map never match.html ]
|
||||
]
|
||||
)
|
||||
|> List.take 5
|
||||
)
|
||||
|
||||
|
||||
viewDropdownWindow : List (Html msg) -> Html msg
|
||||
viewDropdownWindow children =
|
||||
Html.div [ Attr.class "absolute align-below fill-x pad-top-md" ]
|
||||
[ Html.div [ Attr.class "col bg-white shadow border rounded" ]
|
||||
children
|
||||
]
|
196
docs/src/UI/Sidebar.elm
Normal file
@ -0,0 +1,196 @@
|
||||
module UI.Sidebar exposing (viewSidebar, viewTableOfContents)
|
||||
|
||||
import Domain.Index exposing (Index)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Markdown.Block
|
||||
import Markdown.Html
|
||||
import Markdown.Parser
|
||||
import Markdown.Renderer
|
||||
import UI
|
||||
import Url exposing (Url)
|
||||
import Utils.String
|
||||
|
||||
|
||||
sidebarSections : List Section
|
||||
sidebarSections =
|
||||
[ Section "Guide"
|
||||
"/guide"
|
||||
[ Link "Overview" "/guide"
|
||||
, Link "The CLI" "/guide/cli"
|
||||
, Link "Routing" "/guide/routing"
|
||||
, Link "Pages" "/guide/pages"
|
||||
, Link "Shared State" "/guide/shared-state"
|
||||
, Link "Requests" "/guide/requests"
|
||||
, Link "Views" "/guide/views"
|
||||
]
|
||||
, Section "Examples"
|
||||
"/guide"
|
||||
[ Link "User Authentication" "/guide/users"
|
||||
, Link "Elm UI" "/guide/apis"
|
||||
, Link "Page Transitions" "/guide/transitions"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
parseTableOfContents : String -> List Section
|
||||
parseTableOfContents =
|
||||
Markdown.Parser.parse
|
||||
>> Result.mapError (\_ -> "Failed to parse.")
|
||||
>> Result.andThen (Markdown.Renderer.render tableOfContentsRenderer)
|
||||
>> Result.withDefault []
|
||||
>> List.filterMap identity
|
||||
>> headersToSections
|
||||
|
||||
|
||||
type alias Section =
|
||||
{ header : String
|
||||
, url : String
|
||||
, links : List Link
|
||||
}
|
||||
|
||||
|
||||
type alias Link =
|
||||
{ name : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
|
||||
type alias Header =
|
||||
( HeaderLevel, String, Maybe String )
|
||||
|
||||
|
||||
type HeaderLevel
|
||||
= Heading2
|
||||
| Heading3
|
||||
|
||||
|
||||
headersToSections : List Header -> List Section
|
||||
headersToSections =
|
||||
let
|
||||
loop : Header -> ( List Section, Maybe Section ) -> ( List Section, Maybe Section )
|
||||
loop ( level, text, url_ ) ( sections, current ) =
|
||||
let
|
||||
url =
|
||||
url_ |> Maybe.map (Utils.String.toId >> (++) "#") |> Maybe.withDefault ""
|
||||
in
|
||||
case ( level, current ) of
|
||||
( Heading2, Just existing ) ->
|
||||
( sections ++ [ existing ], Just { header = text, url = url, links = [] } )
|
||||
|
||||
( Heading2, Nothing ) ->
|
||||
( sections, Just { header = text, url = url, links = [] } )
|
||||
|
||||
( Heading3, Just existing ) ->
|
||||
( sections, Just { existing | links = existing.links ++ [ { name = text, url = url } ] } )
|
||||
|
||||
( Heading3, Nothing ) ->
|
||||
( sections ++ [ { header = text, url = url, links = [] } ], Nothing )
|
||||
in
|
||||
List.foldl loop ( [], Nothing )
|
||||
>> (\( sections, maybe ) ->
|
||||
maybe
|
||||
|> Maybe.map (\section -> sections ++ [ section ])
|
||||
|> Maybe.withDefault sections
|
||||
)
|
||||
|
||||
|
||||
tableOfContentsRenderer : Markdown.Renderer.Renderer (Maybe Header)
|
||||
tableOfContentsRenderer =
|
||||
{ heading =
|
||||
\{ level, rawText } ->
|
||||
case level of
|
||||
Markdown.Block.H1 ->
|
||||
Just ( Heading2, rawText, Nothing )
|
||||
|
||||
Markdown.Block.H2 ->
|
||||
Just ( Heading2, rawText, Just rawText )
|
||||
|
||||
Markdown.Block.H3 ->
|
||||
Just ( Heading3, rawText, Just rawText )
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
, paragraph = \_ -> Nothing
|
||||
, blockQuote = \_ -> Nothing
|
||||
, html = Markdown.Html.oneOf []
|
||||
, text = \_ -> Nothing
|
||||
, codeSpan = \_ -> Nothing
|
||||
, strong = \_ -> Nothing
|
||||
, emphasis = \_ -> Nothing
|
||||
, hardLineBreak = Nothing
|
||||
, link = \_ _ -> Nothing
|
||||
, image = \_ -> Nothing
|
||||
, unorderedList = \_ -> Nothing
|
||||
, orderedList = \_ _ -> Nothing
|
||||
, codeBlock = \_ -> Nothing
|
||||
, thematicBreak = Nothing
|
||||
, table = \_ -> Nothing
|
||||
, tableHeader = \_ -> Nothing
|
||||
, tableBody = \_ -> Nothing
|
||||
, tableRow = \_ -> Nothing
|
||||
, tableCell = \_ _ -> Nothing
|
||||
, tableHeaderCell = \_ _ -> Nothing
|
||||
}
|
||||
|
||||
|
||||
viewSidebar : { url : Url, index : Index } -> Html msg
|
||||
viewSidebar { url } =
|
||||
let
|
||||
viewSidebarLink : Link -> Html msg
|
||||
viewSidebarLink link__ =
|
||||
viewDocumentationLink (url.path == link__.url) link__
|
||||
|
||||
viewSidebarSection : Section -> Html msg
|
||||
viewSidebarSection section =
|
||||
UI.col.sm []
|
||||
[ Html.h4 [ Attr.class "h4 bold" ] [ Html.text section.header ]
|
||||
, if List.isEmpty section.links then
|
||||
Html.text ""
|
||||
|
||||
else
|
||||
UI.col.md [ Attr.class "border-left pad-y-sm pad-x-md align-left" ] (List.map viewSidebarLink section.links)
|
||||
]
|
||||
in
|
||||
UI.col.md [] (List.map viewSidebarSection sidebarSections)
|
||||
|
||||
|
||||
viewDocumentationLink : Bool -> Link -> Html msg
|
||||
viewDocumentationLink isActive link__ =
|
||||
Html.a
|
||||
[ Attr.class "link"
|
||||
, Attr.classList [ ( "bold text-blue", isActive ) ]
|
||||
, Attr.href link__.url
|
||||
]
|
||||
[ Html.text link__.name ]
|
||||
|
||||
|
||||
viewTableOfContents : { url : Url, content : String } -> Html msg
|
||||
viewTableOfContents { url, content } =
|
||||
let
|
||||
viewTableOfContentsLink : Link -> Html msg
|
||||
viewTableOfContentsLink link__ =
|
||||
viewDocumentationLink (url.fragment == Nothing && link__.url == "" || (url.fragment |> Maybe.map ((++) "#")) == Just link__.url) link__
|
||||
|
||||
viewTocSection : Section -> Html msg
|
||||
viewTocSection section =
|
||||
Html.div [ Attr.class "col gap-xs align-left" ]
|
||||
[ viewTableOfContentsLink { name = section.header, url = section.url }
|
||||
, if List.isEmpty section.links then
|
||||
Html.text ""
|
||||
|
||||
else
|
||||
Html.div [ Attr.class "col pad-left-sm pad-xs gap-sm" ]
|
||||
(section.links
|
||||
|> List.map (\l -> Html.div [ Attr.class "h6" ] [ viewTableOfContentsLink l ])
|
||||
)
|
||||
]
|
||||
in
|
||||
if String.isEmpty content then
|
||||
Html.text ""
|
||||
|
||||
else
|
||||
Html.nav [ Attr.class "col gap-md align-left toc shadow rounded bg-white" ]
|
||||
[ Html.h4 [ Attr.class "h4 bold" ] [ Html.text "On this page" ]
|
||||
, Html.div [ Attr.class "col gap-md" ] (List.map viewTocSection (parseTableOfContents content))
|
||||
]
|
58
docs/src/Utils/String.elm
Normal file
@ -0,0 +1,58 @@
|
||||
module Utils.String exposing
|
||||
( caseInsensitiveContains
|
||||
, format
|
||||
, toId
|
||||
)
|
||||
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
caseInsensitiveContains : String -> String -> Bool
|
||||
caseInsensitiveContains sub word =
|
||||
String.contains (String.toLower sub) (String.toLower word)
|
||||
|
||||
|
||||
toId : String -> String
|
||||
toId =
|
||||
String.toLower
|
||||
>> String.words
|
||||
>> List.map (String.filter Char.isAlphaNum)
|
||||
>> String.join "-"
|
||||
|
||||
|
||||
format : String -> String -> Html msg
|
||||
format query original =
|
||||
original
|
||||
|> String.toLower
|
||||
|> String.split (String.toLower query)
|
||||
|> List.indexedMap Tuple.pair
|
||||
|> List.foldl
|
||||
(\( index, segment ) ( length, str ) ->
|
||||
let
|
||||
nextLength =
|
||||
length + String.length segment + String.length query
|
||||
in
|
||||
( nextLength
|
||||
, str
|
||||
++ [ original
|
||||
|> String.dropLeft length
|
||||
|> String.left (String.length segment)
|
||||
|> Html.text
|
||||
]
|
||||
++ (if nextLength > String.length original then
|
||||
[]
|
||||
|
||||
else
|
||||
[ original
|
||||
|> String.dropLeft (length + String.length segment)
|
||||
|> String.left (String.length query)
|
||||
|> Html.text
|
||||
|> List.singleton
|
||||
|> Html.strong []
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
( 0, [] )
|
||||
|> Tuple.second
|
||||
|> Html.span []
|
38
elm.json
@ -1,28 +1,18 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
"type": "package",
|
||||
"name": "ryannhg/elm-spa",
|
||||
"summary": "Single page apps made easy.",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "6.0.0",
|
||||
"exposed-modules": [
|
||||
"ElmSpa.Request",
|
||||
"ElmSpa.Page"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
"elm/browser": "1.0.0 <= v < 2.0.0",
|
||||
"elm/core": "1.0.0 <= v < 2.0.0",
|
||||
"elm/url": "1.0.0 <= v < 2.0.0"
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {
|
||||
"elm-explorations/test": "1.2.2"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/random": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
"test-dependencies": {}
|
||||
}
|
16
examples/01-hello-world/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# examples/01-hello-world
|
||||
> A web application made with [elm-spa](https://elm-spa.dev)
|
||||
|
||||
## running locally
|
||||
|
||||
```bash
|
||||
elm-spa server
|
||||
```
|
||||
|
||||
### 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!
|
||||
```
|
@ -1,7 +1,10 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
"src",
|
||||
".elm-spa/defaults",
|
||||
".elm-spa/generated",
|
||||
"../../src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
@ -10,13 +13,11 @@
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0",
|
||||
"rtfeldman/elm-css": "16.1.0"
|
||||
"elm/url": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"rtfeldman/elm-hex": "1.0.0"
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
@ -3,11 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- CSS goes here -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- JavaScript goes here -->
|
||||
<script src="/dist/elm.compiled.js"></script>
|
||||
<script src="/main.js"></script>
|
||||
<script src="/dist/elm.js"></script>
|
||||
<script> Elm.Main.init() </script>
|
||||
</body>
|
||||
</html>
|
11
examples/01-hello-world/src/Pages/Home_.elm
Normal file
@ -0,0 +1,11 @@
|
||||
module Pages.Home_ exposing (page)
|
||||
|
||||
import Html
|
||||
|
||||
|
||||
page =
|
||||
{ title = "Homepage"
|
||||
, body =
|
||||
[ Html.text "Hello, world!"
|
||||
]
|
||||
}
|
8
examples/elm-spa-dev/.gitignore
vendored
@ -1,8 +0,0 @@
|
||||
# Folders to ignore
|
||||
elm-stuff
|
||||
node_modules
|
||||
public/dist
|
||||
src/Spa/Generated
|
||||
|
||||
# MacOS weird stuff
|
||||
.DS_Store
|
@ -1,41 +0,0 @@
|
||||
# new elm-spa project
|
||||
> More documentation at https://elm-spa.dev
|
||||
|
||||
## local development
|
||||
|
||||
You can get this site up and running with one command:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
### other commands to know
|
||||
|
||||
There are a handful of commands in the [package.json](./package.json).
|
||||
|
||||
Command | Description
|
||||
:-- | :--
|
||||
`npm run dev` | Run a dev server and automatically build changes.
|
||||
`npm run test:watch` | Run tests as you code.
|
||||
`npm run build` | Build the site for production.
|
||||
`npm run test` | Run the test suite once, great for CI
|
||||
|
||||
|
||||
## deploying
|
||||
|
||||
After you run `npm run build`, the contents of the `public` folder can be hosted as a static site. If you haven't hosted a static site before, I'd recommend using [Netlify](https://netlify.com) (it's free!)
|
||||
|
||||
### using netlify
|
||||
|
||||
Add a `netlify.toml` file next to this README, for standard SPA routing:
|
||||
|
||||
```toml
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
```
|
||||
|
||||
__Build command:__ `npm run build`
|
||||
|
||||
__Publish directory:__ `public`
|
@ -1,34 +0,0 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0",
|
||||
"elm-explorations/markdown": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {
|
||||
"avh4/elm-program-test": "3.2.0",
|
||||
"elm-explorations/test": "1.2.2"
|
||||
},
|
||||
"indirect": {
|
||||
"avh4/elm-fifo": "1.0.4",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/random": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
[[redirects]]
|
||||
from = "/content/*"
|
||||
to = "/content/:splat"
|
||||
status = 200
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
2344
examples/elm-spa-dev/package-lock.json
generated
@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "our-elm-spa-app",
|
||||
"version": "1.0.0",
|
||||
"description": "A project created with elm-spa",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build:dev && npm run dev",
|
||||
"test": "elm-test",
|
||||
"test:watch": "elm-test --watch",
|
||||
"build": "run-s build:elm-spa build:elm build:sitemap",
|
||||
"build:dev": "run-s build:elm-spa build:dev:elm",
|
||||
"build:sitemap": "node sitemap.js",
|
||||
"dev": "run-p dev:elm-spa dev:elm",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output=public/dist/elm.compiled.js",
|
||||
"build:dev:elm": "elm make src/Main.elm --debug --output=public/dist/elm.compiled.js || true",
|
||||
"build:elm-spa": "elm-spa build .",
|
||||
"dev:elm": "elm-live src/Main.elm -u -d public -- --debug --output=public/dist/elm.compiled.js",
|
||||
"dev:elm-spa": "chokidar src/Pages -c \"elm-spa build .\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "2.1.0",
|
||||
"elm": "0.19.1-3",
|
||||
"elm-live": "4.0.2",
|
||||
"elm-spa": "next",
|
||||
"elm-test": "0.19.1-revision2",
|
||||
"npm-run-all": "4.1.5"
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
# Guide
|
||||
|
||||
Welcome to the __elm-spa__ guide! This guide assumes you are familiar with Elm and have checked out [the official guide](https://guide.elm-lang.org).
|
||||
|
||||
If you have any questions after reading this, please join us the `#elm-spa-users` channel in the [Elm Slack](https://elmlang.herokuapp.com/). There are also amazing folks in the `#beginners` channel that are happy to help with any Elm questions you may have.
|
||||
|
||||
## what is elm-spa?
|
||||
|
||||
Inspired by projects like [Nuxt.js](https://nuxtjs.org/), __elm-spa__ is:
|
||||
|
||||
1. A common __framework__ you can use across projects.
|
||||
1. A __CLI__ that eliminates boilerplate and routing code.
|
||||
1. This __website__, with guides on building SPAs in Elm.
|
||||
|
||||
If you've ever wondered how to call a REST API, handle user authentication, create components, or organize your application- __elm-spa__ is here to help.
|
||||
|
||||
Throughout this guide, [blue links](https://elm-lang.org) will take you to external resources from other sites, while [green links](/guide/getting-started) will take you to other pages on this site!
|
||||
|
||||
---
|
||||
|
||||
Ready to [get started](/guide/getting-started)?
|
@ -1,77 +0,0 @@
|
||||
# Authentication
|
||||
|
||||
User authentication can be handled in many ways! In this example, we'll define custom pages that:
|
||||
|
||||
1. Are only visible when a user is logged in
|
||||
1. Redirect to a sign in page otherwise.
|
||||
1. Still benefit from elm-spa's route generation!
|
||||
|
||||
Let's assume we have a `user : Maybe User` field in the `Shared.Model`, and have a sign-in page at `SignIn.elm`!
|
||||
|
||||
## Creating Custom Pages
|
||||
|
||||
Because `Spa/Page.elm` is in _your_ project, you can use it to define your own custom page functions.
|
||||
|
||||
As long as those functions return the same `Page` type, they are valid! The only downside is that they won't be available for `elm-spa add` command.
|
||||
|
||||
If your app involves user authentication, you could make a `protectedSandbox` page type that always gets a `User`, and redirects if one is missing:
|
||||
|
||||
```elm
|
||||
-- within Spa/Page.elm
|
||||
|
||||
protectedSandbox :
|
||||
{ init : User -> Url params -> model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page params (Maybe model) msg
|
||||
protectedSandbox options =
|
||||
{ init =
|
||||
\shared url ->
|
||||
case shared.user of
|
||||
Just user ->
|
||||
options.init user url |> Tuple.mapFirst Just
|
||||
|
||||
Nothing ->
|
||||
( Nothing
|
||||
, Nav.pushUrl url.key (Route.toString Route.SignIn)
|
||||
)
|
||||
, update = -- ... conditionally call options.update
|
||||
, view = -- ... conditionally call options.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = \_ shared -> shared
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
```
|
||||
|
||||
As long as you return a `Page` type, your page will work with the rest of elm-spa's automated routing!
|
||||
|
||||
### Usage
|
||||
|
||||
```elm
|
||||
-- (within an actual page)
|
||||
|
||||
type alias Model = Maybe SafeModel
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.protectedSandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : User -> Url Params -> SafeModel
|
||||
update : Msg -> SafeModel -> SafeModel
|
||||
view : SafeModel -> Document Msg
|
||||
```
|
||||
|
||||
One caveat is that the `Model` type exposed by your page is used by the generated code, so your actual model will need a different name (like `SafeModel`).
|
||||
|
||||
But now you know that these functions will only be called if the `User` is really logged in!
|
||||
|
||||
---
|
||||
|
||||
That's it! Swing by the [`#elm-spa-users`](https://elmlang.herokuapp.com/) channel and say hello!
|
@ -1,59 +0,0 @@
|
||||
# Beyond HTML
|
||||
|
||||
If you're not an CSS ninja, you may have experienced a bad time styling things on the web. Luckily, there's a __wonderful__ project in the Elm community called [Elm UI](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest/) that makes it possible to create UIs without any HTML or CSS at all!
|
||||
|
||||
When you create a page with `elm-spa init`, you can choose between the 3 popular options for building Elm applications:
|
||||
|
||||
- `html` - uses [elm/html](https://package.elm-lang.org/packages/elm/html/latest)
|
||||
- `elm-ui` - uses [mdgriffith/elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest)
|
||||
- `elm-css` - uses [rtfeldman/elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest)
|
||||
|
||||
```terminal
|
||||
elm-spa init my-project --template=elm-ui
|
||||
```
|
||||
|
||||
The `template` option scaffolds out the same starter project, except two files have been modified:
|
||||
|
||||
1. `elm.json` has the `mdgriffith/elm-ui` package installed.
|
||||
2. `Spa/Document.elm` uses `Element` instead of `Html`.
|
||||
|
||||
## Using Something Custom
|
||||
|
||||
Need something other than the three built-in options? Maybe your company is making a custom design system, and you don't want pages to return `Html` or `Element`, you would rather return a `Ui` type instead.
|
||||
|
||||
You can update `src/Spa/Document.elm` with your own custom view library, and the rest of the elm-spa features will still work.
|
||||
|
||||
Here's an example with a made up `Ui` library:
|
||||
|
||||
```elm
|
||||
module Spa.Document exposing (Document, map, toBrowserDocument)
|
||||
|
||||
import Browser
|
||||
import Ui exposing (Ui)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Ui msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Ui.map fn) doc.body
|
||||
}
|
||||
|
||||
|
||||
toBrowserDocument : Document msg -> Browser.Document msg
|
||||
toBrowserDocument doc =
|
||||
{ title = doc.title
|
||||
, body = List.map Ui.toHtml doc.body
|
||||
}
|
||||
```
|
||||
|
||||
As long as your library can implement those three exposed functions, you're all set. Your pages can all use your awesome view package!
|
||||
|
||||
---
|
||||
|
||||
Next up, we'll take a look at [Authentication](/guide/authentication)
|
@ -1,194 +0,0 @@
|
||||
# Components
|
||||
|
||||
In Elm, components don't have to be complicated! In fact, most of the time, you can use boring functions:
|
||||
|
||||
```elm
|
||||
module Components.Footer exposing (view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class)
|
||||
|
||||
|
||||
view : Html msg
|
||||
view =
|
||||
footer [ class "footer" ] [ text "built with elm-spa" ]
|
||||
```
|
||||
|
||||
## Passing in data
|
||||
|
||||
If you have data you need to display in a component, you can pass them in as arguments:
|
||||
|
||||
```elm
|
||||
module Components.Navbar exposing (view)
|
||||
|
||||
import Api.User exposing (User)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes as Attr exposing (class)
|
||||
import Spa.Generated.Route as Route
|
||||
|
||||
|
||||
view : { user : Maybe User } -> Html msg
|
||||
view options =
|
||||
header [ class "navbar" ]
|
||||
[ a [ href Route.Top ] [ text "Home" ]
|
||||
, a [ href Route.NotFound ] [ text "Not found" ]
|
||||
, case options.user of
|
||||
Just user -> button [] [ text "Sign out" ]
|
||||
Nothing -> button [] [ text "Sign in" ]
|
||||
]
|
||||
|
||||
|
||||
href : Route -> Html.Attribute msg
|
||||
href route =
|
||||
Attr.href (Route.toString route)
|
||||
```
|
||||
|
||||
## Handling messages
|
||||
|
||||
What's the easiest way to make a component reusable? Pass in the messages it sends! Rather than giving it it's own hardcoded `Msg` type, pass in the `msg` as an argument.
|
||||
|
||||
This enables the caller to decide how to handle events from components, and makes it easier to test component functions without needing to mock the entire application.
|
||||
|
||||
```elm
|
||||
import Html.Events as Events
|
||||
|
||||
view :
|
||||
{ user : Maybe User
|
||||
, onSignIn : msg
|
||||
, onSignOut : msg
|
||||
}
|
||||
-> Html msg
|
||||
view options =
|
||||
header [ class "navbar" ]
|
||||
[ a [ href Route.Top ] [ text "Home" ]
|
||||
, a [ href Route.NotFound ] [ text "Not found" ]
|
||||
, case options.user of
|
||||
Just _ ->
|
||||
button [ Events.onClick options.onSignOut ]
|
||||
[ text "Sign out" ]
|
||||
Nothing ->
|
||||
button [ Events.onClick options.onSignIn ]
|
||||
[ text "Sign in" ]
|
||||
]
|
||||
```
|
||||
|
||||
## Fancy Components
|
||||
|
||||
In JavaScript frameworks like React or Vue.js, it's common to have a component track its own data, view, and handle updates to that view. In Elm, we _could_ follow that methodology with `Model/Msg` and `init/update/view`, but it's not ideal.
|
||||
|
||||
Unlike in JS, our `view` functions can only return one type of `msg`. This means using `Html.map` and `Cmd.map` every time you want to use a component. That can become a mess when you begin nesting components!
|
||||
|
||||
Modules should be [built around data structures](https://www.youtube.com/watch?v=XpDsk374LDE), and it's easier to reuse functions rather than nesting `update` functions:
|
||||
|
||||
### Making a Carousel Component
|
||||
|
||||
Let's outline the high-level API for the component, we'll provide the complete implementation later!
|
||||
|
||||
```elm
|
||||
module Components.Carousel exposing
|
||||
( Carousel
|
||||
, create
|
||||
, next, previous, select
|
||||
, view
|
||||
)
|
||||
|
||||
type Carousel slide
|
||||
|
||||
create : slide -> List slide -> Carousel slide
|
||||
|
||||
next : Carousel slide -> Carousel slide
|
||||
previous : Carousel slide -> Carousel slide
|
||||
select : Int -> Carousel slide -> Carousel slide
|
||||
|
||||
view :
|
||||
{ carousel : Carousel slide
|
||||
, onNext : msg
|
||||
, onPrevious : msg
|
||||
, onSelect : Int -> msg
|
||||
, viewSlide : slide -> Html msg
|
||||
}
|
||||
-> Html msg
|
||||
```
|
||||
|
||||
The above example shows a file that provides:
|
||||
|
||||
1. A new data structure– `Carousel`
|
||||
1. Functions to update that structure:
|
||||
`next`, `previous`, and `select`
|
||||
1. The way to `view` that structure
|
||||
|
||||
The implementation for `Carousel` isn't exposed, so callers won't break if you change it later. If you'd like to see the full Carousel implementation, [here it is](https://gist.github.com/ryannhg/b26c0d6a5d2bfd74643e7da6543c5170).
|
||||
|
||||
### Using a Carousel Component
|
||||
|
||||
Here's how you might call it in a page:
|
||||
|
||||
```elm
|
||||
import Components.Carousel as Carousel exposing (Carousel)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ testimonials : Carousel Testimonial
|
||||
}
|
||||
|
||||
type alias Testimonial =
|
||||
{ quote : String
|
||||
, author : String
|
||||
}
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ testimonials =
|
||||
Carousel.create
|
||||
{ quote = "Cats have ears", author = "Ryan" }
|
||||
[ { quote = "Dogs also have ears", author = "Alexa" }
|
||||
, { quote = "I have ears", author = "Erik" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= NextTestimonial
|
||||
| PreviousTestimonial
|
||||
| SelectTestimonial Int
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
NextTestimonial ->
|
||||
{ model | testimonials = Carousel.next model.testimonials }
|
||||
|
||||
PreviousTestimonial ->
|
||||
{ model | testimonials = Carousel.previous model.testimonials }
|
||||
|
||||
SelectTestimonial index ->
|
||||
{ model | testimonials = Carousel.select index model.testimonials }
|
||||
```
|
||||
|
||||
```elm
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "page" ]
|
||||
[ Carousel.view
|
||||
{ carousel = model.testimonials
|
||||
, onNext = NextTestimonial
|
||||
, onPrevious = PreviousTestimonial
|
||||
, onSelect = SelectTestimonial
|
||||
, viewSlide = viewTestimonial
|
||||
}
|
||||
]
|
||||
|
||||
viewTestimonial : Testimonial -> Html msg
|
||||
viewTestimonial options =
|
||||
div [ class "testimonial" ]
|
||||
[ p [ class "quote" ] [ text options.quote ]
|
||||
, p [ class "author" ] [ text options.author ]
|
||||
]
|
||||
```
|
||||
|
||||
Just like before, we pass our `msg` types into the component, rather than give them their own special `Msg` types. Let your page handle those updates and your code will be much easier to read.
|
||||
|
||||
---
|
||||
|
||||
Next, let's talk about [using APIs](/guide/using-apis)
|
@ -1,95 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
Getting started with __elm-spa__ is easy! Make sure you have the latest stable version of [NodeJS](https://nodejs.org/en/) installed on your system. At the time of writing, that's version `12.18.2`.
|
||||
|
||||
```terminal
|
||||
npx elm-spa init
|
||||
```
|
||||
|
||||
After choosing your folder name and UI library, you can enter the new folder and run:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
That's it- your new SPA will be live at `http://localhost:8000`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
This one-time command will create a new project in a folder called `our-elm-spa`. Here's an overview of that folder:
|
||||
|
||||
```
|
||||
elm.json
|
||||
package.json
|
||||
|
||||
public/
|
||||
├─ index.html
|
||||
├─ main.js
|
||||
└─ style.css
|
||||
|
||||
src/
|
||||
├─ Pages/
|
||||
| ├─ Top.elm
|
||||
| └─ NotFound.elm
|
||||
├─ Spa/
|
||||
| ├─ Document.elm
|
||||
| ├─ Page.elm
|
||||
| └─ Url.elm
|
||||
├─ Main.elm
|
||||
└─ Shared.elm
|
||||
|
||||
tests/
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
### The project folder
|
||||
|
||||
There are a few interesting things in the project folder:
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`elm.json` | Defines all of our Elm project dependencies.
|
||||
`package.json` | Has `build`, `dev`, and `test` scripts so anyone with [NodeJS](https://nodejs.org) installed can easily run our project.
|
||||
`src/` | Where our frontend Elm application lives.
|
||||
`tests/` | Where our Elm tests live.
|
||||
`public/` | A static directory for serving HTML, JS, CSS, images, and more!
|
||||
|
||||
### The `src` Folder
|
||||
|
||||
The `src` folder will contain all your Elm code:
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`Pages/Top.elm` | The homepage for our single page application.
|
||||
`Pages/NotFound.elm` | The page to show if we're at an invalid route.
|
||||
`Spa/Document.elm` | The kind of thing each page's `view` returns (changing this allows support for [elm-ui](https://github.com/mdgriffith/elm-ui) or [elm-css](https://github.com/rtfeldman/elm-css))
|
||||
`Spa/Page.elm` | Defines the four page types (`static`, `sandbox`, `element`, and `application`)
|
||||
`Spa/Url.elm` | Defines a type that holds route parameters, query parameters (automatically passed into each page)
|
||||
`Main.elm` | The entrypoint to the app, that wires everything together.
|
||||
`Shared.elm` | The place to define layouts and shared data between pages.
|
||||
|
||||
### The `public` folder
|
||||
|
||||
The public folder is served statically. Use this folder to serve images, CSS, JS, and other static assets.
|
||||
|
||||
File | Description
|
||||
:-- | :--
|
||||
`index.html` | The HTML loaded by the server.
|
||||
`main.js` | The JS that starts our Elm single page application.
|
||||
`style.css` | A place to add in some CSS styles.
|
||||
|
||||
#### Using assets
|
||||
|
||||
Here are examples of how to access files in the public folder via URL:
|
||||
|
||||
File Location | URL
|
||||
:-- | :---
|
||||
`public/main.js` | `/main.js`
|
||||
`public/style.css` | `/style.css`
|
||||
`public/images/puppy.png` | `/images/puppy.png`
|
||||
|
||||
__Include the starting slash in your URL!__ If it's missing, it will look for your assets relative to the current URL, which means some pages will work and others won't. (`main.js` vs `/main.js`)
|
||||
|
||||
---
|
||||
|
||||
Next up is [Installation](/guide/installation), which will introduce the CLI.
|
@ -1,75 +0,0 @@
|
||||
# Installation
|
||||
|
||||
You can install `elm-spa` via [npm](https://nodejs.org/):
|
||||
|
||||
```terminal
|
||||
npm install -g elm-spa@latest
|
||||
```
|
||||
|
||||
Now, you can run `elm-spa` from the terminal!
|
||||
|
||||
## Hello, CLI
|
||||
|
||||
If you're ever stuck- run `elm-spa help`, the CLI comes with __built-in documentation__!
|
||||
|
||||
```terminal
|
||||
elm-spa help
|
||||
|
||||
elm-spa – version 5.0.0
|
||||
|
||||
elm-spa init – create a new project
|
||||
elm-spa add – add a new page
|
||||
elm-spa build – generate routes and pages automatically
|
||||
|
||||
elm-spa version – print version number
|
||||
```
|
||||
|
||||
## elm-spa init
|
||||
|
||||
The `init` command scaffolds a new __elm-spa__ project.
|
||||
|
||||
```terminal
|
||||
elm-spa init
|
||||
```
|
||||
|
||||
When you run the command, you will be presented with an interactive dialogue to choose between:
|
||||
|
||||
1. The UI Library ([elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest), [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest), or [html](https://package.elm-lang.org/packages/elm/html/latest))
|
||||
2. The folder name
|
||||
|
||||
Each project works and behaves the same way, but `elm.json`, `Spa.Document`, and the `Shared.view` are updated to use the UI library of your choice.
|
||||
|
||||
## elm-spa add
|
||||
|
||||
You can add more pages to an existing __elm-spa__ project with the `elm-spa add` command.
|
||||
|
||||
```terminal
|
||||
elm-spa add
|
||||
```
|
||||
|
||||
Just like the last command, an interactive dialogue will ask you two things:
|
||||
|
||||
1. The type of page (static, sandbox, element, or application)
|
||||
1. The page's module name
|
||||
|
||||
The meaning of each of the page types will be explained in the [Pages](/guide/pages) section!
|
||||
|
||||
__Note:__ Running the `elm-spa add` command will overwrite the contents of the existing file, so don't use it for upgrading an existing page.
|
||||
|
||||
## elm-spa build
|
||||
|
||||
This command does the automatic code generation for you. If you follow the naming conventions outlined in the next section, this is where elm-spa saves you time!
|
||||
|
||||
```terminal
|
||||
elm-spa build
|
||||
```
|
||||
|
||||
The generated code is in the `src/Spa/Generated` folder! Feel free to take a look, it's human readable Elm code!
|
||||
|
||||
__No need to call this!__ The project created by `elm-spa init` actually calls this under the hood.
|
||||
|
||||
Just use `npm start`, and you're good!
|
||||
|
||||
---
|
||||
|
||||
Next, let's talk about the [Routing](/guide/routing)!
|
@ -1,194 +0,0 @@
|
||||
# Pages
|
||||
|
||||
By default, there are four kinds of pages you can create with __elm-spa__. Always choose the simplest one for the job!
|
||||
|
||||
## Static
|
||||
|
||||
A simple, static page that just returns a view.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
view : Url Params -> Document Msg
|
||||
```
|
||||
|
||||
## Sandbox
|
||||
|
||||
A page that needs to maintain local state.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.sandbox
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Url Params -> Model
|
||||
update : Msg -> Model -> Model
|
||||
view : Model -> Document Msg
|
||||
```
|
||||
|
||||
## Element
|
||||
|
||||
A page that can make side effects with `Cmd` and listen for updates as `Sub`.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
view : Model -> Document Msg
|
||||
subscriptions : Model -> Sub Msg
|
||||
```
|
||||
|
||||
## Application
|
||||
|
||||
A page that can read and write to the shared model.
|
||||
|
||||
```elm
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
, save = save
|
||||
, load = load
|
||||
}
|
||||
```
|
||||
|
||||
```elm
|
||||
init : Shared.Model -> Url Params -> ( Model, Cmd Msg )
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
view : Model -> Document Msg
|
||||
subscriptions : Model -> Sub Msg
|
||||
save : Model -> Shared.Model -> Shared.Model
|
||||
load : Shared.Model -> Model -> ( Model, Cmd Msg )
|
||||
```
|
||||
|
||||
### Working with the `Shared.Model`
|
||||
|
||||
Because `save` and `load` are both new concepts, here's a quick example of how to use them! Imagine this is your `Shared.Model`:
|
||||
|
||||
```elm
|
||||
-- in Shared.elm
|
||||
type alias Model =
|
||||
{ key : Nav.Key
|
||||
, url : Url
|
||||
, user : Maybe User
|
||||
}
|
||||
```
|
||||
|
||||
Let's implement a `SignIn` page together to understand how these functions interact.
|
||||
|
||||
#### init
|
||||
|
||||
If you're using `Page.application`, your page can tell if the user is already logged in on `init`:
|
||||
|
||||
```elm
|
||||
type alias Model =
|
||||
{ email : String
|
||||
, password : String
|
||||
, user : Maybe User
|
||||
}
|
||||
|
||||
init : Shared.Model -> Url Params -> ( Model, Cmd Msg )
|
||||
init shared url =
|
||||
( { email = ""
|
||||
, password = ""
|
||||
, user = shared.user
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
#### load
|
||||
|
||||
On initialization, your page kept a local copy of `user`. This had a tradeoff: the rest of your page functions (`update`, `view`, and `subscriptions`) will be easy to implement and understand, __but__ now it's possible for `shared.user` and your page's `user` to get out of sync.
|
||||
|
||||
Imagine the scenario where the navbar had a "Sign out" button. When that button is clicked, the `shared.user` would be signed out, but our page's `Model` would still show the user as logged in! This is where the `load` function comes in!
|
||||
|
||||
The `load` function gets called automatically whenever the `Shared.Model` changes. This allows you to respond to external changes to update your local state or send a command!
|
||||
|
||||
```elm
|
||||
load : Shared.Model -> Model -> ( Model, Cmd Msg )
|
||||
load shared model =
|
||||
( { model | user = shared.user }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
The `load` function lets you explicitly choose which updates from `Shared.Model` you care about, and provides an easy way to keep your `Model` in sync.
|
||||
|
||||
#### save
|
||||
|
||||
Earlier, when we initialized our page, we kept the `user` in our model. This makes implementing a sign in form easy, without worrying about the `Shared.Model`.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= UpdatedEmail String
|
||||
| UpdatedPassword String
|
||||
| AttemptedSignIn
|
||||
| GotUser (Maybe User)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdatedEmail email ->
|
||||
( { model | email = email }, Cmd.none )
|
||||
|
||||
UpdatedPassword password ->
|
||||
( { model | password = password }, Cmd.none )
|
||||
|
||||
AttemptedSignIn ->
|
||||
( model
|
||||
, Api.User.signIn
|
||||
{ email = model.email
|
||||
, password = model.password
|
||||
, onResponse = GotUser
|
||||
}
|
||||
)
|
||||
|
||||
GotUser user ->
|
||||
( { model | user = user }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
The only issue is that the user is only stored on the Sign In page. if we navigate away, we'd lose that data. That's where `save` comes in!
|
||||
Anytime your page's `init` or `update` is run, `save` is automatically called (by `src/Main.elm`). This allows you to persist local state to `Shared.Model`.
|
||||
|
||||
|
||||
```elm
|
||||
save : Model -> Shared.Model -> Shared.Model
|
||||
save model shared =
|
||||
{ shared | user = model.user }
|
||||
```
|
||||
|
||||
That's it! Now if we navigate to another page, our user will still be signed in.
|
||||
|
||||
---
|
||||
|
||||
Let's take a deeper look at [Shared](/guide/shared) together.
|
@ -1,151 +0,0 @@
|
||||
# Routing
|
||||
|
||||
With __elm-spa__, the names of pages in the `src/Pages` folder automatically generate your routes! Check out the following examples to learn more.
|
||||
|
||||
## Static Routes
|
||||
|
||||
You can create a static route like `/contact` or `/not-found` by creating an elm file in `src/Pages`:
|
||||
|
||||
File | URL
|
||||
:-- | :--
|
||||
`People.elm` | `/people`
|
||||
`About/Careers.elm` | `/about/careers`
|
||||
`OurTeam.elm` | `/our-team`
|
||||
|
||||
__Capitalization matters!__ Notice how `OurTeam` became `our-team`? Capital letters within file names are translated to dashes in URLs.
|
||||
|
||||
## Top Level Routes
|
||||
|
||||
Routes like the homepage use the reserved `Top` keyword to indicate that a page should not be a static route.
|
||||
|
||||
File | URL
|
||||
:-- | :--
|
||||
`Top.elm` | `/`
|
||||
`Example/Top.elm` | `/example`
|
||||
`Top/Top.elm` | `/top`
|
||||
|
||||
__Reserved, but possible!__ If you actually need a `/top` route, you can still make one by using `Top.elm` within a `Top` folder. (As shown above!)
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
Sometimes it's nice to have one page that works for slightly different URLs. __elm-spa__ uses this convention in file names to indicate a dynamic route:
|
||||
|
||||
__`Authors/Name_String.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/authors/ryan` | `{ name = "ryan" }`
|
||||
`/authors/alexa` | `{ name = "alexa" }`
|
||||
|
||||
__`Posts/Id_Int.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/posts/123` | `{ id = 123 }`
|
||||
`/posts/456` | `{ id = 456 }`
|
||||
|
||||
You can access these dynamic parameters from the `Url Params` value passed into each page type!
|
||||
|
||||
__Supported Parameters__: Only `String` and `Int` dynamic parameters are supported.
|
||||
|
||||
### Nested Dynamic Routes
|
||||
|
||||
You can also nest your dynamic routes. Here's an example:
|
||||
|
||||
|
||||
__`Users/User_String/Posts/Id_Int.elm`__
|
||||
|
||||
URL | Params
|
||||
:-- | :--
|
||||
`/users/ryan/posts/123` | `{ user = "ryan"`<br/>`, id = 123`<br/>`}`
|
||||
`/users/alexa/posts/456` | `{ user = "alexa"`<br/>`, id = 456`<br/>`}`
|
||||
|
||||
## URL Params
|
||||
|
||||
As we'll see in the next section, every page will get access to `Url Params`– these allow you access a few things:
|
||||
|
||||
```elm
|
||||
type alias Url params =
|
||||
{ params : params
|
||||
, query : Dict String String
|
||||
, key : Browser.Navigation.Key
|
||||
, rawUrl : Url.Url
|
||||
}
|
||||
```
|
||||
|
||||
#### params
|
||||
|
||||
Each dynamic page has its own params, pulled from the URL. There are examples in the "Params" column above.
|
||||
|
||||
```elm
|
||||
type alias Params =
|
||||
{ name : String
|
||||
}
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view url =
|
||||
{ title = "Author: " ++ url.params.name
|
||||
, body = -- ...
|
||||
}
|
||||
```
|
||||
|
||||
#### query
|
||||
|
||||
A dictionary of query parameters. Here are some examples:
|
||||
|
||||
```elm
|
||||
-- https://elm-spa.dev
|
||||
Dict.get "name" url.query == Nothing
|
||||
|
||||
-- https://elm-spa.dev?name=ryan
|
||||
Dict.get "name" url.query == Just "ryan"
|
||||
|
||||
-- https://elm-spa.dev?name
|
||||
Dict.get "name" url.query == Just ""
|
||||
```
|
||||
|
||||
#### key
|
||||
|
||||
Required for programmatic navigation with `Nav.pushUrl` and other functions from [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#pushUrl)
|
||||
|
||||
#### rawUrl
|
||||
|
||||
The original URL in case you need any other information like the protocol, port, etc.
|
||||
|
||||
## Programmatic Navigation
|
||||
|
||||
The [elm/browser](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Navigation#pushUrl) package allows us to programmatically navigate to another page, if we provide a `Browser.Navigation.Key`. Fortunately, the `Url params` record above contains that `key`, and is available on all pages (and the `Shared` module)!
|
||||
|
||||
I recommend creating a common module, like `Utils.Route` that you can use in your application:
|
||||
|
||||
```elm
|
||||
module Utils.Route exposing (navigate)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
|
||||
|
||||
navigate : Nav.Key -> Route -> Cmd msg
|
||||
navigate key route =
|
||||
Nav.pushUrl key (Route.toString route)
|
||||
```
|
||||
|
||||
From there, you can call `Utils.Route.navigate` from any `init` or `update` function with your desired route.
|
||||
|
||||
```elm
|
||||
module Pages.Dashboard exposing (..)
|
||||
|
||||
import Utils.Route
|
||||
|
||||
-- ...
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init url =
|
||||
( Model { ... }
|
||||
, Utils.Route.navigate url.key Route.SignIn
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Let's take a closer look at [Pages](/guide/pages)!
|
@ -1,142 +0,0 @@
|
||||
# Shared
|
||||
|
||||
Whether you're sharing layouts or information between pages, the `Shared` module is the place to be!
|
||||
|
||||
## Flags
|
||||
|
||||
If you have initial data you want to pass into your Elm application, you should provide it via `Flags`.
|
||||
|
||||
When you create a project with `elm-spa init`, a file will be created at `public/main.js`:
|
||||
|
||||
```javascript
|
||||
// (in public/main.js)
|
||||
var flags = null
|
||||
var app = Elm.Main.init({ flags: flags })
|
||||
```
|
||||
|
||||
The value passed into the `flags` needs to match up with the type of `Shared.Flags`, for it to be passed into `Shared.init`.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```javascript
|
||||
// (in public/main.js)
|
||||
var flags = { project: "elm-spa", year: 2020 }
|
||||
```
|
||||
|
||||
```elm
|
||||
-- (in src/Shared.elm)
|
||||
type alias Flags =
|
||||
{ project : String
|
||||
, year : Int
|
||||
}
|
||||
```
|
||||
|
||||
Once you get comfortable with flags, I recommend always using `Json.Value` from the [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package as your Flags:
|
||||
|
||||
```elm
|
||||
import Json.Decode as Json
|
||||
|
||||
type alias Flags =
|
||||
Json.Value
|
||||
|
||||
type alias InitialData =
|
||||
{ project : String
|
||||
, year : Int
|
||||
}
|
||||
|
||||
decoder : Json.Decoder InitialData
|
||||
decoder =
|
||||
Json.map2 InitialData
|
||||
(Json.field "project" Json.string)
|
||||
(Json.field "year" Json.int)
|
||||
|
||||
init : Flags -> Url -> Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
case Json.decodeValue decoder flags of
|
||||
Ok initialData -> -- Initialize app
|
||||
Err reason -> -- Handle failure
|
||||
```
|
||||
|
||||
This way, you can create a decoder to gracefully handle the JSON being sent into your Elm application.
|
||||
|
||||
Learn more about [Flags](https://guide.elm-lang.org/interop/flags.html) in the official Elm guide.
|
||||
|
||||
## Model
|
||||
|
||||
All data in `Shared.Model` will persist across page navigation.
|
||||
|
||||
By default, it only contains `key` and `url`, which are required for the programmatic navigation and reading URL information in your application.
|
||||
|
||||
This makes it a great choice for things like logged-in users, dark-mode, or any other data displayed on shared components needed by navbars or footers.
|
||||
|
||||
```elm
|
||||
type alias Model =
|
||||
{ key : Key
|
||||
, url : Url
|
||||
, user : Maybe User
|
||||
}
|
||||
```
|
||||
|
||||
Here we added a `user` field that we can update with the next function!
|
||||
|
||||
## update
|
||||
|
||||
The `Shared.update` function is just like a normal `update` function in Elm. It takes in messages and returns the latest version of the `Model`. In this case, the `Model` is the `Shared.Model` mentioned above.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= SignIn User
|
||||
| SignOut
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
SignIn user ->
|
||||
( { model | user = Just user }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SignOut ->
|
||||
( { model | user = Nothing }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
This is just an example of using `update` with the `user` field we added earlier. Let's call those messages from our view.
|
||||
|
||||
## view
|
||||
|
||||
The `Shared.view` function is a great place to render things that should persist across page transitions. It comes with more than just a `Model`, so you can insert the `page` wherever you'd like:
|
||||
|
||||
```elm
|
||||
import Components.Navbar as Navbar
|
||||
import Components.Footer as Footer
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, toMsg : Msg -> msg
|
||||
}
|
||||
-> Model
|
||||
-> Document msg
|
||||
view { page, toMsg } model =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ Navbar.view
|
||||
{ user = model.user
|
||||
, onSignIn = toMsg SignIn
|
||||
, onSignOut = toMsg SignOut
|
||||
}
|
||||
, div [ class "page" ] page.body
|
||||
, Footer.view
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Using the `toMsg` function passed in the first argument, we're able to convert `Shared.Msg` to the same `msg` that our `view` function returns.
|
||||
|
||||
If you want components to send `Shared.Msg`, make sure to use that function first!
|
||||
|
||||
---
|
||||
|
||||
Let's take a look at [Components](/guide/components) now!
|
@ -1,182 +0,0 @@
|
||||
# Using APIs
|
||||
|
||||
Most applications interact with a REST API or a GraphQL endpoint to access data.
|
||||
For this guide, we'll be using the [Reddit REST API](https://www.reddit.com/dev/api/#GET_hot) to fetch the latest posts from [r/elm](https://www.reddit.com/r/elm).
|
||||
## Defining a module
|
||||
|
||||
Just like before, we'll define modules based on data structures:
|
||||
|
||||
```elm
|
||||
module Api.Reddit.Listing exposing
|
||||
( Listing
|
||||
, hot, new, top
|
||||
)
|
||||
```
|
||||
|
||||
## Storing the data
|
||||
|
||||
In Elm, there's a better way to model API data other than just toggling a `loading` boolean from `true` to `false`. Using [the RemoteData pattern](https://www.youtube.com/watch?v=NLcRzOyrH08), we can represent all states data from the web might be in, and display the right thing to our users:
|
||||
|
||||
```elm
|
||||
module Api exposing (Data(..), expectJson)
|
||||
|
||||
type Data value
|
||||
= NotAsked
|
||||
| Loading
|
||||
| Failure Http.Error
|
||||
| Success value
|
||||
|
||||
expectJson : (Data value -> msg) -> Decoder value -> Expect msg
|
||||
```
|
||||
|
||||
The `expectJson` function is a replacement for [Http.expectJson](https://package.elm-lang.org/packages/elm/http/latest/Http#expectJson) which uses `Result` instead.
|
||||
|
||||
## Working with JSON
|
||||
|
||||
The [elm/json](https://package.elm-lang.org/packages/elm/json/latest) package allows us to handle JSON from APIs, without crashing our application if the JSON isn't what we initially expected. We do that by creating decoders:
|
||||
|
||||
```elm
|
||||
import Json.Decode as Json
|
||||
|
||||
type alias Listing =
|
||||
{ title : String
|
||||
, author : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
decoder : Json.Decoder Listing
|
||||
decoder =
|
||||
Json.map3 Listing
|
||||
(Json.field "title" Json.string)
|
||||
(Json.field "author_fullname" Json.string)
|
||||
(Json.field "url" Json.string)
|
||||
```
|
||||
|
||||
## Actually fetching listings
|
||||
|
||||
Let's combine our new `Api` and `decoder` to actually fetch those Reddit posts! We'll use the [elm/http](https://package.elm-lang.org/packages/elm/http/latest) to make the GET request.
|
||||
|
||||
```elm
|
||||
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
hot options =
|
||||
Http.get
|
||||
{ url = "https://api.reddit.com/r/elm/hot"
|
||||
, expect =
|
||||
Api.expectJson options.onResponse
|
||||
(Json.at [ "data", "children" ] (Json.list decoder))
|
||||
}
|
||||
```
|
||||
|
||||
The actual listings are located inside `data.children`, so we used `Json.at` and `Json.list` to before we use our `decoder`.
|
||||
|
||||
```javascript
|
||||
{ "data": { "children": [ ... ] } }
|
||||
```
|
||||
|
||||
We can reuse that code to implement `new` and `top`. Let's move the reusable bits into `listings`, and just pass in the endpoint as a string.
|
||||
|
||||
```elm
|
||||
-- API ENDPOINTS
|
||||
|
||||
hot : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
hot =
|
||||
listings "hot"
|
||||
|
||||
|
||||
new : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
new =
|
||||
listings "new"
|
||||
|
||||
|
||||
top : { onResponse : Api.Data (List Listing) -> msg } -> Cmd msg
|
||||
top =
|
||||
listings "top"
|
||||
|
||||
|
||||
listings :
|
||||
String
|
||||
-> { onResponse : Api.Data (List Listing) -> msg }
|
||||
-> Cmd msg
|
||||
listings endpoint options =
|
||||
Http.get
|
||||
{ url = "https://api.reddit.com/r/elm/" ++ endpoint
|
||||
, expect =
|
||||
Api.expectJson options.onResponse
|
||||
(Json.at [ "data", "children" ] (Json.list decoder))
|
||||
}
|
||||
```
|
||||
|
||||
## Calling the API
|
||||
|
||||
Now that we have our new `Api.Reddit.Listing` module, we can use it in our pages. Here's an example of what that looks like:
|
||||
|
||||
```elm
|
||||
import Api
|
||||
import Api.Reddit.Listing exposing (Listing)
|
||||
|
||||
type alias Model =
|
||||
{ listings : Api.Data (List Listing)
|
||||
}
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init url =
|
||||
( Model Api.Loading
|
||||
, Api.Reddit.Listing.hot
|
||||
{ onResponse = GotHotListings
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
This sends an initial request to fetch the top Reddit posts from r/elm. We need to handle the response in our update function.
|
||||
|
||||
```elm
|
||||
type Msg
|
||||
= GotHotListings (Api.Data (List Listing))
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotHotListings data ->
|
||||
( { model | listings = data }
|
||||
, Cmd.none
|
||||
)
|
||||
```
|
||||
|
||||
Notice how we stored the entire `Api.Data` response, whether it succeeded or failed? That's perfect for the next bit, where we have control over how to show the user the state of the listings:
|
||||
|
||||
```elm
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = "Posts"
|
||||
, body =
|
||||
[ div [ class "page" ]
|
||||
[ viewListings model.listings
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
viewListings : Api.Data (List Listing) -> Html msg
|
||||
viewListings data =
|
||||
case data of
|
||||
Api.NotAsked -> text "Not asked"
|
||||
Api.Loading -> text "Loading..."
|
||||
Api.Failure _ -> text "Something went wrong..."
|
||||
Api.Success listings ->
|
||||
div [ class "listings" ]
|
||||
(List.map viewListing listings)
|
||||
|
||||
viewListing : Listing -> Html msg
|
||||
viewListing listing =
|
||||
div [ class "listing" ]
|
||||
[ a [ class "title", href listing.url ]
|
||||
[ text listing.title ]
|
||||
, p [ class "author" ]
|
||||
[ text ("Author: " ++ listing.author) ]
|
||||
]
|
||||
```
|
||||
|
||||
That's it! Here are the [actual files](https://gist.github.com/ryannhg/3ce83ec17ed473717e5604c7047e4d2c) used for this section.
|
||||
|
||||
---
|
||||
|
||||
Next we'll go [Beyond HTML](/guide/beyond-html), to explore other view options.
|
Before Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 161 KiB |
@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>elm-spa</title>
|
||||
<meta name="description" content="single page apps made easy.">
|
||||
<link rel="shortcut icon" href="/favicon.png" type="image/x-png">
|
||||
<!-- CSS goes here -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/atom-one-dark.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+Da+2:wght@800&family=Fira+Code:wght@400;700&family=Nunito:ital,wght@0,400;0,800;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<!-- Twitter Meta -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="elm-spa">
|
||||
<meta name="twitter:description" content="single page apps made easy">
|
||||
<meta name="twitter:site" content="@ryannhg">
|
||||
<meta name="twitter:image" content="https://elm-spa.dev/images/screenshot.png">
|
||||
<meta name="twitter:image:alt" content="A screenshot of the elm-spa homepage">
|
||||
</head>
|
||||
<body>
|
||||
<!-- JavaScript goes here -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/languages/elm.min.js"></script>
|
||||
<script src="/dist/elm.compiled.js"></script>
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,8 +0,0 @@
|
||||
// Initial data to pass in to Elm (linked with `Shared.Flags`)
|
||||
// https://guide.elm-lang.org/interop/flags.html
|
||||
var flags = null
|
||||
|
||||
// Start our Elm application
|
||||
var app = Elm.Main.init({ flags: flags })
|
||||
|
||||
// Ports go here: https://guide.elm-lang.org/interop/ports.html
|
@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.elm-spa.dev/dist/sitemap.xml
|
@ -1,100 +0,0 @@
|
||||
@import "https://nope.rhg.dev/dist/1.0.0/core.min.css";
|
||||
|
||||
html, body {
|
||||
color: #222;
|
||||
background-color: #f8f0f4;
|
||||
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
body { overflow-y: scroll; }
|
||||
|
||||
table { line-height: 1.2; }
|
||||
|
||||
.font-h5, .font-h6, .font-body, .text-body {
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.font-h1, .font-h2, .font-h3, .font-h4, .text-header {
|
||||
font-family: 'Baloo Da 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.font-h1 { font-size: 6rem; }
|
||||
.font-h5 { font-size: 1.5rem; }
|
||||
|
||||
.text--bigger { font-size: 1.2rem; }
|
||||
|
||||
/* links, buttons, and hoverable elements */
|
||||
.link { text-decoration: underline; font-weight: bold; color: #409844; }
|
||||
.link--external { color: dodgerblue; }
|
||||
.hoverable, .link, .button {
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.hoverable:hover:not([disabled]),
|
||||
.link:hover:not([disabled]),
|
||||
.button:hover:not([disabled]) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.link.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.size--120 { min-width: 6rem; min-height: 6rem; max-width: 6rem; max-height: 6rem; }
|
||||
.width--sidebar { min-width: 12rem; max-width: 12rem; }
|
||||
|
||||
.color--faint { color: rgba(0, 0, 0, 0.6); }
|
||||
.color--green { color: #409844; }
|
||||
|
||||
.fadeable { transition: transform 200ms ease-in-out; }
|
||||
.no-width { width: 0; overflow: hidden; }
|
||||
.invisible { opacity: 0; visibility: hidden; }
|
||||
|
||||
.readable { max-width: 36em; }
|
||||
|
||||
.sticky { position: sticky; top: 0; }
|
||||
|
||||
code.lang-terminal { position: relative; padding-left: 1.2rem }
|
||||
code.lang-terminal::before { position: absolute; top: 0.0675em; left: 0; content: "$"; margin-left: 0; opacity: 0.5; pointer-events: none; }
|
||||
|
||||
/* markdown styles */
|
||||
.markdown h1 { font-weight: bold; font-size: 4rem; letter-spacing: -0.05em; font-family: 'Baloo Da 2', sans-serif; }
|
||||
.markdown h2 { font-weight: bold; font-size: 2.5rem; margin-top: 1rem; letter-spacing: -0.05em; }
|
||||
.markdown h3 { font-weight: bold; font-size: 2rem; margin-top: 1rem; letter-spacing: -0.05em; }
|
||||
.markdown h4 { font-weight: bold; font-size: 1.5rem; margin-top: 0.5rem; letter-spacing: -0.05em; }
|
||||
.markdown h5 { font-weight: bold; font-size: 1.25rem; }
|
||||
.markdown h6 { font-weight: bold; font-size: 1rem; }
|
||||
.markdown p, .markdown ul, .markdown ol { color: #444; line-height: 1.4; font-size: 1.25rem; letter-spacing: -0.01em; }
|
||||
.markdown blockquote { line-height: 1.4; opacity: 0.5; padding: 0.25rem 1rem; border-left: solid 0.125rem #888; }
|
||||
.markdown pre { line-height: 1.3; padding: 1rem; background: #333; color: white; border-radius: 0.125em; }
|
||||
.markdown code { font-family: 'Fira Code', monospace; font-size: 0.9em; letter-spacing: 0; }
|
||||
.markdown p code, .markdown ul code, .markdown ol code { border: solid 1px #ccc; padding: 0 0.25em; border-radius: 0.125em; white-space: nowrap; }
|
||||
.markdown a { text-decoration: underline; font-weight: bold; color: #409844; }
|
||||
.markdown a[href^=https] { text-decoration: underline; font-weight: bold; color: dodgerblue; }
|
||||
.markdown table { border-radius: 0.125em; border: solid 1px #ccc; }
|
||||
.markdown td { vertical-align: top }
|
||||
.markdown td, .markdown th { padding: 0.5rem; }
|
||||
.markdown tbody tr:nth-child(2n+1) { background: #f2e3eb; }
|
||||
.markdown hr { height: 2px; width: 12rem; max-width: 100%; background-color: #ccc; border: 0; margin-left: 0; margin-right: 0; }
|
||||
|
||||
/* responsive font scaling */
|
||||
html { font-size: 16px; }
|
||||
@media screen and (min-width: 641px) { html { font-size: 18px; } }
|
||||
@media screen and (min-width: 1600px) { html { font-size: 20px; } }
|
||||
|
||||
/* accessible code highlighting */
|
||||
.hljs-comment, .hljs-quote { color: #959ba7; }
|
||||
|
||||
.home-pre {
|
||||
background: linear-gradient(#333, #222);
|
||||
color: white;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 0.5em 1em rgba(0,0,0,0.25);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const filesInGuideFolder =
|
||||
fs.readdirSync(path.join(__dirname, 'public/content/guide'))
|
||||
|
||||
const routes =
|
||||
[ '/',
|
||||
'/guide',
|
||||
'/examples',
|
||||
...(
|
||||
filesInGuideFolder
|
||||
.map(file => '/guide/' + file.split('.md')[0])
|
||||
)
|
||||
]
|
||||
|
||||
Promise.resolve(routes)
|
||||
.then(routes => routes.map(route =>`<url><loc>https://www.elm-spa.dev${route}</loc></url>`))
|
||||
.then(entries => `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${entries.join('\n ')}
|
||||
</urlset>`)
|
||||
.then(content => fs.writeFileSync(path.join(__dirname, 'public', 'dist', 'sitemap.xml'), content, { encoding: 'utf-8' }))
|
||||
.then(console.log)
|
||||
.catch(console.error)
|
@ -1,54 +0,0 @@
|
||||
module Api.Data exposing (Data(..), fromHttpResult, view)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr exposing (class, classList)
|
||||
import Spa.Transition
|
||||
|
||||
|
||||
type Data value
|
||||
= Loading
|
||||
| Success value
|
||||
| Failure String
|
||||
|
||||
|
||||
fromHttpResult : Result error value -> Data value
|
||||
fromHttpResult result =
|
||||
case result of
|
||||
Ok value ->
|
||||
Success value
|
||||
|
||||
Err _ ->
|
||||
Failure "Either this is a broken link or there's missing documentation!"
|
||||
|
||||
|
||||
view : (value -> Html msg) -> Data value -> Html msg
|
||||
view toHtml data =
|
||||
Html.div
|
||||
[ classList [ ( "invisible", data == Loading ) ]
|
||||
, Attr.style "transition" Spa.Transition.properties.page
|
||||
]
|
||||
<|
|
||||
case data of
|
||||
Loading ->
|
||||
[]
|
||||
|
||||
Success value ->
|
||||
[ toHtml value ]
|
||||
|
||||
Failure reason ->
|
||||
[ Html.div [ class "column spacing-small" ]
|
||||
[ Html.div [ class "column spacing-small" ]
|
||||
[ Html.h1 [ class "font-h2" ] [ Html.text "well. that's weird." ]
|
||||
, Html.p [] [ Html.text reason ]
|
||||
]
|
||||
, Html.p []
|
||||
[ Html.text "Could you please "
|
||||
, Html.a
|
||||
[ class "link"
|
||||
, Attr.href "https://github.com/ryannhg/elm-spa/issues/new?labels=documentation&title=Broken%20docs%20link"
|
||||
, Attr.target "_blank"
|
||||
]
|
||||
[ Html.text "let me know?" ]
|
||||
]
|
||||
]
|
||||
]
|
@ -1,12 +0,0 @@
|
||||
module Api.Markdown exposing (get)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Http
|
||||
|
||||
|
||||
get : { file : String, onResponse : Data String -> msg } -> Cmd msg
|
||||
get options =
|
||||
Http.get
|
||||
{ url = "/content/" ++ options.file
|
||||
, expect = Http.expectString (Api.Data.fromHttpResult >> options.onResponse)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
# src/Api
|
||||
> Call backend API services
|
@ -1,16 +0,0 @@
|
||||
module Components.Markdown exposing (view)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes exposing (class)
|
||||
import Markdown
|
||||
|
||||
|
||||
view : String -> Html msg
|
||||
view =
|
||||
Markdown.toHtmlWith
|
||||
{ githubFlavored = Just { tables = True, breaks = False }
|
||||
, defaultHighlighting = Nothing
|
||||
, sanitize = False
|
||||
, smartypants = False
|
||||
}
|
||||
[ class "markdown readable column spacing-small" ]
|
@ -1,2 +0,0 @@
|
||||
# src/Components
|
||||
> Reusable views and things
|
@ -1,78 +0,0 @@
|
||||
module Components.Sidebar exposing (view)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Options, view
|
||||
|
||||
-}
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Utils.String
|
||||
|
||||
|
||||
type alias Section =
|
||||
{ title : String
|
||||
, links : List Link
|
||||
}
|
||||
|
||||
|
||||
type alias Link =
|
||||
{ label : String
|
||||
, route : Route
|
||||
}
|
||||
|
||||
|
||||
view : Route -> Html msg
|
||||
view currentRoute =
|
||||
let
|
||||
viewSection : Section -> Html msg
|
||||
viewSection section =
|
||||
Html.section [ class "column spacing-small align-left" ]
|
||||
[ h3 [ class "font-h4" ] [ text section.title ]
|
||||
, div [ class "column spacing-small align-left" ]
|
||||
(List.map viewLink section.links)
|
||||
]
|
||||
|
||||
viewLink : Link -> Html msg
|
||||
viewLink link =
|
||||
a
|
||||
[ href (Route.toString link.route)
|
||||
, if link.route == currentRoute then
|
||||
class "color--green text-underline text-bold"
|
||||
|
||||
else
|
||||
class "text-underline hoverable"
|
||||
]
|
||||
[ text link.label ]
|
||||
in
|
||||
div [ class "hidden-mobile width--sidebar column pt-medium spacing-medium align-left" ]
|
||||
(List.map viewSection sections)
|
||||
|
||||
|
||||
sections : List Section
|
||||
sections =
|
||||
let
|
||||
guide : String -> Link
|
||||
guide label =
|
||||
Link label <|
|
||||
Route.Guide__Topic_String
|
||||
{ topic = Utils.String.sluggify label
|
||||
}
|
||||
in
|
||||
[ { title = "Guide"
|
||||
, links =
|
||||
[ Link "Introduction" Route.Guide
|
||||
, guide "Getting Started"
|
||||
, guide "Installation"
|
||||
, guide "Routing"
|
||||
, guide "Pages"
|
||||
, guide "Shared"
|
||||
, guide "Components"
|
||||
, guide "Using APIs"
|
||||
, guide "Beyond HTML"
|
||||
, guide "Authentication"
|
||||
]
|
||||
}
|
||||
]
|
@ -1,173 +0,0 @@
|
||||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Nav
|
||||
import Shared exposing (Flags)
|
||||
import Spa.Document as Document exposing (Document)
|
||||
import Spa.Generated.Pages as Pages
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Transition
|
||||
import Url exposing (Url)
|
||||
import Utils.Cmd
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view >> Document.toBrowserDocument
|
||||
, onUrlRequest = LinkClicked
|
||||
, onUrlChange = UrlChanged
|
||||
}
|
||||
|
||||
|
||||
fromUrl : Url -> Route
|
||||
fromUrl =
|
||||
Route.fromUrl >> Maybe.withDefault Route.NotFound
|
||||
|
||||
|
||||
|
||||
-- INIT
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ url : Url
|
||||
, key : Nav.Key
|
||||
, shared : Shared.Model
|
||||
, page : Pages.Model
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
, nextUrl : Url
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
let
|
||||
shared =
|
||||
Shared.init flags key url
|
||||
|
||||
route =
|
||||
fromUrl url
|
||||
|
||||
( page, pageCmd ) =
|
||||
Pages.init route shared
|
||||
in
|
||||
( Model url key shared page { layout = True, page = True } url
|
||||
, Cmd.batch
|
||||
[ Cmd.map Pages pageCmd
|
||||
, Utils.Cmd.delay Spa.Transition.delays.layout (FadeIn url)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= LinkClicked Browser.UrlRequest
|
||||
| UrlChanged Url
|
||||
| Shared Shared.Msg
|
||||
| Pages Pages.Msg
|
||||
| FadeIn Url
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
LinkClicked (Browser.Internal url) ->
|
||||
( model
|
||||
, Nav.pushUrl model.key (Url.toString url)
|
||||
)
|
||||
|
||||
LinkClicked (Browser.External href) ->
|
||||
( model
|
||||
, Nav.load href
|
||||
)
|
||||
|
||||
UrlChanged url ->
|
||||
if url == model.url then
|
||||
( model, Cmd.none )
|
||||
|
||||
else if url.path == model.url.path then
|
||||
loadPage url model
|
||||
|
||||
else
|
||||
( { model | isTransitioning = { layout = False, page = True }, nextUrl = url }
|
||||
, Utils.Cmd.delay Spa.Transition.delays.page (FadeIn url)
|
||||
)
|
||||
|
||||
FadeIn url ->
|
||||
loadPage url model
|
||||
|
||||
Shared sharedMsg ->
|
||||
let
|
||||
( shared, cmd ) =
|
||||
Shared.update sharedMsg model.shared
|
||||
|
||||
( page, pageCmd ) =
|
||||
Pages.load model.page shared
|
||||
in
|
||||
( { model | page = page, shared = shared }
|
||||
, Cmd.map Shared cmd
|
||||
)
|
||||
|
||||
Pages pageMsg ->
|
||||
let
|
||||
( page, cmd ) =
|
||||
Pages.update pageMsg model.page
|
||||
|
||||
shared =
|
||||
Pages.save page model.shared
|
||||
in
|
||||
( { model | page = page, shared = shared }
|
||||
, Cmd.map Pages cmd
|
||||
)
|
||||
|
||||
|
||||
loadPage : Url -> Model -> ( Model, Cmd Msg )
|
||||
loadPage url model =
|
||||
let
|
||||
route =
|
||||
fromUrl url
|
||||
|
||||
( page, cmd ) =
|
||||
Pages.init route model.shared
|
||||
|
||||
shared =
|
||||
Pages.save page model.shared
|
||||
in
|
||||
( { model
|
||||
| url = url
|
||||
, nextUrl = url
|
||||
, page = page
|
||||
, shared = shared
|
||||
, isTransitioning = { layout = False, page = False }
|
||||
}
|
||||
, Cmd.map Pages cmd
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
Shared.view
|
||||
{ page = Pages.view model.page |> Document.map Pages
|
||||
, shared = model.shared
|
||||
, toMsg = Shared
|
||||
, isTransitioning = model.isTransitioning
|
||||
, route = fromUrl model.url
|
||||
, shouldShowSidebar = isSidebarPage model.url
|
||||
}
|
||||
|
||||
|
||||
isSidebarPage : Url -> Bool
|
||||
isSidebarPage { path } =
|
||||
String.startsWith "/docs" path || String.startsWith "/guide" path
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Pages.subscriptions model.page
|
||||
|> Sub.map Pages
|
@ -1,101 +0,0 @@
|
||||
module Pages.Examples exposing (Model, Msg, Params, page)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (alt, class, href, src, style, target)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Example =
|
||||
{ name : String
|
||||
, githubUser : String
|
||||
, description : String
|
||||
, image : String
|
||||
, demoUrl : String
|
||||
, srcUrl : String
|
||||
}
|
||||
|
||||
|
||||
examples : List Example
|
||||
examples =
|
||||
[ Example "Realworld Example App"
|
||||
"ryannhg"
|
||||
"The official RealWorld application for elm-spa"
|
||||
"https://github.com/ryannhg/rhg-dev/blob/master/public/images/realworld-homepage.png?raw=true"
|
||||
"https://realworld.elm-spa.dev"
|
||||
"https://github.com/ryannhg/elm-spa-realworld"
|
||||
, Example "elm-spa.dev"
|
||||
"ryannhg"
|
||||
"The website you're on right now!"
|
||||
"/images/elm-spa-homepage.png"
|
||||
"https://elm-spa.dev"
|
||||
"https://github.com/ryannhg/elm-spa/tree/master/examples/elm-spa-dev"
|
||||
]
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view { params } =
|
||||
{ title = "examples | elm-spa"
|
||||
, body =
|
||||
[ div [ class "column spacing-giant py-large center-x" ]
|
||||
[ div [ class "column spacing-tiny text-center" ]
|
||||
[ h1 [ class "font-h1" ] [ text "examples" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "featured example projects" ]
|
||||
]
|
||||
, div [ class "column spacing-large" ] (List.map viewExample examples)
|
||||
, div [ class "row spacing-tiny" ]
|
||||
[ span [] [ text "Have a cool project?" ]
|
||||
, a
|
||||
[ class "link link--external"
|
||||
, href "https://github.com/ryannhg/elm-spa/issues/new?assignees=ryannhg&labels=examples&template=new-example.md&title=Featured+Example%3A+%5Bname%5D"
|
||||
, target "_blank"
|
||||
]
|
||||
[ text "Feature it here!" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewExample : Example -> Html msg
|
||||
viewExample example =
|
||||
section [ class "row spacing-medium wrap" ]
|
||||
[ a [ href example.demoUrl, target "_blank", class "hoverable" ]
|
||||
[ img [ src example.image, alt example.name, style "width" "360px" ] []
|
||||
]
|
||||
, div [ class "column spacing-large flex" ]
|
||||
[ div [ class "column spacing-tiny" ]
|
||||
[ h3 [ class "font-h3" ] [ text example.name ]
|
||||
, p [ class "font-body color--faint" ] [ text example.description ]
|
||||
, a [ class "link link--external", target "_blank", href ("https://github.com/" ++ example.githubUser) ] [ text ("@" ++ example.githubUser) ]
|
||||
]
|
||||
, div [ class "row spacing-small" ]
|
||||
[ a [ href example.demoUrl, target "_blank", class "link link--external" ] [ text "Demo" ]
|
||||
, a [ href example.srcUrl, target "_blank", class "link link--external" ] [ text "Source" ]
|
||||
]
|
||||
]
|
||||
]
|
@ -1,67 +0,0 @@
|
||||
module Pages.Guide exposing (Model, Msg, Params, page)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Api.Markdown
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ route : Route
|
||||
, content : Data String
|
||||
}
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = always Sub.none
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init { rawUrl } =
|
||||
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
|
||||
, content = Api.Data.Loading
|
||||
}
|
||||
, Api.Markdown.get
|
||||
{ file = "guide.md"
|
||||
, onResponse = GotMarkdown
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= GotMarkdown (Data String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotMarkdown content ->
|
||||
( { model | content = content }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = "guide | elm-spa"
|
||||
, body =
|
||||
[ Api.Data.view
|
||||
Components.Markdown.view
|
||||
model.content
|
||||
]
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
module Pages.Guide.Topic_String exposing (Model, Msg, Params, page)
|
||||
|
||||
import Api.Data exposing (Data)
|
||||
import Api.Markdown
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
type alias Params =
|
||||
{ topic : String
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ title : String
|
||||
, route : Route
|
||||
, content : Data String
|
||||
}
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = always Sub.none
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Url Params -> ( Model, Cmd Msg )
|
||||
init { rawUrl, params } =
|
||||
( { route = Route.fromUrl rawUrl |> Maybe.withDefault Route.NotFound
|
||||
, title = params.topic
|
||||
, content = Api.Data.Loading
|
||||
}
|
||||
, Api.Markdown.get
|
||||
{ file = "guide/" ++ params.topic ++ ".md"
|
||||
, onResponse = GotMarkdown
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
type Msg
|
||||
= GotMarkdown (Data String)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
GotMarkdown content ->
|
||||
( { model | content = content }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
view : Model -> Document Msg
|
||||
view model =
|
||||
{ title = prettifySlug model.title ++ " | guide | elm-spa"
|
||||
, body =
|
||||
[ Api.Data.view
|
||||
Components.Markdown.view
|
||||
model.content
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
prettifySlug : String -> String
|
||||
prettifySlug slug =
|
||||
slug
|
||||
|> String.replace "-" " "
|
||||
|> String.replace "elm spa" "elm-spa"
|
@ -1,43 +0,0 @@
|
||||
module Pages.NotFound exposing (Model, Msg, Params, page, view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, href)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view _ =
|
||||
{ title = "404"
|
||||
, body =
|
||||
[ div [ class "column spacing-tiny" ]
|
||||
[ h1 [ class "font-h2" ] [ text "Page not found" ]
|
||||
, p [ class "font-body color--faint" ]
|
||||
[ text "How about the "
|
||||
, a [ class "link", href (Route.toString Route.Top) ] [ text "homepage" ]
|
||||
, text "? That's a nice place."
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
# src/Pages
|
||||
> Correspond to a URL route
|
@ -1,69 +0,0 @@
|
||||
module Pages.Top exposing (Model, Msg, Params, page, view)
|
||||
|
||||
import Components.Markdown
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (alt, class, src)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view _ =
|
||||
{ title = "elm-spa"
|
||||
, body =
|
||||
[ div [ class "column spacing-medium center-x" ]
|
||||
[ hero
|
||||
, viewSection "No assembly required." """
|
||||
Build reliable [Elm](https://elm-lang.org) applications with the wonderful tools created by the community– brought together in one place:
|
||||
- Use __elm-ui__ to create UIs without CSS.
|
||||
- Comes with __elm-live__, a hot-reloading web server.
|
||||
- Create a test suite with __elm-test__
|
||||
"""
|
||||
, span [] []
|
||||
, viewSection "Ready to learn more?" """
|
||||
[Checkout the official guide](/guide)
|
||||
"""
|
||||
, span [] []
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
hero : Html msg
|
||||
hero =
|
||||
div [ class "column spacing-medium py-large center-x text-center" ]
|
||||
[ div [ class "column spacing-tiny center-x" ]
|
||||
[ img [ alt "elm-spa logo", class "size--120", src "/images/logo.svg" ] []
|
||||
, h1 [ class "font-h1" ] [ text "elm-spa" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "single page apps made easy." ]
|
||||
]
|
||||
, pre [ class "home-pre" ] [ code [ class "lang-terminal" ] [ text "npx elm-spa init" ] ]
|
||||
]
|
||||
|
||||
|
||||
viewSection : String -> String -> Html msg
|
||||
viewSection title content =
|
||||
section [ class "column spacing-small center-x" ]
|
||||
[ h3 [ class "font-h2" ] [ text title ]
|
||||
, Components.Markdown.view content
|
||||
]
|
@ -1,42 +0,0 @@
|
||||
module Pages.Tour exposing (Model, Msg, Params, page)
|
||||
|
||||
import Html exposing (div, h1, p, text)
|
||||
import Html.Attributes exposing (class)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Page as Page exposing (Page)
|
||||
import Spa.Url as Url exposing (Url)
|
||||
|
||||
|
||||
page : Page Params Model Msg
|
||||
page =
|
||||
Page.static
|
||||
{ view = view
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
Url Params
|
||||
|
||||
|
||||
type alias Msg =
|
||||
Never
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
type alias Params =
|
||||
()
|
||||
|
||||
|
||||
view : Url Params -> Document Msg
|
||||
view { params } =
|
||||
{ title = "Tour"
|
||||
, body =
|
||||
[ div [ class "column spacing-tiny py-large center-x text-center" ]
|
||||
[ h1 [ class "font-h1" ] [ text "tour" ]
|
||||
, p [ class "font-h5 color--faint" ] [ text "coming soon!" ]
|
||||
]
|
||||
]
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
port module Ports exposing (log)
|
||||
|
||||
-- A place to interact with JavaScript
|
||||
-- https://guide.elm-lang.org/interop/ports.html
|
||||
|
||||
|
||||
port log : String -> Cmd msg
|
@ -1,128 +0,0 @@
|
||||
module Shared exposing
|
||||
( Flags
|
||||
, Model
|
||||
, Msg
|
||||
, init
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Components.Sidebar
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, classList, href, style)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Generated.Route as Route exposing (Route)
|
||||
import Spa.Transition
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
()
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ key : Nav.Key
|
||||
, url : Url
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Nav.Key -> Url -> Model
|
||||
init _ key url =
|
||||
Model key url
|
||||
|
||||
|
||||
type Msg
|
||||
= ReplaceMe
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ReplaceMe ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
||||
|
||||
|
||||
view :
|
||||
{ page : Document msg
|
||||
, shared : Model
|
||||
, toMsg : Msg -> msg
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
, shouldShowSidebar : Bool
|
||||
, route : Route
|
||||
}
|
||||
-> Document msg
|
||||
view ({ page, isTransitioning } as options) =
|
||||
{ title = page.title
|
||||
, body =
|
||||
[ div
|
||||
[ class "column container px-medium spacing-small fill-y"
|
||||
, style "transition" Spa.Transition.properties.layout
|
||||
, classList [ ( "invisible", isTransitioning.layout ) ]
|
||||
]
|
||||
[ viewNavbar
|
||||
, div [ class "flex row align-top relative" ]
|
||||
[ viewSidebar options
|
||||
, viewPage options
|
||||
]
|
||||
, viewFooter
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
viewNavbar : Html msg
|
||||
viewNavbar =
|
||||
header [ class "py-medium row spacing-small spread center-y" ]
|
||||
[ a [ class "font-h3 text-header hoverable", href "/" ]
|
||||
[ text "elm-spa" ]
|
||||
, div [ class "row spacing-small text--bigger" ]
|
||||
[ a [ class "link", href (Route.toString Route.Guide) ] [ text "guide" ]
|
||||
|
||||
-- , a [ class "link", href (Route.toString Route.Tour) ] [ text "tour" ]
|
||||
, a [ class "link", href (Route.toString Route.Examples) ] [ text "examples" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewPage :
|
||||
{ options
|
||||
| page : Document msg
|
||||
, isTransitioning : { layout : Bool, page : Bool }
|
||||
}
|
||||
-> Html msg
|
||||
viewPage { page, isTransitioning } =
|
||||
main_
|
||||
[ class "flex"
|
||||
, style "transition" Spa.Transition.properties.page
|
||||
, classList [ ( "invisible", isTransitioning.page ) ]
|
||||
]
|
||||
page.body
|
||||
|
||||
|
||||
viewSidebar : { options | shouldShowSidebar : Bool, route : Route } -> Html msg
|
||||
viewSidebar { shouldShowSidebar, route } =
|
||||
aside
|
||||
[ class "hidden-mobile fadeable sticky"
|
||||
, classList
|
||||
[ ( "invisible", not shouldShowSidebar )
|
||||
, ( "no-width", not shouldShowSidebar )
|
||||
]
|
||||
]
|
||||
[ Components.Sidebar.view route
|
||||
]
|
||||
|
||||
|
||||
viewFooter : Html msg
|
||||
viewFooter =
|
||||
footer [ class "footer pt-large pb-medium text-center color--faint" ]
|
||||
[ text "[ Built with "
|
||||
, a [ class "text-underline hoverable", Html.Attributes.target "_blank", href "https://elm-lang.org" ] [ text "Elm" ]
|
||||
, text " ]"
|
||||
]
|
@ -1,26 +0,0 @@
|
||||
module Spa.Document exposing
|
||||
( Document
|
||||
, map
|
||||
, toBrowserDocument
|
||||
)
|
||||
|
||||
import Browser
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
type alias Document msg =
|
||||
{ title : String
|
||||
, body : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> Document msg1 -> Document msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
|
||||
|
||||
toBrowserDocument : Document msg -> Browser.Document msg
|
||||
toBrowserDocument doc =
|
||||
doc
|
@ -1,126 +0,0 @@
|
||||
module Spa.Page exposing
|
||||
( Page
|
||||
, static, sandbox, element, application
|
||||
, Upgraded, Bundle, upgrade
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs Page
|
||||
@docs static, sandbox, element, application
|
||||
@docs Upgraded, Bundle, upgrade
|
||||
|
||||
-}
|
||||
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Shared
|
||||
import Spa.Document as Document exposing (Document)
|
||||
import Spa.Url exposing (Url)
|
||||
import Url
|
||||
|
||||
|
||||
type alias Page params model msg =
|
||||
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, save : model -> Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> model -> ( model, Cmd msg )
|
||||
}
|
||||
|
||||
|
||||
static :
|
||||
{ view : Url params -> Document msg
|
||||
}
|
||||
-> Page params (Url params) msg
|
||||
static page =
|
||||
{ init = \_ url -> ( url, Cmd.none )
|
||||
, update = \_ model -> ( model, Cmd.none )
|
||||
, view = page.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
sandbox :
|
||||
{ init : Url params -> model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> Page params model msg
|
||||
sandbox page =
|
||||
{ init = \_ url -> ( page.init url, Cmd.none )
|
||||
, update = \msg model -> ( page.update msg model, Cmd.none )
|
||||
, view = page.view
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
element :
|
||||
{ init : Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
}
|
||||
-> Page params model msg
|
||||
element page =
|
||||
{ init = \_ params -> page.init params
|
||||
, update = \msg model -> page.update msg model
|
||||
, view = page.view
|
||||
, subscriptions = page.subscriptions
|
||||
, save = always identity
|
||||
, load = \_ model -> ( model, Cmd.none )
|
||||
}
|
||||
|
||||
|
||||
application :
|
||||
{ init : Shared.Model -> Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, save : model -> Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> model -> ( model, Cmd msg )
|
||||
}
|
||||
-> Page params model msg
|
||||
application page =
|
||||
page
|
||||
|
||||
|
||||
|
||||
-- UPGRADING
|
||||
|
||||
|
||||
type alias Upgraded pageParams pageModel pageMsg model msg =
|
||||
{ init : pageParams -> Shared.Model -> Key -> Url.Url -> ( model, Cmd msg )
|
||||
, update : pageMsg -> pageModel -> ( model, Cmd msg )
|
||||
, bundle : pageModel -> Bundle model msg
|
||||
}
|
||||
|
||||
|
||||
type alias Bundle model msg =
|
||||
{ view : Document msg
|
||||
, subscriptions : Sub msg
|
||||
, save : Shared.Model -> Shared.Model
|
||||
, load : Shared.Model -> ( model, Cmd msg )
|
||||
}
|
||||
|
||||
|
||||
upgrade :
|
||||
(pageModel -> model)
|
||||
-> (pageMsg -> msg)
|
||||
-> Page pageParams pageModel pageMsg
|
||||
-> Upgraded pageParams pageModel pageMsg model msg
|
||||
upgrade toModel toMsg page =
|
||||
{ init = \params shared key url -> page.init shared (Spa.Url.create params key url) |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
, update = \msg model -> page.update msg model |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
, bundle =
|
||||
\model ->
|
||||
{ view = page.view model |> Document.map toMsg
|
||||
, subscriptions = page.subscriptions model |> Sub.map toMsg
|
||||
, save = page.save model
|
||||
, load = \shared -> page.load shared model |> Tuple.mapBoth toModel (Cmd.map toMsg)
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
# src/Spa
|
||||
> elm-spa configuration and generated code
|
@ -1,23 +0,0 @@
|
||||
module Spa.Transition exposing
|
||||
( delays
|
||||
, properties
|
||||
)
|
||||
|
||||
|
||||
delays : { layout : Int, page : Int }
|
||||
delays =
|
||||
{ layout = 300
|
||||
, page = 300
|
||||
}
|
||||
|
||||
|
||||
properties : { layout : String, page : String }
|
||||
properties =
|
||||
{ layout = property delays.layout
|
||||
, page = property delays.page
|
||||
}
|
||||
|
||||
|
||||
property : Int -> String
|
||||
property delay =
|
||||
"opacity " ++ String.fromInt delay ++ "ms ease-in-out, visibility " ++ String.fromInt delay ++ "ms ease-in-out"
|
@ -1,45 +0,0 @@
|
||||
module Spa.Url exposing (Url, create)
|
||||
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Dict exposing (Dict)
|
||||
import Url
|
||||
|
||||
|
||||
type alias Url params =
|
||||
{ key : Key
|
||||
, params : params
|
||||
, query : Dict String String
|
||||
, rawUrl : Url.Url
|
||||
}
|
||||
|
||||
|
||||
create : params -> Key -> Url.Url -> Url params
|
||||
create params key url =
|
||||
{ params = params
|
||||
, key = key
|
||||
, rawUrl = url
|
||||
, query =
|
||||
url.query
|
||||
|> Maybe.map toQueryDict
|
||||
|> Maybe.withDefault Dict.empty
|
||||
}
|
||||
|
||||
|
||||
toQueryDict : String -> Dict String String
|
||||
toQueryDict queryString =
|
||||
let
|
||||
second : List a -> Maybe a
|
||||
second list =
|
||||
list |> List.drop 1 |> List.head
|
||||
|
||||
toTuple : List String -> Maybe ( String, String )
|
||||
toTuple list =
|
||||
Maybe.map2 Tuple.pair
|
||||
(List.head list)
|
||||
(second list)
|
||||
in
|
||||
queryString
|
||||
|> String.split "&"
|
||||
|> List.map (String.split "=")
|
||||
|> List.filterMap toTuple
|
||||
|> Dict.fromList
|
@ -1,16 +0,0 @@
|
||||
module Utils.Cmd exposing (delay, send)
|
||||
|
||||
import Process
|
||||
import Task
|
||||
|
||||
|
||||
send : msg -> Cmd msg
|
||||
send =
|
||||
delay 0
|
||||
|
||||
|
||||
delay : Int -> msg -> Cmd msg
|
||||
delay ms msg =
|
||||
Process.sleep (toFloat ms)
|
||||
|> Task.map (\_ -> msg)
|
||||
|> Task.perform identity
|
@ -1,2 +0,0 @@
|
||||
# src/Utils
|
||||
> Helpful modules around data structures
|
@ -1,8 +0,0 @@
|
||||
module Utils.String exposing (sluggify)
|
||||
|
||||
|
||||
sluggify : String -> String
|
||||
sluggify words =
|
||||
words
|
||||
|> String.replace " " "-"
|
||||
|> String.toLower
|
@ -1,24 +0,0 @@
|
||||
module Program.NotFoundTest exposing (all)
|
||||
|
||||
import Pages.NotFound as Page
|
||||
import ProgramTest exposing (ProgramTest, expectViewHas)
|
||||
import Program.Utils.Spa
|
||||
import Test exposing (..)
|
||||
import Test.Html.Selector exposing (text)
|
||||
|
||||
|
||||
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
|
||||
start =
|
||||
Program.Utils.Spa.createStaticPage
|
||||
{ view = Page.view
|
||||
}
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Pages.NotFound"
|
||||
[ test "should say page not found" <|
|
||||
\() ->
|
||||
start
|
||||
|> expectViewHas [ text "Page not found" ]
|
||||
]
|
@ -1,2 +0,0 @@
|
||||
# tests/Program
|
||||
> Write tests for pages
|
@ -1,24 +0,0 @@
|
||||
module Program.TopTest exposing (all)
|
||||
|
||||
import Pages.Top as Page
|
||||
import ProgramTest exposing (ProgramTest, expectViewHas)
|
||||
import Program.Utils.Spa
|
||||
import Test exposing (..)
|
||||
import Test.Html.Selector exposing (text)
|
||||
|
||||
|
||||
start : ProgramTest Page.Model Page.Msg (Cmd Page.Msg)
|
||||
start =
|
||||
Program.Utils.Spa.createStaticPage
|
||||
{ view = Page.view
|
||||
}
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Pages.Top"
|
||||
[ test "should say homepage" <|
|
||||
\() ->
|
||||
start
|
||||
|> expectViewHas [ text "Homepage" ]
|
||||
]
|
@ -1,54 +0,0 @@
|
||||
module Program.Utils.Spa exposing
|
||||
( createElementPage
|
||||
, createSandboxPage
|
||||
, createStaticPage
|
||||
)
|
||||
|
||||
import ProgramTest exposing (ProgramTest)
|
||||
import Spa.Document exposing (Document)
|
||||
import Spa.Url exposing (Url)
|
||||
|
||||
|
||||
createStaticPage :
|
||||
{ view : Document msg
|
||||
}
|
||||
-> ProgramTest () msg (Cmd msg)
|
||||
createStaticPage page =
|
||||
ProgramTest.createDocument
|
||||
{ init = \_ -> ( (), Cmd.none )
|
||||
, update = \_ model -> ( model, Cmd.none )
|
||||
, view = \_ -> page.view |> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start ()
|
||||
|
||||
|
||||
createSandboxPage :
|
||||
{ init : model
|
||||
, update : msg -> model -> model
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> ProgramTest model msg (Cmd msg)
|
||||
createSandboxPage page =
|
||||
ProgramTest.createDocument
|
||||
{ init = \_ -> ( page.init, Cmd.none )
|
||||
, update = \msg model -> ( page.update msg model, Cmd.none )
|
||||
, view = page.view >> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start ()
|
||||
|
||||
|
||||
createElementPage :
|
||||
Url params
|
||||
->
|
||||
{ init : Url params -> ( model, Cmd msg )
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Document msg
|
||||
}
|
||||
-> ProgramTest model msg (Cmd msg)
|
||||
createElementPage params page =
|
||||
ProgramTest.createDocument
|
||||
{ init = page.init
|
||||
, update = page.update
|
||||
, view = page.view >> Spa.Document.toBrowserDocument
|
||||
}
|
||||
|> ProgramTest.start params
|
@ -1,2 +0,0 @@
|
||||
# tests
|
||||
> A place for function and program tests
|
@ -1,2 +0,0 @@
|
||||
# tests/Unit
|
||||
> Write tests for functions
|
200
index.js
@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const prompts = require('prompts')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { Elm } = require('./dist/elm.worker.js')
|
||||
const package = require('./package.json')
|
||||
|
||||
// File stuff
|
||||
const folders = {
|
||||
src: (dir) => path.join(process.cwd(), dir, 'src'),
|
||||
pages: (dir) => path.join(process.cwd(), dir, 'src', 'Pages'),
|
||||
generated: (dir) => path.join(process.cwd(), dir, 'src', 'Spa', 'Generated')
|
||||
}
|
||||
|
||||
const rejectIfMissing = (dir) => new Promise((resolve, reject) =>
|
||||
fs.existsSync(dir) ? resolve(true) : reject(false)
|
||||
)
|
||||
|
||||
const cp = (src, dest) => {
|
||||
const exists = fs.existsSync(src)
|
||||
const stats = exists && fs.statSync(src)
|
||||
if (stats && stats.isDirectory()) {
|
||||
fs.mkdirSync(dest)
|
||||
fs.readdirSync(src).forEach(child =>
|
||||
cp(path.join(src, child), path.join(dest, child))
|
||||
)
|
||||
} else {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
}
|
||||
|
||||
const listFiles = (dir) =>
|
||||
fs.readdirSync(dir)
|
||||
.reduce((files, file) =>
|
||||
fs.statSync(path.join(dir, file)).isDirectory() ?
|
||||
files.concat(listFiles(path.join(dir, file))) :
|
||||
files.concat(path.join(dir, file)),
|
||||
[])
|
||||
|
||||
const ensureDirectory = (dir) =>
|
||||
fs.existsSync(dir) || fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const saveToFolder = (prefix) => ({ filepath, content }) =>
|
||||
fs.writeFileSync(path.join(prefix, filepath), content, { encoding: 'utf8' })
|
||||
|
||||
// Formatting output
|
||||
const bold = (str) => '\033[1m' + str + '\033[0m'
|
||||
const green = (str) => '\033[32m' + str + '\033[0m'
|
||||
const toFilepath = name => path.join(folders.pages('.'), `${name.split('.').join('/')}.elm`)
|
||||
|
||||
// Flags + Validation
|
||||
const flags = { command: '', name: '', pageType: '', filepaths: [] }
|
||||
|
||||
const isValidPageType = type =>
|
||||
[ 'static', 'sandbox', 'element', 'application' ].some(x => x === type)
|
||||
|
||||
const isValidModuleName = (name = '') => {
|
||||
const isAlphaOrUnderscoreOnly = word => word.match(/[A-Z|a-z|_]+/)[0] === word
|
||||
const isCapitalized = word => word[0].toUpperCase() === word[0]
|
||||
return name &&
|
||||
name.length &&
|
||||
name.split('.').every(word => isAlphaOrUnderscoreOnly(word) && isCapitalized(word))
|
||||
}
|
||||
|
||||
// Help commands
|
||||
const help = `
|
||||
${bold('elm-spa')} – version ${package.version}
|
||||
|
||||
${bold('elm-spa init')} – create a new project
|
||||
${bold('elm-spa add')} – add a new page
|
||||
${bold('elm-spa build')} – generate routes and pages automatically
|
||||
|
||||
${bold('elm-spa version')} – print the version number
|
||||
`
|
||||
|
||||
const toUnixFilepath = (filepath) =>
|
||||
filepath.split(path.sep).join('/')
|
||||
|
||||
// Fancy interactive prompts
|
||||
const interactivePrompts = {
|
||||
'init': _ => prompts([
|
||||
{
|
||||
type: 'select',
|
||||
name: 'ui',
|
||||
message: 'UI package?',
|
||||
choices: [
|
||||
{ title: 'elm-ui', value: 'elm-ui', description: '"What if you never had to write CSS again?"' },
|
||||
{ title: 'html', value: 'html', description: '"Use HTML in Elm!"' },
|
||||
{ title: 'elm-css', value: 'elm-css', description: '"Typed CSS in Elm."' }
|
||||
],
|
||||
initial: 0
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `What's the folder name?`,
|
||||
initial: 'my-elm-spa',
|
||||
validate: (input) =>
|
||||
/[a-z\-]+/.test(input) || 'Lowercase letters and dashes only.'
|
||||
}
|
||||
], { onCancel: _ => process.exit(0) }),
|
||||
|
||||
'add': _ => prompts([
|
||||
{
|
||||
type: 'select',
|
||||
name: 'type',
|
||||
message: 'What kind of page?',
|
||||
choices: [
|
||||
{ title: 'static', value: 'static', description: 'A simple, static page' },
|
||||
{ title: 'sandbox', value: 'sandbox', description: 'Needs to manage local state' },
|
||||
{ title: 'element', value: 'element', description: 'Needs to send Cmd msg or receive Sub msg' },
|
||||
{ title: 'application', value: 'application', description: 'Needs read-write access to Shared.Model' },
|
||||
],
|
||||
initial: 0
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `What's the module name?`,
|
||||
hint: 'Example: "Posts.Id_Int"',
|
||||
validate: (input) =>
|
||||
isValidModuleName(input) || 'Must be a valid Elm module name.'
|
||||
}
|
||||
], { onCancel: _ => process.exit(0) })
|
||||
}
|
||||
|
||||
// Available commands
|
||||
const commands = {
|
||||
|
||||
'init': ([ template, folder ]) =>
|
||||
template && folder && [ 'html', 'elm-css', 'elm-ui' ].includes(template)
|
||||
? Promise.resolve()
|
||||
.then(_ => {
|
||||
const dest = path.join(process.cwd(), folder)
|
||||
cp(path.join(__dirname, 'templates', template), dest)
|
||||
try { fs.renameSync(path.join(dest, '.npmignore'), path.join(dest, '.gitignore')) } catch (_) {}
|
||||
})
|
||||
.then(_ => `\n${green('✔')} Created a new project in ${path.join(process.cwd(), folder)}\n`)
|
||||
.catch(_ => `\nUnable to initialize a project at ${path.join(process.cwd(), folder)}\n`)
|
||||
: interactivePrompts.init()
|
||||
.then(({ ui, name }) => commands.init([ ui, name ])),
|
||||
|
||||
'add': ([ type, name ]) =>
|
||||
(type && name) && type !== 'help' && isValidPageType(type) && isValidModuleName(name)
|
||||
? rejectIfMissing(folders.pages('.'))
|
||||
.then(_ => new Promise(
|
||||
Elm.Main.init({ flags: { ...flags, command: 'add', name: name, pageType: type } }).ports.addPort.subscribe)
|
||||
)
|
||||
.then(file => {
|
||||
const containingFolder = path.join(folders.pages('.'), file.filepath.split('/').slice(0, -1).join('/'))
|
||||
ensureDirectory(containingFolder)
|
||||
saveToFolder((folders.pages('.')))(file)
|
||||
})
|
||||
.then(_ => `\n${green('✔')} Added a new ${bold(type)} page at:\n${toFilepath(name)}\n`)
|
||||
.catch(_ => `\nPlease run ${bold('elm-spa add')} in the folder with ${bold('elm.json')}\n`)
|
||||
: interactivePrompts.add()
|
||||
.then(({ type, name }) => commands.add([ type, name ])),
|
||||
|
||||
'build': (_, dir = '.') =>
|
||||
Promise.resolve(folders.pages(dir))
|
||||
.then(listFiles)
|
||||
.then(names => names.filter(name => name.endsWith('.elm')))
|
||||
.then(names => names.map(name => name.substring(folders.pages(dir).length)))
|
||||
.then(filepaths => new Promise(
|
||||
Elm.Main.init({ flags: { ...flags, command: 'build', filepaths: filepaths.map(toUnixFilepath) } }).ports.buildPort.subscribe
|
||||
))
|
||||
.then(files => {
|
||||
ensureDirectory(folders.generated(dir))
|
||||
files.forEach(saveToFolder(folders.src(dir)))
|
||||
return files
|
||||
})
|
||||
.then(_ => `\n${green('✔')} elm-spa build complete!\n`)
|
||||
.catch(_ => `\nPlease run ${bold('elm-spa build')} in the folder with ${bold('elm.json')}\n`),
|
||||
|
||||
'-v': _ => Promise.resolve(package.version),
|
||||
|
||||
'version': _ => Promise.resolve(package.version),
|
||||
|
||||
'help': _ => Promise.resolve(help)
|
||||
|
||||
}
|
||||
|
||||
const main = ([ command, ...args ] = []) =>
|
||||
(commands[command] || commands['help'])(args)
|
||||
.then(console.info)
|
||||
.catch(reason => {
|
||||
console.info(`\n${bold('Congratulations!')} - you've found a bug!
|
||||
|
||||
If you'd like, open an issue here with the following output:
|
||||
https://github.com/ryannhg/elm-spa/issues/new?labels=cli-crash
|
||||
|
||||
|
||||
${bold(`### terminal output`)}
|
||||
`)
|
||||
console.log('```')
|
||||
console.error(reason)
|
||||
console.log('```\n')
|
||||
})
|
||||
|
||||
main([...process.argv.slice(2)])
|
1116
package-lock.json
generated
48
package.json
@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "elm-spa",
|
||||
"version": "5.0.4",
|
||||
"description": "single page apps made easy",
|
||||
"main": "index.js",
|
||||
"bin": "./index.js",
|
||||
"directories": {
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:elm && npm run build:minify",
|
||||
"build:elm": "elm make src/Main.elm --optimize --output dist/elm.js",
|
||||
"build:minify": "uglifyjs dist/elm.js --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' | uglifyjs --mangle > dist/elm.worker.js",
|
||||
"test:ci": "npm run test && npm run test:cli",
|
||||
"test": "elm-test",
|
||||
"test:watch": "elm-test --watch",
|
||||
"test:cli": "(npm run build && cd tests && npm run test:cli:init && npm run test:cli:add && npm run test:cli:build && npm run test:cli:cleanup)",
|
||||
"test:cli:init": "./index.js init elm-ui tests/demo && cd tests/demo && npm install && npm run build",
|
||||
"test:cli:add": "cd tests/demo && ../../index.js add static Top && ../../index.js add sandbox Posts.Top && ../../index.js add element Posts.Id_Int && ../../index.js add application Authors.Name_String.Posts.Id_Int && npm run build",
|
||||
"test:cli:build": "cd tests/demo && ../../index.js build",
|
||||
"test:cli:cleanup": "cd tests && rm -rf demo",
|
||||
"dev": "chokidar src -c \"(npm run build || true)\""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/ryannhg/elm-spa.git"
|
||||
},
|
||||
"keywords": [
|
||||
"elm",
|
||||
"spa",
|
||||
"web",
|
||||
"framework"
|
||||
],
|
||||
"author": "Ryan Haskell-Glatz",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ryannhg/elm-spa/issues"
|
||||
},
|
||||
"homepage": "https://github.com/ryannhg/elm-spa#readme",
|
||||
"dependencies": {
|
||||
"prompts": "2.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"elm": "0.19.1-3",
|
||||
"elm-test": "0.19.1-revision2",
|
||||
"uglify-js": "3.10.0"
|
||||
}
|
||||
}
|