move over from private repo

This commit is contained in:
Ryan Haskell-Glatz 2021-01-15 21:39:37 -06:00
parent 51eb1976d4
commit a0ac4af029
192 changed files with 11379 additions and 10513 deletions

View File

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

View File

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

30
LICENSE
View File

@ -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.

View File

@ -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
View 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

View File

@ -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": {}
}
}

View 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)

View 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!

View 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
}
```

View 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.

View 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.

View 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.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
}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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

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

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nunito:ital,wght@0,300;0,600;0,800;1,300&family=Nunito+Sans:wght@800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="//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
View 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
View 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;
}

View 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
View 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
View 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
View 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

View 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

View 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
View 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.
"""
]
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

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

196
docs/src/UI/Sidebar.elm Normal file
View 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
View File

@ -0,0 +1,58 @@
module Utils.String exposing
( caseInsensitiveContains
, format
, toId
)
import Html exposing (Html)
caseInsensitiveContains : String -> String -> Bool
caseInsensitiveContains sub word =
String.contains (String.toLower sub) (String.toLower word)
toId : String -> String
toId =
String.toLower
>> String.words
>> List.map (String.filter 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 []

View File

@ -1,28 +1,18 @@
{
"type": "application",
"source-directories": [
"src"
"type": "package",
"name": "ryannhg/elm-spa",
"summary": "Single page apps made easy.",
"license": "BSD-3-Clause",
"version": "6.0.0",
"exposed-modules": [
"ElmSpa.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": {}
}

View 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!
```

View File

@ -1,7 +1,10 @@
{
"type": "application",
"source-directories": [
"src"
"src",
".elm-spa/defaults",
".elm-spa/generated",
"../../src"
],
"elm-version": "0.19.1",
"dependencies": {
@ -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": {

View File

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

View File

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

View File

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

View File

@ -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`

View File

@ -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"
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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)?

View File

@ -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!

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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)!

View File

@ -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.

View File

@ -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)!

View File

@ -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!

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

View File

@ -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&amp;family=Fira+Code:wght@400;700&amp;family=Nunito:ital,wght@0,400;0,800;1,400&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<!-- 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>

View File

@ -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

View File

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

View File

@ -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);
}

View File

@ -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)

View File

@ -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?" ]
]
]
]

View File

@ -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)
}

View File

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

View File

@ -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" ]

View File

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

View File

@ -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"
]
}
]

View File

@ -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

View File

@ -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" ]
]
]
]

View File

@ -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
]
}

View File

@ -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"

View File

@ -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."
]
]
]
}

View File

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

View File

@ -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
]

View File

@ -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!" ]
]
]
}

View File

@ -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

View File

@ -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 " ]"
]

View File

@ -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

View File

@ -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)
}
}

View File

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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -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" ]
]

View File

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

View File

@ -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" ]
]

View File

@ -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

View File

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

View File

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

200
index.js
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

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