├── .gitignore ├── LICENSE ├── README.md ├── docs ├── README.md ├── elm.json ├── netlify.toml ├── public │ ├── content │ │ ├── examples.md │ │ ├── examples │ │ │ ├── 01-hello-world.md │ │ │ ├── 02-pages.md │ │ │ ├── 03-storage.md │ │ │ └── 04-authentication.md │ │ ├── guide.md │ │ ├── guide │ │ │ ├── 01-cli.md │ │ │ ├── 02-routing.md │ │ │ ├── 03-pages.md │ │ │ ├── 04-requests.md │ │ │ ├── 05-shared-state.md │ │ │ └── 06-views.md │ │ └── images │ │ │ ├── 01-hello-world.png │ │ │ ├── 02-pages.png │ │ │ ├── 03-storage.png │ │ │ ├── 04-authentication.png │ │ │ ├── community │ │ │ └── 01-elmcss-patterns.png │ │ │ ├── realworld.png │ │ │ └── this-site.png │ ├── favicon.png │ ├── images │ │ ├── icons │ │ │ ├── brain.svg │ │ │ ├── laptop.svg │ │ │ ├── lock.svg │ │ │ └── magic.svg │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── outlined-to-edge.png │ │ ├── rounded-logo-bg.png │ │ └── screenshot.png │ ├── index.html │ ├── main.js │ ├── robots.txt │ ├── sitemap.xml │ ├── style.css │ └── vendor │ │ ├── prism.css │ │ └── prism.js ├── scripts │ └── generate-index.js └── src │ ├── Domain │ └── Index.elm │ ├── Main.elm │ ├── Pages │ ├── Examples.elm │ ├── Examples │ │ └── Section_.elm │ ├── Guide.elm │ ├── Guide │ │ └── Section_.elm │ ├── Home_.elm │ └── NotFound.elm │ ├── Ports.elm │ ├── Shared.elm │ ├── UI.elm │ ├── UI │ ├── Docs.elm │ ├── Layout.elm │ ├── Searchbar.elm │ └── Sidebar.elm │ └── Utils │ └── String.elm ├── elm.json ├── examples ├── 01-hello-world │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ │ └── index.html │ └── src │ │ └── Pages │ │ └── Home_.elm ├── 02-pages │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ │ ├── index.html │ │ └── style.css │ └── src │ │ ├── Pages │ │ ├── Advanced.elm │ │ ├── Dynamic │ │ │ └── Name_.elm │ │ ├── Element.elm │ │ ├── Home_.elm │ │ ├── Sandbox.elm │ │ └── Static.elm │ │ ├── Shared.elm │ │ └── UI.elm ├── 03-local-storage │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ │ ├── index.html │ │ └── main.js │ └── src │ │ ├── Pages │ │ └── Home_.elm │ │ ├── Shared.elm │ │ └── Storage.elm ├── 04-authentication │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ │ ├── index.html │ │ └── main.js │ └── src │ │ ├── Auth.elm │ │ ├── Domain │ │ └── User.elm │ │ ├── Pages │ │ ├── Home_.elm │ │ └── SignIn.elm │ │ ├── Shared.elm │ │ ├── Storage.elm │ │ └── UI.elm ├── 05-vite │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── package.json │ ├── public │ │ ├── index.html │ │ └── main.js │ ├── src │ │ └── Pages │ │ │ └── Home_.elm │ └── vite.config.js ├── 06-testing │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── Pages │ │ │ └── Home_.elm │ │ └── Utils │ │ │ └── String.elm │ └── tests │ │ ├── ProgramTests │ │ └── Homepage.elm │ │ └── UnitTests │ │ └── Utils │ │ └── StringTest.elm └── 07-elm-ui │ ├── .gitignore │ ├── README.md │ ├── elm.json │ ├── public │ └── index.html │ └── src │ ├── Pages │ └── Home_.elm │ └── View.elm └── src ├── ElmSpa ├── Page.elm └── Request.elm └── cli ├── .npmignore ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── cli.ts ├── cli │ ├── _common.ts │ ├── add.ts │ ├── build.ts │ ├── help.ts │ ├── init.ts │ ├── server.ts │ └── watch.ts ├── config.ts ├── defaults │ ├── Auth.elm │ ├── Effect.elm │ ├── Main.elm │ ├── Pages │ │ └── NotFound.elm │ ├── Shared.elm │ └── View.elm ├── file.ts ├── index.ts ├── new │ ├── README.md │ ├── _gitignore │ ├── elm.json │ ├── public │ │ └── index.html │ └── src │ │ └── Pages │ │ └── Home_.elm ├── templates │ ├── add.ts │ ├── add │ │ ├── advanced.elm │ │ ├── element.elm │ │ ├── sandbox.elm │ │ └── static.elm │ ├── model.ts │ ├── msg.ts │ ├── page.ts │ ├── pages.ts │ ├── params.ts │ ├── request.ts │ ├── routes.ts │ └── utils.ts ├── terminal.ts └── types.ts ├── tests ├── file.spec.ts └── templates │ └── utils.spec.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist 6 | 7 | # Local Netlify folder 8 | .netlify -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-present, Ryan Haskell-Glatz 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Ryan Haskell-Glatz nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [![elm-spa](https://v6.elm-spa.dev/images/rounded-logo-bg.png)](https://elm-spa.dev) 2 | 3 | # **Installation** 4 | 5 | ```bash 6 | npm install -g elm-spa@latest 7 | ``` 8 | 9 | # **Quick start** 10 | 11 | ## **1. Create a new project** 12 | 13 | ```bash 14 | npx elm-spa new 15 | ``` 16 | 17 | ## **2. Check out the new files** 18 | 19 | ```bash 20 | your-new-project/ 21 | - elm.json 22 | - src/Pages/Home_.elm 23 | - public/index.html 24 | ``` 25 | 26 | ## **3. Run it in your browser** 27 | 28 | ```bash 29 | npx elm-spa server # Ready at http://localhost:1234 30 | ``` 31 | 32 | # **Learn more** 33 | 34 | __Visit the official site__ at [elm-spa.dev](https://elm-spa.dev) for more examples, guides, and other documentation. 35 | 36 | ### **Do I need the Elm package?** 37 | 38 | If you are using elm-spa, there's no need to read the [ryan-haskell/elm-spa](https://package.elm-lang.org/packages/ryan-haskell/elm-spa/latest/) package documentation. The package only exists to constrain the CLI, and provides a few basic internal helper functions. 39 | 40 | Check out [the official website](https://elm-spa.dev) instead! 41 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # elm-spa.dev 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ![Screenshot of the site](./public/images/screenshot.png) 5 | 6 | ## dependencies 7 | 8 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 9 | 10 | ```bash 11 | npm install -g elm-spa 12 | ``` 13 | 14 | ## running locally 15 | 16 | ```bash 17 | elm-spa server # starts this app at http:/localhost:1234 18 | ``` 19 | 20 | ### other commands 21 | 22 | ```bash 23 | elm-spa add # add a new page to the application 24 | elm-spa build # one-time production build 25 | elm-spa watch # builds code as you go (without the server) 26 | ``` 27 | 28 | ## learn more 29 | 30 | You can learn more at [elm-spa.dev](https://elm-spa.dev) 31 | 32 | -------------------------------------------------------------------------------- /docs/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "dillonkearns/elm-markdown": "5.1.1", 12 | "elm/browser": "1.0.2", 13 | "elm/core": "1.0.5", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "ryan-haskell/elm-spa": "1.0.0" 19 | }, 20 | "indirect": { 21 | "elm/bytes": "1.0.8", 22 | "elm/file": "1.0.5", 23 | "elm/parser": "1.1.0", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "public" 3 | command = "npm i elm@latest && node scripts/generate-index.js && npx elm-spa build" 4 | 5 | # Prevents missing markdown files from redirecting to index.html 6 | [[redirects]] 7 | from = "/content/*" 8 | to = "/content/:splat" 9 | status = 200 10 | force = true 11 | 12 | [[redirects]] 13 | from = "/*" 14 | to = "/index.html" 15 | status = 200 16 | -------------------------------------------------------------------------------- /docs/public/content/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Prefer to learn by example? Wonderful! The source code for all of the examples on this site can be found in the GitHub repo's [examples](https://github.com/ryan-haskell/elm-spa/tree/main/examples) folder. 4 | 5 | ### Hello, world! 6 | 7 | Get an introduction to the framework with a simple app. 8 | 9 | [![Example 1 screenshot](/content/images/01-hello-world.png)](/examples/01-hello-world) 10 | 11 | ### Pages 12 | 13 | Learn how pages and URL routing work together. 14 | 15 | [![Example 2 screenshot](/content/images/02-pages.png)](/examples/02-pages) 16 | 17 | ### Local storage 18 | 19 | Use ports and local storage to persist data on refresh. 20 | 21 | [![Example 3 screenshot](/content/images/03-storage.png)](/examples/03-storage) 22 | 23 | ### User authentication 24 | 25 | Explore the elm-spa's user authentication API. 26 | 27 | [![Example 4 screenshot](/content/images/04-authentication.png)](/examples/04-authentication) 28 | 29 | ## Real world examples 30 | 31 | ### elm-css patterns 32 | 33 | _Author_: [@bigardone](https://github.com/bigardone) 34 | 35 | [![elm-css patterns screenshot](/content/images/community/01-elmcss-patterns.png)](https://elmcsspatterns.io/) 36 | 37 | Source code: [GitHub](https://github.com/bigardone/elm-css-patterns) 38 | 39 | ### RealWorld Conduit App 40 | 41 | _Author_: [@ryan-haskell](https://github.com/ryan-haskell) 42 | 43 | Implements the [RealWorld app](https://github.com/gothinkster/realworld), inspired by Richard Feldman's "elm-spa-example" project. 44 | 45 | [![Realworld app screenshot](/content/images/realworld.png)](https://realworld.elm-spa.dev) 46 | 47 | Source code: [GitHub](https://github.com/ryan-haskell/elm-spa-realworld) 48 | 49 | ### This website 50 | 51 | _Author_: [@ryan-haskell](https://github.com/ryan-haskell) 52 | 53 | The website you are looking at _right now_ was built with __elm-spa__. Mindbending, right? 54 | 55 | [![Realworld app screenshot](/content/images/this-site.png)](https://elm-spa.dev) 56 | 57 | Source code: [GitHub](https://github.com/ryan-haskell/elm-spa/tree/main/docs) 58 | 59 | ## More examples 60 | 61 | There are more examples available on the official repo: 62 | 63 | __[Working with NPM 🔗](https://github.com/ryan-haskell/elm-spa/tree/main/examples/05-vite)__ 64 | 65 | Use [Vite](https://vitejs.dev/) instead of the default __elm-spa server__ command. This gives you access to NPM, reading environment variables, and fancier JS ecosystem stuff. 66 | 67 | __[Testing 🔗](https://github.com/ryan-haskell/elm-spa/tree/main/examples/06-testing)__ 68 | 69 | Use [elm-test](https://github.com/elm-explorations/test) and [elm-program-test](https://elm-program-test.netlify.app/) to write unit and end-to-end tests for your single page application. 70 | 71 | __[Using Elm UI 🔗](https://github.com/ryan-haskell/elm-spa/tree/main/examples/07-elm-ui)__ 72 | 73 | Use the wonderful [elm-ui](https://package.elm-lang.org/packages/mdgriffith/elm-ui/latest) package to create web UIs without the need for HTML or CSS. This example can also be applied to [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest/) or any other custom UI of your choice. -------------------------------------------------------------------------------- /docs/public/content/examples/01-hello-world.md: -------------------------------------------------------------------------------- 1 | # Hello, world! 2 | 3 | __Source code__: [GitHub](https://github.com/ryan-haskell/elm-spa/tree/main/examples/01-hello-world) 4 | 5 | Welcome to __elm-spa__! This guide is a breakdown of the simplest project you can make: the "Hello, world!" example. 6 | 7 | ### Installation 8 | 9 | In case you are starting from scratch, you can install __elm-spa__ via NPM: 10 | 11 | ```terminal 12 | npm install -g elm-spa@latest 13 | ``` 14 | 15 | ### Creating a project 16 | 17 | This will allow you to create a new project using the following commands: 18 | 19 | ```terminal 20 | elm-spa new 21 | ``` 22 | 23 | 24 | 25 | 26 | When we ran `elm-spa new`, only __three__ files were created: 27 | 28 | - __public/index.html__ - the entrypoint for our web app. 29 | - __src/Pages/Home\_.elm__ - the homepage. 30 | - __elm.json__ - our project dependencies. 31 | 32 | ### Running the server 33 | 34 | With only these files, we can get an application up-and-running: 35 | 36 | ```terminal 37 | elm-spa server 38 | ``` 39 | 40 | This runs a server at [http://localhost:1234](http://localhost:1234). If everything worked, you should see this in your browser: 41 | 42 | ![A page that reads "Hello World"](/content/images/01-hello-world.png) 43 | 44 | 45 | ### The entrypoint 46 | 47 | Earlier, I mentioned that `public/index.html` was the "entrypoint" to our web app. Let's take a look at that file: 48 | 49 | ```html 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ``` 62 | 63 | This HTML file defines some standard tags, and then runs our Elm application. Because our Elm compiles to JavaScript, the `elm-spa server` command generates a `/dist/elm.js` file anytime we make changes. 64 | 65 | Once we import that with a ` 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/public/main.js: -------------------------------------------------------------------------------- 1 | const app = Elm.Main.init({ flags: window.__FLAGS__ }) 2 | 3 | // Handle smoothly scrolling to links 4 | const scrollToHash = () => { 5 | const BREAKPOINT_XL = 1920 6 | const NAVBAR_HEIGHT_PX = window.innerWidth > BREAKPOINT_XL ? 127 : 102 7 | const element = window.location.hash && document.querySelector(window.location.hash) 8 | if (element) { 9 | // element.scrollIntoView({ behavior: 'smooth' }) 10 | window.scroll({ behavior: 'smooth', top: window.pageYOffset + element.getBoundingClientRect().top - NAVBAR_HEIGHT_PX }) 11 | } else { 12 | window.scroll({ behavior: 'smooth', top: 0 }) 13 | } 14 | } 15 | 16 | app.ports.onUrlChange.subscribe(_ => setTimeout(scrollToHash, 400)) 17 | setTimeout(scrollToHash, 400) 18 | 19 | // Quick search shortcut (/) 20 | window.addEventListener('keypress', (e) => { 21 | if (e.key === '/') { 22 | const el = document.getElementById('quick-search') 23 | if (el && el !== document.activeElement) { 24 | el.focus() 25 | el.select() 26 | e.preventDefault() 27 | } 28 | } 29 | return false 30 | }) 31 | 32 | // HighlightJS custom element 33 | customElements.define('prism-js', class HighlightJS extends HTMLElement { 34 | constructor() { super() } 35 | connectedCallback() { 36 | const pre = document.createElement('pre') 37 | 38 | pre.className = this.language ? `language-${this.language}` : `language-elm` 39 | pre.textContent = this.body 40 | 41 | this.appendChild(pre) 42 | window.Prism.highlightElement(pre) 43 | } 44 | }) 45 | 46 | // Dropdown arrow key support 47 | customElements.define('dropdown-arrow-keys', class DropdownArrowKeys extends HTMLElement { 48 | constructor() { 49 | super() 50 | } 51 | connectedCallback() { 52 | const component = this 53 | const arrows = { ArrowUp: -1, ArrowDown: 1 } 54 | const interactiveChildren = () => component.querySelectorAll('input, a, button') 55 | 56 | const onBlur = (e) => window.requestAnimationFrame(_ => { 57 | const active = document.activeElement 58 | const siblings = interactiveChildren() 59 | let foundFocusedSibling = false 60 | 61 | e.target.removeEventListener('blur', onBlur) 62 | 63 | siblings.forEach(sibling => { 64 | if (sibling === active) { 65 | sibling.addEventListener('blur', onBlur) 66 | foundFocusedSibling = true 67 | } 68 | }) 69 | if (foundFocusedSibling === false) { 70 | component.dispatchEvent(new CustomEvent('clearDropdown')) 71 | siblings.forEach(el => el.addEventListener('focus', _ => el.addEventListener('blur', onBlur))) 72 | } 73 | }) 74 | 75 | interactiveChildren().forEach(el => el.addEventListener('blur', onBlur)) 76 | 77 | component.addEventListener('keydown', (e) => { 78 | const delta = arrows[e.key] 79 | if (delta) { 80 | e.preventDefault() 81 | const interactive = interactiveChildren() 82 | const count = interactive.length 83 | const active = document.activeElement 84 | if (count < 2) return 85 | 86 | interactive.forEach((el, i) => { 87 | if (active == el) { 88 | const next = interactive[(i + delta + count) % count] 89 | next.focus() 90 | } 91 | }) 92 | } 93 | }) 94 | } 95 | }) 96 | 97 | window.addEventListener('keyup', (e) => { 98 | const el = document.getElementById('quick-search') 99 | if (e.key === 'Escape' && el === document.activeElement) { 100 | if (el) el.blur() 101 | } 102 | }) -------------------------------------------------------------------------------- /docs/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /docs/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://www.elm-spa.dev/ 4 | 5 | 6 | https://www.elm-spa.dev/guide 7 | 8 | 9 | https://www.elm-spa.dev/examples 10 | 11 | 12 | https://www.elm-spa.dev/guide/01-cli 13 | 14 | 15 | https://www.elm-spa.dev/guide/02-routing 16 | 17 | 18 | https://www.elm-spa.dev/guide/03-pages 19 | 20 | 21 | https://www.elm-spa.dev/guide/04-requests 22 | 23 | 24 | https://www.elm-spa.dev/guide/05-shared-state 25 | 26 | 27 | https://www.elm-spa.dev/guide/06-views 28 | 29 | 30 | https://www.elm-spa.dev/examples/01-hello-world 31 | 32 | 33 | https://www.elm-spa.dev/examples/02-pages 34 | 35 | 36 | https://www.elm-spa.dev/examples/03-storage 37 | 38 | 39 | https://www.elm-spa.dev/examples/04-authentication 40 | 41 | -------------------------------------------------------------------------------- /docs/public/vendor/prism.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | code[class*=language-elm],pre[class*=language-elm]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-elm]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-elm],pre[class*=language-elm]{background:#2d2d2d}:not(pre)>code[class*=language-elm]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} -------------------------------------------------------------------------------- /docs/scripts/generate-index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const path = require('path') 3 | 4 | const config = { 5 | content: path.join(__dirname, '..', 'public', 'content'), 6 | output: path.join(__dirname, '..', 'public', 'dist') 7 | } 8 | 9 | // Terminal color output 10 | const green = `` 11 | const reset = `` 12 | 13 | // Recursively lists all files in the given folder 14 | const listContainedFiles = async (folder) => { 15 | let files = [] 16 | const items = await fs.readdir(folder) 17 | 18 | await Promise.all(items.map(async item => { 19 | const filepath = path.join(folder, item) 20 | const stat = await fs.stat(filepath) 21 | if (stat.isDirectory()) { 22 | const innerFiles = await listContainedFiles(filepath) 23 | files = files.concat(innerFiles) 24 | } else { 25 | files.push(filepath) 26 | } 27 | })) 28 | 29 | return files 30 | } 31 | 32 | // The entrypoint to my script 33 | const main = () => 34 | listContainedFiles(config.content) 35 | .then(files => 36 | Promise.all(files.map(async f => { 37 | const url = f.substring(config.content.length, f.length - '.md'.length) 38 | const content = await fs.readFile(f, { encoding: 'utf-8' }) 39 | const headers = 40 | content.split('\n') 41 | .reduce((acc, line) => { 42 | if (line.startsWith('# ')) { 43 | acc[line.substring(2)] = 1 44 | } else if (line.startsWith('## ')) { 45 | acc[line.substring(3)] = 2 46 | } 47 | 48 | return acc 49 | }, {}) 50 | 51 | return { url, headers } 52 | })) 53 | ) 54 | .then(json => `window.__FLAGS__ = ${JSON.stringify(json, null, 2)}`) 55 | .then(async contents => { 56 | await fs.mkdir(config.output, { recursive: true }) 57 | return fs.writeFile(path.join(config.output, 'flags.js'), contents, { encoding: 'utf-8' }) 58 | }) 59 | .then(_ => console.info(`\n ${green}✓${reset} Indexed the content folder\n`)) 60 | .catch(console.error) 61 | 62 | // Run the program 63 | main() -------------------------------------------------------------------------------- /docs/src/Domain/Index.elm: -------------------------------------------------------------------------------- 1 | module Domain.Index exposing 2 | ( Index, decoder 3 | , Link, search 4 | , Section, sections 5 | ) 6 | 7 | {-| 8 | 9 | @docs Index, decoder 10 | @docs Link, search 11 | @docs Section, sections 12 | 13 | -} 14 | 15 | import Dict exposing (Dict) 16 | import Html exposing (Html) 17 | import Json.Decode as Json 18 | import Utils.String 19 | 20 | 21 | type alias Index = 22 | List IndexedPage 23 | 24 | 25 | decoder : Json.Decoder Index 26 | decoder = 27 | let 28 | indexedPageDecoder : Json.Decoder IndexedPage 29 | indexedPageDecoder = 30 | Json.map2 IndexedPage 31 | (Json.field "url" Json.string) 32 | (Json.field "headers" (Json.dict Json.int)) 33 | in 34 | Json.list indexedPageDecoder 35 | 36 | 37 | type alias IndexedPage = 38 | { url : String 39 | , headers : Dict String Int 40 | } 41 | 42 | 43 | type alias Link = 44 | { html : Html Never 45 | , label : String 46 | , url : String 47 | , level : Int 48 | } 49 | 50 | 51 | terms : Index -> List ( String, String, Int ) 52 | terms = 53 | List.concatMap 54 | (\page -> 55 | page.headers 56 | |> Dict.toList 57 | |> List.map 58 | (\( header, level ) -> 59 | ( header 60 | , page.url 61 | ++ (if level == 1 then 62 | "" 63 | 64 | else 65 | "#" ++ Utils.String.toId header 66 | ) 67 | , level 68 | ) 69 | ) 70 | ) 71 | 72 | 73 | search : String -> Index -> List Link 74 | search query index = 75 | index 76 | |> terms 77 | |> List.map 78 | (\( label, url, level ) -> 79 | { label = label 80 | , url = url 81 | , level = level 82 | , html = Utils.String.format query label 83 | } 84 | ) 85 | |> List.filter (\link -> Utils.String.caseInsensitiveContains query link.label) 86 | 87 | 88 | 89 | -- SECTIONS 90 | 91 | 92 | type alias Section = 93 | { header : String 94 | , url : String 95 | , pages : List SectionLink 96 | } 97 | 98 | 99 | type alias SectionLink = 100 | { label : String 101 | , url : String 102 | } 103 | 104 | 105 | sections : Index -> List Section 106 | sections index = 107 | let 108 | sectionOrder = 109 | [ "Guide" 110 | , "Examples" 111 | ] 112 | 113 | toLabelUrls = 114 | List.filterMap 115 | (\doc -> 116 | doc.headers 117 | |> Dict.filter (\_ level -> level == 1) 118 | |> Dict.toList 119 | |> List.head 120 | |> Maybe.map (Tuple.first >> (\label -> { label = label, url = doc.url })) 121 | ) 122 | 123 | topLevelLabelUrls : List { label : String, url : String } 124 | topLevelLabelUrls = 125 | let 126 | isOneLevelDeep doc = 127 | List.length (String.split "/" doc.url) == 2 128 | in 129 | index 130 | |> List.filter isOneLevelDeep 131 | |> toLabelUrls 132 | 133 | toSection top children = 134 | { header = top.label 135 | , url = top.url 136 | , pages = children 137 | } 138 | in 139 | topLevelLabelUrls 140 | |> List.map 141 | (\top -> 142 | index 143 | |> List.filter (.url >> (\url -> String.startsWith top.url url && url /= top.url)) 144 | |> toLabelUrls 145 | |> List.sortBy .url 146 | |> toSection top 147 | ) 148 | |> List.sortBy 149 | (\section -> 150 | sectionOrder 151 | |> List.indexedMap Tuple.pair 152 | |> List.filter (Tuple.second >> (==) section.header) 153 | |> List.map Tuple.first 154 | |> List.head 155 | |> Maybe.withDefault -1 156 | ) 157 | -------------------------------------------------------------------------------- /docs/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser 4 | import Browser.Navigation as Nav exposing (Key) 5 | import Effect 6 | import Gen.Model 7 | import Gen.Pages as Pages 8 | import Gen.Route as Route 9 | import Ports 10 | import Request 11 | import Shared 12 | import Url exposing (Url) 13 | import View 14 | 15 | 16 | main : Program Shared.Flags Model Msg 17 | main = 18 | Browser.application 19 | { init = init 20 | , update = update 21 | , view = view 22 | , subscriptions = subscriptions 23 | , onUrlChange = ChangedUrl 24 | , onUrlRequest = ClickedLink 25 | } 26 | 27 | 28 | 29 | -- INIT 30 | 31 | 32 | type alias Model = 33 | { url : Url 34 | , key : Key 35 | , shared : Shared.Model 36 | , page : Pages.Model 37 | } 38 | 39 | 40 | init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg ) 41 | init flags url key = 42 | let 43 | ( shared, sharedCmd ) = 44 | Shared.init (Request.create () url key) flags 45 | 46 | ( page, effect ) = 47 | Pages.init (Route.fromUrl url) shared url key 48 | in 49 | ( Model url key shared page 50 | , Cmd.batch 51 | [ Cmd.map Shared sharedCmd 52 | , Effect.toCmd ( Shared, Page ) effect 53 | ] 54 | ) 55 | 56 | 57 | 58 | -- UPDATE 59 | 60 | 61 | type Msg 62 | = ChangedUrl Url 63 | | ClickedLink Browser.UrlRequest 64 | | Shared Shared.Msg 65 | | Page Pages.Msg 66 | 67 | 68 | update : Msg -> Model -> ( Model, Cmd Msg ) 69 | update msg model = 70 | case msg of 71 | ClickedLink (Browser.Internal url) -> 72 | ( model 73 | , Nav.pushUrl model.key (Url.toString url) 74 | ) 75 | 76 | ClickedLink (Browser.External url) -> 77 | ( model 78 | , Nav.load url 79 | ) 80 | 81 | ChangedUrl url -> 82 | if url.path /= model.url.path then 83 | let 84 | ( page, effect ) = 85 | Pages.init (Route.fromUrl url) model.shared url model.key 86 | in 87 | ( { model | url = url, page = page } 88 | , Cmd.batch 89 | [ Effect.toCmd ( Shared, Page ) effect 90 | , Ports.onUrlChange () 91 | ] 92 | ) 93 | 94 | else 95 | ( { model | url = url } 96 | , Ports.onUrlChange () 97 | ) 98 | 99 | Shared sharedMsg -> 100 | let 101 | ( shared, sharedCmd ) = 102 | Shared.update (Request.create () model.url model.key) sharedMsg model.shared 103 | 104 | ( page, effect ) = 105 | Pages.init (Route.fromUrl model.url) shared model.url model.key 106 | in 107 | if page == Gen.Model.Redirecting_ then 108 | ( { model | shared = shared, page = page } 109 | , Cmd.batch 110 | [ Cmd.map Shared sharedCmd 111 | , Effect.toCmd ( Shared, Page ) effect 112 | ] 113 | ) 114 | 115 | else 116 | ( { model | shared = shared } 117 | , Cmd.map Shared sharedCmd 118 | ) 119 | 120 | Page pageMsg -> 121 | let 122 | ( page, effect ) = 123 | Pages.update pageMsg model.page model.shared model.url model.key 124 | in 125 | ( { model | page = page } 126 | , Effect.toCmd ( Shared, Page ) effect 127 | ) 128 | 129 | 130 | 131 | -- VIEW 132 | 133 | 134 | view : Model -> Browser.Document Msg 135 | view model = 136 | Pages.view model.page model.shared model.url model.key 137 | |> View.map Page 138 | |> View.toBrowserDocument 139 | 140 | 141 | 142 | -- SUBSCRIPTIONS 143 | 144 | 145 | subscriptions : Model -> Sub Msg 146 | subscriptions model = 147 | Sub.batch 148 | [ Pages.subscriptions model.page model.shared model.url model.key |> Sub.map Page 149 | , Shared.subscriptions (Request.create () model.url model.key) model.shared |> Sub.map Shared 150 | ] 151 | -------------------------------------------------------------------------------- /docs/src/Pages/Examples.elm: -------------------------------------------------------------------------------- 1 | module Pages.Examples exposing (Model, Msg, page) 2 | 3 | import Page 4 | import Request 5 | import Shared 6 | import UI.Docs 7 | 8 | 9 | page : Shared.Model -> Request.With params -> Page.With Model Msg 10 | page = 11 | UI.Docs.page 12 | 13 | 14 | type alias Model = 15 | UI.Docs.Model 16 | 17 | 18 | type alias Msg = 19 | UI.Docs.Msg 20 | -------------------------------------------------------------------------------- /docs/src/Pages/Examples/Section_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Examples.Section_ exposing (Model, Msg, page) 2 | 3 | import Page 4 | import Request 5 | import Shared 6 | import UI.Docs 7 | 8 | 9 | page : Shared.Model -> Request.With params -> Page.With Model Msg 10 | page = 11 | UI.Docs.page 12 | 13 | 14 | type alias Model = 15 | UI.Docs.Model 16 | 17 | 18 | type alias Msg = 19 | UI.Docs.Msg 20 | -------------------------------------------------------------------------------- /docs/src/Pages/Guide.elm: -------------------------------------------------------------------------------- 1 | module Pages.Guide exposing (Model, Msg, page) 2 | 3 | import Page 4 | import Request 5 | import Shared 6 | import UI.Docs 7 | 8 | 9 | page : Shared.Model -> Request.With params -> Page.With Model Msg 10 | page = 11 | UI.Docs.page 12 | 13 | 14 | type alias Model = 15 | UI.Docs.Model 16 | 17 | 18 | type alias Msg = 19 | UI.Docs.Msg 20 | -------------------------------------------------------------------------------- /docs/src/Pages/Guide/Section_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Guide.Section_ exposing (Model, Msg, page) 2 | 3 | import Page 4 | import Request 5 | import Shared 6 | import UI.Docs 7 | 8 | 9 | page : Shared.Model -> Request.With params -> Page.With Model Msg 10 | page = 11 | UI.Docs.page 12 | 13 | 14 | type alias Model = 15 | UI.Docs.Model 16 | 17 | 18 | type alias Msg = 19 | UI.Docs.Msg 20 | -------------------------------------------------------------------------------- /docs/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (Model, Msg, page) 2 | 3 | import Gen.Params.Home_ exposing (Params) 4 | import Gen.Route exposing (Route) 5 | import Html 6 | import Html.Attributes as Attr 7 | import Page 8 | import Request 9 | import Shared 10 | import UI exposing (Html) 11 | import UI.Layout 12 | import View exposing (View) 13 | 14 | 15 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 16 | page = 17 | UI.Layout.pageFullWidth 18 | { view = view 19 | } 20 | 21 | 22 | type alias Model = 23 | UI.Layout.Model 24 | 25 | 26 | type alias Msg = 27 | UI.Layout.Msg 28 | 29 | 30 | view : View Msg 31 | view = 32 | { title = "elm-spa" 33 | , body = 34 | [ Html.div [ Attr.class "row center-x" ] 35 | [ UI.hero 36 | { title = "elm-spa" 37 | , description = "single page apps made easy" 38 | } 39 | ] 40 | , alternatingMarkdownSections 41 | [ ( "laptop" 42 | , """ 43 | ## Build reliable applications with Elm 44 | 45 | With __elm-spa__, you can create production-ready applications with one command: 46 | 47 | ```terminal 48 | npx elm-spa new 49 | ``` 50 | 51 | No need to configure webpack, gulp, or any other NPM dev tools. This __zero-configuration__ CLI comes with a live-reloading dev server, production-ready build commands, and even a few scaffolding commands for new and existing applications. 52 | """ 53 | , [ ( "Explore the CLI", Gen.Route.Guide__Section_ { section = "01-cli" } ) 54 | ] 55 | ) 56 | , ( "magic" 57 | , """ 58 | ## Automatic routing 59 | 60 | With __elm-spa__, routing is automatically generated for you based on a standard file-structure convention. This means you'll be able to navigate any project, making it great for onboarding new hires or collaborating with a team! 61 | """ 62 | , [ ( "Learn how routing works", Gen.Route.Guide__Section_ { section = "02-routing" } ) 63 | ] 64 | ) 65 | , ( "lock" 66 | , """ 67 | ## User authentication 68 | 69 | The latest release comes with a simple way to setup user authentication. Use the `Page.protected` API to easily guarantee only logged-in users can view certain pages. 70 | """ 71 | , [ ( "See it in action", Gen.Route.Examples__Section_ { section = "04-authentication" } ) 72 | ] 73 | ) 74 | , ( "brain" 75 | , """ 76 | ## Ready to learn more? 77 | 78 | Awesome! Check out the official guide to learn the concepts, or start by looking at a collection of examples. 79 | """ 80 | , [ ( "Read the guide", Gen.Route.Guide ) 81 | , ( "View examples", Gen.Route.Examples ) 82 | ] 83 | ) 84 | ] 85 | ] 86 | } 87 | 88 | 89 | alternatingMarkdownSections : List ( String, String, List ( String, Route ) ) -> Html msg 90 | alternatingMarkdownSections sections = 91 | let 92 | viewSection i ( emoji, str, buttons ) = 93 | Html.section [ Attr.class "home__section" ] 94 | [ Html.div [ Attr.class "home__section-row container relative row", Attr.classList [ ( "align-right", modBy 2 i == 1 ) ] ] 95 | [ Html.img [ Attr.class "home__section-icon", Attr.src ("/images/icons/" ++ emoji ++ ".svg"), Attr.alt emoji ] [] 96 | , Html.div [ Attr.class "col gap-lg" ] 97 | [ UI.markdown { withHeaderLinks = False } str 98 | , Html.div [ Attr.class "row gap-md" ] 99 | (List.map 100 | (\( label, route ) -> Html.a [ Attr.class "button", Attr.href (Gen.Route.toHref route) ] [ Html.text label ]) 101 | buttons 102 | ) 103 | ] 104 | ] 105 | ] 106 | in 107 | Html.main_ [ Attr.class "col" ] 108 | (List.indexedMap viewSection sections) 109 | -------------------------------------------------------------------------------- /docs/src/Pages/NotFound.elm: -------------------------------------------------------------------------------- 1 | module Pages.NotFound exposing (Model, Msg, page) 2 | 3 | import Gen.Params.NotFound exposing (Params) 4 | import Page 5 | import Request 6 | import Shared 7 | import UI 8 | import UI.Layout 9 | import View exposing (View) 10 | 11 | 12 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 13 | page = 14 | UI.Layout.page 15 | { view = view 16 | } 17 | 18 | 19 | type alias Model = 20 | UI.Layout.Model 21 | 22 | 23 | type alias Msg = 24 | UI.Layout.Msg 25 | 26 | 27 | view : View Msg 28 | view = 29 | { title = "404 · elm-spa" 30 | , body = 31 | [ UI.hero 32 | { title = "404" 33 | , description = "that page wasn't found." 34 | } 35 | , UI.markdown { withHeaderLinks = False } "## But that's alright.\n\nThere's always [the homepage](/)!" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /docs/src/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing (onUrlChange) 2 | 3 | import Json.Decode as Json 4 | 5 | 6 | port onUrlChange : () -> Cmd msg 7 | -------------------------------------------------------------------------------- /docs/src/Shared.elm: -------------------------------------------------------------------------------- 1 | module Shared exposing 2 | ( Flags 3 | , Model 4 | , Msg 5 | , init 6 | , subscriptions 7 | , update 8 | ) 9 | 10 | import Browser.Navigation exposing (Key) 11 | import Dict exposing (Dict) 12 | import Domain.Index exposing (Index) 13 | import Json.Decode as Json 14 | import Request exposing (Request) 15 | import Url exposing (Url) 16 | 17 | 18 | type alias Flags = 19 | Json.Value 20 | 21 | 22 | type alias Model = 23 | { index : Index 24 | } 25 | 26 | 27 | type alias Token = 28 | () 29 | 30 | 31 | type Msg 32 | = NoOp 33 | 34 | 35 | 36 | -- INIT 37 | 38 | 39 | init : Request -> Flags -> ( Model, Cmd Msg ) 40 | init _ flags = 41 | ( Model 42 | (flags 43 | |> Json.decodeValue Domain.Index.decoder 44 | |> Result.withDefault [] 45 | ) 46 | , Cmd.none 47 | ) 48 | 49 | 50 | 51 | -- UPDATE 52 | 53 | 54 | update : Request -> Msg -> Model -> ( Model, Cmd Msg ) 55 | update request msg model = 56 | case msg of 57 | NoOp -> 58 | ( model, Cmd.none ) 59 | 60 | 61 | 62 | -- SUBSCRIPTIONS 63 | 64 | 65 | subscriptions : Request -> Model -> Sub Msg 66 | subscriptions request model = 67 | Sub.none 68 | -------------------------------------------------------------------------------- /docs/src/UI/Docs.elm: -------------------------------------------------------------------------------- 1 | module UI.Docs exposing (Model, Msg, page) 2 | 3 | import Http 4 | import Page 5 | import Request 6 | import Shared 7 | import UI 8 | import UI.Layout 9 | import Url exposing (Url) 10 | import View exposing (View) 11 | 12 | 13 | page : Shared.Model -> Request.With params -> Page.With Model Msg 14 | page shared req = 15 | Page.element 16 | { init = init req.url 17 | , update = update 18 | , view = view shared req.url 19 | , subscriptions = \_ -> Sub.none 20 | } 21 | 22 | 23 | 24 | -- INIT 25 | 26 | 27 | type alias Model = 28 | { layout : UI.Layout.Model 29 | , markdown : Fetchable String 30 | } 31 | 32 | 33 | type Fetchable data 34 | = Loading 35 | | Success data 36 | | Failure String 37 | 38 | 39 | withDefault : value -> Fetchable value -> value 40 | withDefault fallback fetchable = 41 | case fetchable of 42 | Success value -> 43 | value 44 | 45 | _ -> 46 | fallback 47 | 48 | 49 | init : Url -> ( Model, Cmd Msg ) 50 | init url = 51 | ( Model UI.Layout.init Loading 52 | , Http.get 53 | { url = "/content" ++ url.path ++ ".md" 54 | , expect = Http.expectString GotMarkdown 55 | } 56 | ) 57 | 58 | 59 | 60 | -- UPDATE 61 | 62 | 63 | type Msg 64 | = Layout UI.Layout.Msg 65 | | GotMarkdown (Result Http.Error String) 66 | 67 | 68 | update : Msg -> Model -> ( Model, Cmd Msg ) 69 | update msg model = 70 | case msg of 71 | Layout layoutMsg -> 72 | ( { model | layout = UI.Layout.update layoutMsg model.layout } 73 | , Cmd.none 74 | ) 75 | 76 | GotMarkdown response -> 77 | let 78 | success markdown = 79 | ( { model | markdown = Success markdown } 80 | , Cmd.none 81 | ) 82 | 83 | failure = 84 | ( { model | markdown = Failure "Couldn't find that section of the guide..." } 85 | , Cmd.none 86 | ) 87 | in 88 | case response of 89 | Ok markdown -> 90 | if String.startsWith " 97 | failure 98 | 99 | 100 | 101 | -- VIEW 102 | 103 | 104 | view : Shared.Model -> Url -> Model -> View Msg 105 | view shared url model = 106 | { title = 107 | case model.markdown of 108 | Loading -> 109 | "" 110 | 111 | Success content -> 112 | let 113 | firstLine = 114 | content 115 | |> String.lines 116 | |> List.head 117 | |> Maybe.withDefault "Guide" 118 | in 119 | String.dropLeft 2 firstLine ++ " | elm-spa" 120 | 121 | Failure _ -> 122 | "Uh oh. | elm-spa" 123 | , body = 124 | UI.Layout.viewDocumentation 125 | { shared = shared 126 | , url = url 127 | , onMsg = Layout 128 | , model = model.layout 129 | } 130 | (withDefault "" model.markdown) 131 | [ case model.markdown of 132 | Loading -> 133 | UI.none 134 | 135 | Failure reason -> 136 | UI.markdown { withHeaderLinks = False } ("# Uh oh.\n\n" ++ reason) 137 | 138 | Success markdown -> 139 | UI.markdown { withHeaderLinks = True } markdown 140 | , UI.gutter 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /docs/src/UI/Layout.elm: -------------------------------------------------------------------------------- 1 | module UI.Layout exposing 2 | ( Model, init 3 | , Msg, update 4 | , viewDefault, viewDocumentation 5 | , page, pageFullWidth 6 | ) 7 | 8 | {-| 9 | 10 | @docs Model, init 11 | @docs Msg, update 12 | @docs viewDefault, viewDocumentation 13 | 14 | -} 15 | 16 | import Gen.Route as Route exposing (Route) 17 | import Html exposing (Html) 18 | import Html.Attributes as Attr 19 | import Page exposing (Page) 20 | import Request exposing (Request) 21 | import Shared 22 | import UI 23 | import UI.Searchbar 24 | import UI.Sidebar 25 | import Url exposing (Url) 26 | import View exposing (View) 27 | 28 | 29 | type alias Model = 30 | { query : String 31 | } 32 | 33 | 34 | init : Model 35 | init = 36 | { query = "" 37 | } 38 | 39 | 40 | type Msg 41 | = OnQueryChange String 42 | 43 | 44 | update : Msg -> Model -> Model 45 | update msg model = 46 | case msg of 47 | OnQueryChange query -> 48 | { model | query = query } 49 | 50 | 51 | viewDefault : 52 | { model : Model 53 | , onMsg : Msg -> msg 54 | , shared : Shared.Model 55 | , url : Url 56 | } 57 | -> List (Html msg) 58 | -> List (Html msg) 59 | viewDefault options view = 60 | [ navbar options 61 | , Html.main_ [ Attr.class "page container pad-x-md" ] view 62 | , footer 63 | ] 64 | 65 | 66 | viewFullWidth : 67 | { model : Model 68 | , onMsg : Msg -> msg 69 | , shared : Shared.Model 70 | , url : Url 71 | } 72 | -> List (Html msg) 73 | -> List (Html msg) 74 | viewFullWidth options view = 75 | [ navbar options 76 | , Html.div [ Attr.class "page" ] view 77 | , footer 78 | ] 79 | 80 | 81 | viewDocumentation : 82 | { model : Model 83 | , onMsg : Msg -> msg 84 | , shared : Shared.Model 85 | , url : Url 86 | } 87 | -> String 88 | -> List (Html msg) 89 | -> List (Html msg) 90 | viewDocumentation options markdownContent view = 91 | [ navbar options 92 | , Html.div [ Attr.class "page container pad-md" ] 93 | [ UI.row.xl [ UI.align.top, UI.padY.lg ] 94 | [ Html.aside [ Attr.class "only-desktop sticky pad-y-lg aside" ] 95 | [ UI.Sidebar.viewSidebar 96 | { index = options.shared.index 97 | , url = options.url 98 | } 99 | ] 100 | , Html.main_ [ Attr.class "flex" ] 101 | [ UI.row.lg [ UI.align.top ] 102 | [ Html.div [ Attr.class "col flex margin-override" ] view 103 | , Html.div [ Attr.class "hidden-mobile sticky pad-y-lg table-of-contents" ] 104 | [ UI.Sidebar.viewTableOfContents 105 | { content = markdownContent 106 | , url = options.url 107 | } 108 | ] 109 | ] 110 | ] 111 | ] 112 | ] 113 | , footer 114 | ] 115 | 116 | 117 | navbar : 118 | { model : Model 119 | , onMsg : Msg -> msg 120 | , shared : Shared.Model 121 | , url : Url 122 | } 123 | -> Html msg 124 | navbar { onMsg, model, shared, url } = 125 | let 126 | navLink : { text : String, route : Route } -> Html msg 127 | navLink options = 128 | let 129 | href : String 130 | href = 131 | Route.toHref options.route 132 | in 133 | Html.a 134 | [ Attr.class "link" 135 | , Attr.href href 136 | , Attr.classList 137 | [ ( "bold text-blue" 138 | , if href == "/" then 139 | href == url.path 140 | 141 | else 142 | String.startsWith href url.path 143 | ) 144 | ] 145 | ] 146 | [ Html.text options.text ] 147 | in 148 | Html.header [ Attr.class "header pad-y-lg pad-x-md" ] 149 | [ Html.div [ Attr.class "container" ] 150 | [ Html.div [ Attr.class "row gap-md spread" ] 151 | [ Html.div [ Attr.class "row align-center gap-lg" ] 152 | [ Html.a [ Attr.class "header__logo", Attr.href "/" ] [ UI.logo ] 153 | , Html.nav [ Attr.class "row gap-md hidden-mobile pad-left-xs" ] 154 | [ navLink { text = "about", route = Route.Home_ } 155 | , navLink { text = "guide", route = Route.Guide } 156 | , navLink { text = "examples", route = Route.Examples } 157 | ] 158 | ] 159 | , Html.div [ Attr.class "row gap-md spread" ] 160 | [ Html.nav [ Attr.class "row gap-md hidden-mobile" ] 161 | [ UI.iconLink { text = "GitHub Repo", icon = UI.icons.github, url = "https://github.com/ryan-haskell/elm-spa" } 162 | , UI.iconLink { text = "NPM Package", icon = UI.icons.npm, url = "https://npmjs.org/elm-spa" } 163 | , UI.iconLink { text = "Elm Package", icon = UI.icons.elm, url = "https://package.elm-lang.org/packages/ryan-haskell/elm-spa/latest" } 164 | ] 165 | , UI.Searchbar.view 166 | { index = shared.index 167 | , query = model.query 168 | , onQueryChange = onMsg << OnQueryChange 169 | } 170 | ] 171 | ] 172 | ] 173 | ] 174 | 175 | 176 | footer : Html msg 177 | footer = 178 | Html.div [ Attr.class "footer__zone" ] 179 | [ Html.footer [ Attr.class "footer container pad-top-xl" ] 180 | [ Html.div [ Attr.class "row pad-x-md pad-y-lg pad-top-xl spread faded" ] 181 | [ Html.a [ Attr.href "https://github.com/ryan-haskell/elm-spa/tree/main/docs", Attr.target "_blank", Attr.class "link hidden-mobile" ] [ Html.text "Site source code" ] 182 | , Html.span [] [ Html.text "© 2019 – 2021, Ryan Haskell-Glatz" ] 183 | ] 184 | ] 185 | ] 186 | 187 | 188 | 189 | -- PAGE 190 | 191 | 192 | page : { view : View Msg } -> Shared.Model -> Request.With params -> Page.With Model Msg 193 | page options shared req = 194 | Page.sandbox 195 | { init = init 196 | , update = update 197 | , view = 198 | \model -> 199 | { title = options.view.title 200 | , body = 201 | viewDefault 202 | { shared = shared 203 | , url = req.url 204 | , model = model 205 | , onMsg = identity 206 | } 207 | options.view.body 208 | } 209 | } 210 | 211 | 212 | pageFullWidth : { view : View Msg } -> Shared.Model -> Request.With params -> Page.With Model Msg 213 | pageFullWidth options shared req = 214 | Page.sandbox 215 | { init = init 216 | , update = update 217 | , view = 218 | \model -> 219 | { title = options.view.title 220 | , body = 221 | viewFullWidth 222 | { shared = shared 223 | , url = req.url 224 | , model = model 225 | , onMsg = identity 226 | } 227 | options.view.body 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /docs/src/UI/Searchbar.elm: -------------------------------------------------------------------------------- 1 | module UI.Searchbar exposing (view) 2 | 3 | import Domain.Index exposing (Index, Link) 4 | import Html exposing (Html) 5 | import Html.Attributes as Attr 6 | import Html.Events as Events 7 | import Json.Decode as Json 8 | 9 | 10 | view : 11 | { index : Index 12 | , query : String 13 | , onQueryChange : String -> msg 14 | } 15 | -> Html msg 16 | view options = 17 | Html.node "dropdown-arrow-keys" 18 | [ Events.on "clearDropdown" (Json.succeed (options.onQueryChange "")) 19 | ] 20 | [ Html.label [ Attr.class "search relative z-2", Attr.attribute "aria-label" "Search" ] 21 | [ Html.input 22 | [ Attr.id "quick-search" 23 | , Attr.class "search__input" 24 | , Attr.type_ "search" 25 | , Attr.placeholder "Search" 26 | , Attr.value options.query 27 | , Events.onInput options.onQueryChange 28 | ] 29 | [] 30 | , Html.div [ Attr.class "search__icon icon icon--search" ] [] 31 | , Html.kbd [ Attr.class "search__kbd" ] [ Html.text "/" ] 32 | , if String.length options.query > 2 then 33 | case Domain.Index.search options.query options.index of 34 | [] -> 35 | viewDropdownWindow 36 | [ Html.span [ Attr.class "faint pad-md" ] [ Html.text "No matches found." ] 37 | ] 38 | 39 | matches -> 40 | viewMatches matches 41 | 42 | else 43 | Html.text "" 44 | ] 45 | ] 46 | 47 | 48 | viewMatches : List Link -> Html msg 49 | viewMatches matches = 50 | viewDropdownWindow 51 | (matches 52 | |> List.sortBy (\link -> ( link.level, link.label |> String.length )) 53 | |> List.map 54 | (\match -> 55 | Html.a [ Attr.class "dropdown__link", Attr.href match.url ] 56 | [ Html.span [ Attr.class "underline" ] [ Html.map never match.html ] 57 | ] 58 | ) 59 | |> List.take 5 60 | ) 61 | 62 | 63 | viewDropdownWindow : List (Html msg) -> Html msg 64 | viewDropdownWindow children = 65 | Html.div [ Attr.class "absolute align-below fill-x pad-top-md" ] 66 | [ Html.div [ Attr.class "col bg-white shadow border rounded" ] 67 | children 68 | ] 69 | -------------------------------------------------------------------------------- /docs/src/UI/Sidebar.elm: -------------------------------------------------------------------------------- 1 | module UI.Sidebar exposing (viewSidebar, viewTableOfContents) 2 | 3 | import Domain.Index exposing (Index) 4 | import Html exposing (Html) 5 | import Html.Attributes as Attr 6 | import Markdown.Block 7 | import Markdown.Html 8 | import Markdown.Parser 9 | import Markdown.Renderer 10 | import UI 11 | import Url exposing (Url) 12 | import Utils.String 13 | 14 | 15 | parseTableOfContents : String -> List Section 16 | parseTableOfContents = 17 | Markdown.Parser.parse 18 | >> Result.mapError (\_ -> "Failed to parse.") 19 | >> Result.andThen (Markdown.Renderer.render tableOfContentsRenderer) 20 | >> Result.withDefault [] 21 | >> List.filterMap identity 22 | >> headersToSections 23 | 24 | 25 | type alias Section = 26 | { header : String 27 | , url : String 28 | , pages : List Link 29 | } 30 | 31 | 32 | type alias Link = 33 | { label : String 34 | , url : String 35 | } 36 | 37 | 38 | type alias Header = 39 | ( HeaderLevel, String, Maybe String ) 40 | 41 | 42 | type HeaderLevel 43 | = Heading2 44 | | Heading3 45 | 46 | 47 | headersToSections : List Header -> List Section 48 | headersToSections = 49 | let 50 | loop : Header -> ( List Section, Maybe Section ) -> ( List Section, Maybe Section ) 51 | loop ( level, text, url_ ) ( sections, current ) = 52 | let 53 | url = 54 | url_ |> Maybe.map (Utils.String.toId >> (++) "#") |> Maybe.withDefault "" 55 | in 56 | case ( level, current ) of 57 | ( Heading2, Just existing ) -> 58 | ( sections ++ [ existing ], Just { header = text, url = url, pages = [] } ) 59 | 60 | ( Heading2, Nothing ) -> 61 | ( sections, Just { header = text, url = url, pages = [] } ) 62 | 63 | ( Heading3, Just existing ) -> 64 | ( sections, Just { existing | pages = existing.pages ++ [ { label = text, url = url } ] } ) 65 | 66 | ( Heading3, Nothing ) -> 67 | ( sections ++ [ { header = text, url = url, pages = [] } ], Nothing ) 68 | in 69 | List.foldl loop ( [], Nothing ) 70 | >> (\( sections, maybe ) -> 71 | maybe 72 | |> Maybe.map (\section -> sections ++ [ section ]) 73 | |> Maybe.withDefault sections 74 | ) 75 | 76 | 77 | tableOfContentsRenderer : Markdown.Renderer.Renderer (Maybe Header) 78 | tableOfContentsRenderer = 79 | { heading = 80 | \{ level, rawText } -> 81 | case level of 82 | Markdown.Block.H1 -> 83 | Just ( Heading2, rawText, Nothing ) 84 | 85 | Markdown.Block.H2 -> 86 | Just ( Heading2, rawText, Just rawText ) 87 | 88 | Markdown.Block.H3 -> 89 | Just ( Heading3, rawText, Just rawText ) 90 | 91 | _ -> 92 | Nothing 93 | , paragraph = \_ -> Nothing 94 | , blockQuote = \_ -> Nothing 95 | , html = Markdown.Html.oneOf [] 96 | , text = \_ -> Nothing 97 | , codeSpan = \_ -> Nothing 98 | , strong = \_ -> Nothing 99 | , emphasis = \_ -> Nothing 100 | , hardLineBreak = Nothing 101 | , link = \_ _ -> Nothing 102 | , image = \_ -> Nothing 103 | , unorderedList = \_ -> Nothing 104 | , orderedList = \_ _ -> Nothing 105 | , codeBlock = \_ -> Nothing 106 | , thematicBreak = Nothing 107 | , table = \_ -> Nothing 108 | , tableHeader = \_ -> Nothing 109 | , tableBody = \_ -> Nothing 110 | , tableRow = \_ -> Nothing 111 | , tableCell = \_ _ -> Nothing 112 | , tableHeaderCell = \_ _ -> Nothing 113 | } 114 | 115 | 116 | viewSidebar : { url : Url, index : Index } -> Html msg 117 | viewSidebar { url, index } = 118 | let 119 | viewSidebarLink : Link -> Html msg 120 | viewSidebarLink link__ = 121 | viewDocumentationLink (url.path == link__.url) link__ 122 | 123 | viewSidebarSection : Section -> Html msg 124 | viewSidebarSection section = 125 | UI.col.sm [ UI.align.left ] 126 | [ Html.a 127 | [ Attr.href section.url 128 | , Attr.classList [ ( "bold text-blue", url.path == section.url ) ] 129 | , Attr.class "h4 bold underline" 130 | ] 131 | [ Html.text section.header ] 132 | , if List.isEmpty section.pages then 133 | Html.text "" 134 | 135 | else 136 | UI.col.md [ Attr.class "border-left pad-y-sm pad-x-md align-left" ] (List.map viewSidebarLink section.pages) 137 | ] 138 | in 139 | UI.col.md [] (List.map viewSidebarSection (Domain.Index.sections index)) 140 | 141 | 142 | viewDocumentationLink : Bool -> Link -> Html msg 143 | viewDocumentationLink isActive link__ = 144 | Html.a 145 | [ Attr.class "link" 146 | , Attr.classList [ ( "bold text-blue", isActive ) ] 147 | , Attr.href link__.url 148 | ] 149 | [ Html.text link__.label ] 150 | 151 | 152 | viewTableOfContents : { url : Url, content : String } -> Html msg 153 | viewTableOfContents { url, content } = 154 | let 155 | viewTableOfContentsLink : Link -> Html msg 156 | viewTableOfContentsLink link__ = 157 | viewDocumentationLink (url.fragment == Nothing && link__.url == "" || (url.fragment |> Maybe.map ((++) "#")) == Just link__.url) link__ 158 | 159 | viewTocSection : Section -> Html msg 160 | viewTocSection section = 161 | Html.div [ Attr.class "col gap-xs align-left" ] 162 | [ viewTableOfContentsLink { label = section.header, url = section.url } 163 | , if List.isEmpty section.pages then 164 | Html.text "" 165 | 166 | else 167 | Html.div [ Attr.class "col pad-left-sm pad-xs gap-sm" ] 168 | (section.pages 169 | |> List.map (\l -> Html.div [ Attr.class "h6" ] [ viewTableOfContentsLink l ]) 170 | ) 171 | ] 172 | in 173 | if String.isEmpty content then 174 | Html.text "" 175 | 176 | else 177 | Html.nav [ Attr.class "col gap-md align-left toc shadow rounded bg-white" ] 178 | [ Html.h4 [ Attr.class "h4 bold" ] [ Html.text "On this page" ] 179 | , Html.div [ Attr.class "col gap-md" ] (List.map viewTocSection (parseTableOfContents content)) 180 | ] 181 | -------------------------------------------------------------------------------- /docs/src/Utils/String.elm: -------------------------------------------------------------------------------- 1 | module Utils.String exposing 2 | ( caseInsensitiveContains 3 | , format 4 | , toId 5 | ) 6 | 7 | import Html exposing (Html) 8 | 9 | 10 | caseInsensitiveContains : String -> String -> Bool 11 | caseInsensitiveContains sub word = 12 | String.contains (String.toLower sub) (String.toLower word) 13 | 14 | 15 | toId : String -> String 16 | toId = 17 | String.toLower 18 | >> String.words 19 | >> List.map (String.filter (\c -> c == '-' || Char.isAlphaNum c)) 20 | >> String.join "-" 21 | 22 | 23 | format : String -> String -> Html msg 24 | format query original = 25 | original 26 | |> String.toLower 27 | |> String.split (String.toLower query) 28 | |> List.indexedMap Tuple.pair 29 | |> List.foldl 30 | (\( index, segment ) ( length, str ) -> 31 | let 32 | nextLength = 33 | length + String.length segment + String.length query 34 | in 35 | ( nextLength 36 | , str 37 | ++ [ original 38 | |> String.dropLeft length 39 | |> String.left (String.length segment) 40 | |> Html.text 41 | ] 42 | ++ (if nextLength > String.length original then 43 | [] 44 | 45 | else 46 | [ original 47 | |> String.dropLeft (length + String.length segment) 48 | |> String.left (String.length query) 49 | |> Html.text 50 | |> List.singleton 51 | |> Html.strong [] 52 | ] 53 | ) 54 | ) 55 | ) 56 | ( 0, [] ) 57 | |> Tuple.second 58 | |> Html.span [] 59 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "ryan-haskell/elm-spa", 4 | "summary": "single page apps made easy.", 5 | "license": "BSD-3-Clause", 6 | "version": "1.0.0", 7 | "exposed-modules": [ 8 | "ElmSpa.Page", 9 | "ElmSpa.Request" 10 | ], 11 | "elm-version": "0.19.0 <= v < 0.20.0", 12 | "dependencies": { 13 | "elm/browser": "1.0.0 <= v < 2.0.0", 14 | "elm/core": "1.0.0 <= v < 2.0.0", 15 | "elm/url": "1.0.0 <= v < 2.0.0" 16 | }, 17 | "test-dependencies": {} 18 | } -------------------------------------------------------------------------------- /examples/01-hello-world/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/01-hello-world/README.md: -------------------------------------------------------------------------------- 1 | # examples/01-hello-world 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) 29 | -------------------------------------------------------------------------------- /examples/01-hello-world/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /examples/01-hello-world/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/01-hello-world/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (view) 2 | 3 | import Html 4 | import View exposing (View) 5 | 6 | 7 | view : View msg 8 | view = 9 | { title = "Homepage" 10 | , body = [ Html.text "Hello, world!" ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/02-pages/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/02-pages/README.md: -------------------------------------------------------------------------------- 1 | # examples/02-pages 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) 29 | -------------------------------------------------------------------------------- /examples/02-pages/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/http": "2.0.0", 15 | "elm/json": "1.1.3", 16 | "elm/url": "1.0.0", 17 | "ryan-haskell/elm-spa": "1.0.0" 18 | }, 19 | "indirect": { 20 | "elm/bytes": "1.0.8", 21 | "elm/file": "1.0.5", 22 | "elm/time": "1.0.0", 23 | "elm/virtual-dom": "1.0.2" 24 | } 25 | }, 26 | "test-dependencies": { 27 | "direct": {}, 28 | "indirect": {} 29 | } 30 | } -------------------------------------------------------------------------------- /examples/02-pages/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/02-pages/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | } 4 | 5 | .container { 6 | max-width: 960px; 7 | margin: 1rem auto; 8 | } 9 | 10 | .navbar { 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .navbar .brand { 16 | font-size: 1.5rem; 17 | } 18 | 19 | .navbar .splitter { flex: 1 1 auto; } 20 | 21 | .navbar a { 22 | margin-right: 16px; 23 | } 24 | 25 | h1, h2 { 26 | margin-top: 3rem; 27 | } -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Advanced.elm: -------------------------------------------------------------------------------- 1 | module Pages.Advanced exposing (Model, Msg, page) 2 | 3 | import Effect exposing (Effect) 4 | import Gen.Params.Advanced exposing (Params) 5 | import Html 6 | import Html.Events as Events 7 | import Page 8 | import Request 9 | import Shared 10 | import UI 11 | import View exposing (View) 12 | 13 | 14 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 15 | page shared req = 16 | Page.advanced 17 | { init = init 18 | , update = update 19 | , view = view shared 20 | , subscriptions = subscriptions 21 | } 22 | 23 | 24 | 25 | -- INIT 26 | 27 | 28 | type alias Model = 29 | {} 30 | 31 | 32 | init : ( Model, Effect Msg ) 33 | init = 34 | ( {}, Effect.none ) 35 | 36 | 37 | 38 | -- UPDATE 39 | 40 | 41 | type Msg 42 | = IncrementShared 43 | | DecrementShared 44 | 45 | 46 | update : Msg -> Model -> ( Model, Effect Msg ) 47 | update msg model = 48 | case msg of 49 | IncrementShared -> 50 | ( model 51 | , Effect.fromShared Shared.Increment 52 | ) 53 | 54 | DecrementShared -> 55 | ( model 56 | , Effect.fromShared Shared.Decrement 57 | ) 58 | 59 | 60 | 61 | -- SUBSCRIPTIONS 62 | 63 | 64 | subscriptions : Model -> Sub Msg 65 | subscriptions model = 66 | Sub.none 67 | 68 | 69 | 70 | -- VIEW 71 | 72 | 73 | view : Shared.Model -> Model -> View Msg 74 | view shared model = 75 | { title = "Advanced" 76 | , body = 77 | UI.layout 78 | [ UI.h1 "Advanced" 79 | , Html.p [] [ Html.text "An advanced page uses Effects instead of Cmds, which allow you to send Shared messages directly from a page." ] 80 | , Html.h2 [] [ Html.text "Shared Counter" ] 81 | , Html.h3 [] [ Html.text (String.fromInt shared.counter) ] 82 | , Html.button [ Events.onClick DecrementShared ] [ Html.text "-" ] 83 | , Html.button [ Events.onClick IncrementShared ] [ Html.text "+" ] 84 | , Html.p [] [ Html.text "This value doesn't reset as you navigate from one page to another (but will on page refresh)!" ] 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Dynamic/Name_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Dynamic.Name_ exposing (page) 2 | 3 | import Gen.Params.Dynamic.Name_ exposing (Params) 4 | import Html exposing (Html) 5 | import Page exposing (Page) 6 | import Request 7 | import Shared 8 | import UI 9 | import View exposing (View) 10 | 11 | 12 | page : Shared.Model -> Request.With Params -> Page 13 | page shared req = 14 | Page.static 15 | { view = view req.params 16 | } 17 | 18 | 19 | view : Params -> View msg 20 | view params = 21 | { title = "Dynamic: " ++ params.name 22 | , body = 23 | UI.layout 24 | [ UI.h1 "Dynamic Page" 25 | , Html.p [] [ Html.text "Dynamic pages with underscores can safely access URL parameters." ] 26 | , Html.p [] [ Html.text "Because this file is named \"Name_.elm\", it has a \"name\" parameter." ] 27 | , Html.p [] [ Html.text "Try changing the URL above to something besides \"apple\" or \"banana\"! " ] 28 | , Html.h2 [] [ Html.text params.name ] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Element.elm: -------------------------------------------------------------------------------- 1 | module Pages.Element exposing (Model, Msg, page) 2 | 3 | import Browser.Dom exposing (Viewport) 4 | import Browser.Events 5 | import Gen.Params.Element exposing (Params) 6 | import Html 7 | import Html.Attributes as Attr 8 | import Html.Events as Events 9 | import Http 10 | import Json.Decode as Json 11 | import Page 12 | import Request 13 | import Shared 14 | import Task 15 | import UI 16 | import View exposing (View) 17 | 18 | 19 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 20 | page shared req = 21 | Page.element 22 | { init = init 23 | , update = update 24 | , view = view 25 | , subscriptions = subscriptions 26 | } 27 | 28 | 29 | 30 | -- INIT 31 | 32 | 33 | type alias Model = 34 | { window : { width : Int, height : Int } 35 | , image : WebRequest 36 | } 37 | 38 | 39 | type WebRequest 40 | = NotAsked 41 | | Success String 42 | | Failure 43 | 44 | 45 | init : ( Model, Cmd Msg ) 46 | init = 47 | ( { window = { width = 0, height = 0 } 48 | , image = NotAsked 49 | } 50 | , Browser.Dom.getViewport 51 | |> Task.perform GotInitialViewport 52 | ) 53 | 54 | 55 | 56 | -- UPDATE 57 | 58 | 59 | type Msg 60 | = ResizedWindow Int Int 61 | | GotInitialViewport Viewport 62 | | ClickedFetchCat 63 | | GotCatGif (Result Http.Error String) 64 | 65 | 66 | update : Msg -> Model -> ( Model, Cmd Msg ) 67 | update msg model = 68 | case msg of 69 | GotInitialViewport { viewport } -> 70 | ( { model 71 | | window = 72 | { width = floor viewport.width 73 | , height = floor viewport.height 74 | } 75 | } 76 | , Cmd.none 77 | ) 78 | 79 | ResizedWindow w h -> 80 | ( { model | window = { width = w, height = h } } 81 | , Cmd.none 82 | ) 83 | 84 | ClickedFetchCat -> 85 | let 86 | gifDecoder = 87 | Json.field "url" Json.string 88 | |> Json.map (\url -> "https://cataas.com" ++ url) 89 | in 90 | ( model 91 | , Http.get 92 | { url = "https://cataas.com/cat?json=true&type=sm" 93 | , expect = Http.expectJson GotCatGif gifDecoder 94 | } 95 | ) 96 | 97 | GotCatGif (Ok url) -> 98 | ( { model | image = Success url } 99 | , Cmd.none 100 | ) 101 | 102 | GotCatGif (Err _) -> 103 | ( { model | image = Failure } 104 | , Cmd.none 105 | ) 106 | 107 | 108 | 109 | -- SUBSCRIPTIONS 110 | 111 | 112 | subscriptions : Model -> Sub Msg 113 | subscriptions model = 114 | Browser.Events.onResize ResizedWindow 115 | 116 | 117 | 118 | -- VIEW 119 | 120 | 121 | view : Model -> View Msg 122 | view model = 123 | { title = "Element" 124 | , body = 125 | UI.layout 126 | [ UI.h1 "Element" 127 | , Html.p [] [ Html.text "An element page can perform side-effects like HTTP requests and subscribe to events from the browser!" ] 128 | , Html.br [] [] 129 | , Html.h2 [] [ Html.text "Commands" ] 130 | , Html.p [] 131 | [ Html.button [ Events.onClick ClickedFetchCat ] [ Html.text "Get a cat" ] 132 | ] 133 | , case model.image of 134 | NotAsked -> 135 | Html.text "" 136 | 137 | Failure -> 138 | Html.text "Something went wrong, please try again." 139 | 140 | Success image -> 141 | Html.img [ Attr.src image, Attr.alt "Cat" ] [] 142 | , Html.br [] [] 143 | , Html.h2 [] [ Html.text "Subscriptions" ] 144 | , Html.p [] 145 | [ Html.strong [] [ Html.text "Window size:" ] 146 | , Html.text (windowSizeToString model.window) 147 | ] 148 | ] 149 | } 150 | 151 | 152 | windowSizeToString : { width : Int, height : Int } -> String 153 | windowSizeToString { width, height } = 154 | "( " ++ String.fromInt width ++ ", " ++ String.fromInt height ++ " )" 155 | -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (view) 2 | 3 | import Html 4 | import UI 5 | import View exposing (View) 6 | 7 | 8 | view : View msg 9 | view = 10 | { title = "Homepage" 11 | , body = 12 | UI.layout 13 | [ Html.h1 [] [ Html.text "Homepage" ] 14 | , Html.p [] [ Html.text "This homepage is just a view function, click the links in the navbar to see more pages!" ] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Sandbox.elm: -------------------------------------------------------------------------------- 1 | module Pages.Sandbox exposing (Model, Msg, page) 2 | 3 | import Gen.Params.Sandbox exposing (Params) 4 | import Html 5 | import Html.Events 6 | import Page 7 | import Request 8 | import Shared 9 | import UI 10 | import View exposing (View) 11 | 12 | 13 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 14 | page shared req = 15 | Page.sandbox 16 | { init = init 17 | , update = update 18 | , view = view 19 | } 20 | 21 | 22 | 23 | -- INIT 24 | 25 | 26 | type alias Model = 27 | { counter : Int 28 | } 29 | 30 | 31 | init : Model 32 | init = 33 | { counter = 0 34 | } 35 | 36 | 37 | 38 | -- UPDATE 39 | 40 | 41 | type Msg 42 | = Increment 43 | | Decrement 44 | 45 | 46 | update : Msg -> Model -> Model 47 | update msg model = 48 | case msg of 49 | Increment -> 50 | { model | counter = model.counter + 1 } 51 | 52 | Decrement -> 53 | { model | counter = model.counter - 1 } 54 | 55 | 56 | 57 | -- VIEW 58 | 59 | 60 | view : Model -> View Msg 61 | view model = 62 | { title = "Sandbox" 63 | , body = 64 | UI.layout 65 | [ UI.h1 "Sandbox" 66 | , Html.p [] [ Html.text "A sandbox page can keep track of state!" ] 67 | , Html.h3 [] [ Html.text (String.fromInt model.counter) ] 68 | , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] 69 | , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /examples/02-pages/src/Pages/Static.elm: -------------------------------------------------------------------------------- 1 | module Pages.Static exposing (page) 2 | 3 | import Gen.Params.Static exposing (Params) 4 | import Html 5 | import Page exposing (Page) 6 | import Request exposing (Request) 7 | import Shared 8 | import UI 9 | import View exposing (View) 10 | 11 | 12 | page : Shared.Model -> Request -> Page 13 | page shared req = 14 | Page.static 15 | { view = view 16 | } 17 | 18 | 19 | view : View msg 20 | view = 21 | { title = "Static" 22 | , body = 23 | UI.layout 24 | [ UI.h1 "Static" 25 | , Html.p [] [ Html.text "A static page only renders a view, but has access to shared state and URL information." ] 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/02-pages/src/Shared.elm: -------------------------------------------------------------------------------- 1 | module Shared exposing 2 | ( Flags 3 | , Model 4 | , Msg(..) 5 | , init 6 | , subscriptions 7 | , update 8 | ) 9 | 10 | import Json.Decode as Json 11 | import Request exposing (Request) 12 | 13 | 14 | type alias Flags = 15 | Json.Value 16 | 17 | 18 | type alias Model = 19 | { counter : Int 20 | } 21 | 22 | 23 | type Msg 24 | = Increment 25 | | Decrement 26 | 27 | 28 | init : Request -> Flags -> ( Model, Cmd Msg ) 29 | init _ _ = 30 | ( { counter = 0 }, Cmd.none ) 31 | 32 | 33 | update : Request -> Msg -> Model -> ( Model, Cmd Msg ) 34 | update _ msg model = 35 | case msg of 36 | Increment -> 37 | ( { model | counter = model.counter + 1 } 38 | , Cmd.none 39 | ) 40 | 41 | Decrement -> 42 | ( { model | counter = model.counter - 1 } 43 | , Cmd.none 44 | ) 45 | 46 | 47 | subscriptions : Request -> Model -> Sub Msg 48 | subscriptions _ _ = 49 | Sub.none 50 | -------------------------------------------------------------------------------- /examples/02-pages/src/UI.elm: -------------------------------------------------------------------------------- 1 | module UI exposing (h1, layout) 2 | 3 | import Gen.Route as Route exposing (Route) 4 | import Html exposing (Html) 5 | import Html.Attributes as Attr 6 | 7 | 8 | layout : List (Html msg) -> List (Html msg) 9 | layout children = 10 | let 11 | viewLink : String -> Route -> Html msg 12 | viewLink label route = 13 | Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ] 14 | in 15 | [ Html.div [ Attr.class "container" ] 16 | [ Html.header [ Attr.class "navbar" ] 17 | [ Html.strong [ Attr.class "brand" ] [ viewLink "Home" Route.Home_ ] 18 | , viewLink "Static" Route.Static 19 | , viewLink "Sandbox" Route.Sandbox 20 | , viewLink "Element" Route.Element 21 | , viewLink "Advanced" Route.Advanced 22 | , Html.div [ Attr.class "splitter" ] [] 23 | , viewLink "Dynamic: Apple" (Route.Dynamic__Name_ { name = "apple" }) 24 | , viewLink "Dynamic: Banana" (Route.Dynamic__Name_ { name = "banana" }) 25 | ] 26 | , Html.main_ [] children 27 | ] 28 | ] 29 | 30 | 31 | h1 : String -> Html msg 32 | h1 label = 33 | Html.h1 [] [ Html.text label ] 34 | -------------------------------------------------------------------------------- /examples/03-local-storage/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/03-local-storage/README.md: -------------------------------------------------------------------------------- 1 | # my new project 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) -------------------------------------------------------------------------------- /examples/03-local-storage/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /examples/03-local-storage/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/03-local-storage/public/main.js: -------------------------------------------------------------------------------- 1 | const app = Elm.Main.init({ 2 | flags: JSON.parse(localStorage.getItem('storage')) 3 | }) 4 | 5 | app.ports.save.subscribe(storage => { 6 | localStorage.setItem('storage', JSON.stringify(storage)) 7 | app.ports.load.send(storage) 8 | }) -------------------------------------------------------------------------------- /examples/03-local-storage/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (Model, Msg, init, page, update, view) 2 | 3 | import Html 4 | import Html.Events 5 | import Page 6 | import Request exposing (Request) 7 | import Shared 8 | import Storage exposing (Storage) 9 | import View exposing (View) 10 | 11 | 12 | page : Shared.Model -> Request -> Page.With Model Msg 13 | page shared _ = 14 | Page.element 15 | { init = init 16 | , update = update shared.storage 17 | , view = view shared.storage 18 | , subscriptions = subscriptions 19 | } 20 | 21 | 22 | 23 | -- INIT 24 | 25 | 26 | type alias Model = 27 | {} 28 | 29 | 30 | init : ( Model, Cmd Msg ) 31 | init = 32 | ( {}, Cmd.none ) 33 | 34 | 35 | 36 | -- UPDATE 37 | 38 | 39 | type Msg 40 | = Increment 41 | | Decrement 42 | 43 | 44 | update : Storage -> Msg -> Model -> ( Model, Cmd Msg ) 45 | update storage msg model = 46 | case msg of 47 | Increment -> 48 | ( model 49 | , Storage.increment storage 50 | ) 51 | 52 | Decrement -> 53 | ( model 54 | , Storage.decrement storage 55 | ) 56 | 57 | 58 | 59 | -- SUBSCRIPTIONS 60 | 61 | 62 | subscriptions : Model -> Sub Msg 63 | subscriptions _ = 64 | Sub.none 65 | 66 | 67 | 68 | -- VIEW 69 | 70 | 71 | view : Storage -> Model -> View Msg 72 | view storage _ = 73 | { title = "Homepage" 74 | , body = 75 | [ Html.h1 [] [ Html.text "Local storage" ] 76 | , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] 77 | , Html.p [] [ Html.text ("Count: " ++ String.fromInt storage.counter) ] 78 | , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /examples/03-local-storage/src/Shared.elm: -------------------------------------------------------------------------------- 1 | module Shared exposing 2 | ( Flags 3 | , Model 4 | , Msg 5 | , init 6 | , subscriptions 7 | , update 8 | ) 9 | 10 | import Json.Decode as Json 11 | import Request exposing (Request) 12 | import Storage exposing (Storage) 13 | 14 | 15 | type alias Flags = 16 | Json.Value 17 | 18 | 19 | type alias Model = 20 | { storage : Storage 21 | } 22 | 23 | 24 | init : Request -> Flags -> ( Model, Cmd Msg ) 25 | init _ flags = 26 | ( { storage = Storage.fromJson flags } 27 | , Cmd.none 28 | ) 29 | 30 | 31 | type Msg 32 | = StorageUpdated Storage 33 | 34 | 35 | update : Request -> Msg -> Model -> ( Model, Cmd Msg ) 36 | update _ msg model = 37 | case msg of 38 | StorageUpdated storage -> 39 | ( { model | storage = storage } 40 | , Cmd.none 41 | ) 42 | 43 | 44 | subscriptions : Request -> Model -> Sub Msg 45 | subscriptions _ _ = 46 | Storage.onChange StorageUpdated 47 | -------------------------------------------------------------------------------- /examples/03-local-storage/src/Storage.elm: -------------------------------------------------------------------------------- 1 | port module Storage exposing 2 | ( Storage, fromJson, onChange 3 | , increment, decrement 4 | ) 5 | 6 | {-| 7 | 8 | @docs Storage, fromJson, onChange 9 | @docs increment, decrement 10 | 11 | -} 12 | 13 | import Json.Decode as Json 14 | import Json.Encode as Encode 15 | 16 | 17 | 18 | -- PORTS 19 | 20 | 21 | port save : Json.Value -> Cmd msg 22 | 23 | 24 | port load : (Json.Value -> msg) -> Sub msg 25 | 26 | 27 | 28 | -- STORAGE 29 | 30 | 31 | type alias Storage = 32 | { counter : Int 33 | } 34 | 35 | 36 | 37 | -- Converting to JSON 38 | 39 | 40 | toJson : Storage -> Json.Value 41 | toJson storage = 42 | Encode.object 43 | [ ( "counter", Encode.int storage.counter ) 44 | ] 45 | 46 | 47 | 48 | -- Converting from JSON 49 | 50 | 51 | fromJson : Json.Value -> Storage 52 | fromJson json = 53 | json 54 | |> Json.decodeValue decoder 55 | |> Result.withDefault init 56 | 57 | 58 | init : Storage 59 | init = 60 | { counter = 0 61 | } 62 | 63 | 64 | decoder : Json.Decoder Storage 65 | decoder = 66 | Json.map Storage 67 | (Json.field "counter" Json.int) 68 | 69 | 70 | 71 | -- Updating storage 72 | 73 | 74 | increment : Storage -> Cmd msg 75 | increment storage = 76 | { storage | counter = storage.counter + 1 } 77 | |> toJson 78 | |> save 79 | 80 | 81 | decrement : Storage -> Cmd msg 82 | decrement storage = 83 | { storage | counter = storage.counter - 1 } 84 | |> toJson 85 | |> save 86 | 87 | 88 | 89 | -- LISTENING FOR STORAGE UPDATES 90 | 91 | 92 | onChange : (Storage -> msg) -> Sub msg 93 | onChange fromStorage = 94 | load (\json -> fromJson json |> fromStorage) 95 | -------------------------------------------------------------------------------- /examples/04-authentication/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/04-authentication/README.md: -------------------------------------------------------------------------------- 1 | # my new project 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) -------------------------------------------------------------------------------- /examples/04-authentication/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /examples/04-authentication/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/04-authentication/public/main.js: -------------------------------------------------------------------------------- 1 | const app = Elm.Main.init({ 2 | flags: JSON.parse(localStorage.getItem('storage')) 3 | }) 4 | 5 | app.ports.save_.subscribe(storage => { 6 | localStorage.setItem('storage', JSON.stringify(storage)) 7 | app.ports.load_.send(storage) 8 | }) -------------------------------------------------------------------------------- /examples/04-authentication/src/Auth.elm: -------------------------------------------------------------------------------- 1 | module Auth exposing 2 | ( User 3 | , beforeProtectedInit 4 | ) 5 | 6 | {-| 7 | 8 | @docs User 9 | @docs beforeProtectedInit 10 | 11 | -} 12 | 13 | import Domain.User 14 | import ElmSpa.Page as ElmSpa 15 | import Gen.Route exposing (Route) 16 | import Request exposing (Request) 17 | import Shared 18 | 19 | 20 | type alias User = 21 | Domain.User.User 22 | 23 | 24 | beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route 25 | beforeProtectedInit { storage } _ = 26 | case storage.user of 27 | Just user -> 28 | ElmSpa.Provide user 29 | 30 | Nothing -> 31 | ElmSpa.RedirectTo Gen.Route.SignIn 32 | -------------------------------------------------------------------------------- /examples/04-authentication/src/Domain/User.elm: -------------------------------------------------------------------------------- 1 | module Domain.User exposing (User, decoder, encode) 2 | 3 | import Json.Decode as Json 4 | import Json.Encode as Encode 5 | 6 | 7 | type alias User = 8 | { name : String 9 | } 10 | 11 | 12 | decoder : Json.Decoder User 13 | decoder = 14 | Json.map User 15 | (Json.field "name" Json.string) 16 | 17 | 18 | encode : User -> Json.Value 19 | encode user = 20 | Encode.object 21 | [ ( "name", Encode.string user.name ) 22 | ] 23 | -------------------------------------------------------------------------------- /examples/04-authentication/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (Model, Msg, page, view) 2 | 3 | import Auth 4 | import Html 5 | import Html.Events as Events 6 | import Page 7 | import Request exposing (Request) 8 | import Shared 9 | import Storage exposing (Storage) 10 | import UI 11 | import View exposing (View) 12 | 13 | 14 | page : Shared.Model -> Request -> Page.With Model Msg 15 | page shared _ = 16 | Page.protected.element <| 17 | \user -> 18 | { init = init 19 | , update = update shared.storage 20 | , view = view user 21 | , subscriptions = \_ -> Sub.none 22 | } 23 | 24 | 25 | 26 | -- INIT 27 | 28 | 29 | type alias Model = 30 | {} 31 | 32 | 33 | init : ( Model, Cmd Msg ) 34 | init = 35 | ( {}, Cmd.none ) 36 | 37 | 38 | 39 | -- UPDATE 40 | 41 | 42 | type Msg 43 | = ClickedSignOut 44 | 45 | 46 | update : Storage -> Msg -> Model -> ( Model, Cmd Msg ) 47 | update storage msg model = 48 | case msg of 49 | ClickedSignOut -> 50 | ( model 51 | , Storage.signOut storage 52 | ) 53 | 54 | 55 | view : Auth.User -> Model -> View Msg 56 | view user _ = 57 | { title = "Homepage" 58 | , body = 59 | UI.layout 60 | [ Html.h1 [] [ Html.text ("Hello, " ++ user.name ++ "!") ] 61 | , Html.button [ Events.onClick ClickedSignOut ] [ Html.text "Sign out" ] 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /examples/04-authentication/src/Pages/SignIn.elm: -------------------------------------------------------------------------------- 1 | module Pages.SignIn exposing (Model, Msg, page) 2 | 3 | import Gen.Params.SignIn exposing (Params) 4 | import Html 5 | import Html.Attributes as Attr 6 | import Html.Events as Events 7 | import Page 8 | import Request 9 | import Shared 10 | import Storage exposing (Storage) 11 | import UI 12 | import View exposing (View) 13 | 14 | 15 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 16 | page shared req = 17 | Page.element 18 | { init = init 19 | , update = update shared.storage 20 | , view = view 21 | , subscriptions = subscriptions 22 | } 23 | 24 | 25 | 26 | -- INIT 27 | 28 | 29 | type alias Model = 30 | { name : String } 31 | 32 | 33 | init : ( Model, Cmd Msg ) 34 | init = 35 | ( { name = "" } 36 | , Cmd.none 37 | ) 38 | 39 | 40 | 41 | -- UPDATE 42 | 43 | 44 | type Msg 45 | = UpdatedName String 46 | | SubmittedSignInForm 47 | 48 | 49 | update : Storage -> Msg -> Model -> ( Model, Cmd Msg ) 50 | update storage msg model = 51 | case msg of 52 | UpdatedName name -> 53 | ( { model | name = name } 54 | , Cmd.none 55 | ) 56 | 57 | SubmittedSignInForm -> 58 | ( model 59 | , Storage.signIn { name = model.name } storage 60 | ) 61 | 62 | 63 | 64 | -- SUBSCRIPTIONS 65 | 66 | 67 | subscriptions : Model -> Sub Msg 68 | subscriptions model = 69 | Sub.none 70 | 71 | 72 | 73 | -- VIEW 74 | 75 | 76 | view : Model -> View Msg 77 | view model = 78 | { title = "Sign in" 79 | , body = 80 | UI.layout 81 | [ Html.form [ Events.onSubmit SubmittedSignInForm ] 82 | [ Html.label [] 83 | [ Html.span [] [ Html.text "Name" ] 84 | , Html.input 85 | [ Attr.type_ "text" 86 | , Attr.value model.name 87 | , Events.onInput UpdatedName 88 | ] 89 | [] 90 | ] 91 | , Html.button [ Attr.disabled (String.isEmpty model.name) ] 92 | [ Html.text "Sign in" ] 93 | ] 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /examples/04-authentication/src/Shared.elm: -------------------------------------------------------------------------------- 1 | module Shared exposing 2 | ( Flags 3 | , Model 4 | , Msg 5 | , init 6 | , subscriptions 7 | , update 8 | ) 9 | 10 | import Gen.Route 11 | import Json.Decode as Json 12 | import Request exposing (Request) 13 | import Storage exposing (Storage) 14 | 15 | 16 | type alias Flags = 17 | Json.Value 18 | 19 | 20 | type alias Model = 21 | { storage : Storage 22 | } 23 | 24 | 25 | init : Request -> Flags -> ( Model, Cmd Msg ) 26 | init req flags = 27 | let 28 | model = 29 | { storage = Storage.fromJson flags } 30 | in 31 | ( model 32 | , if model.storage.user /= Nothing && req.route == Gen.Route.SignIn then 33 | Request.replaceRoute Gen.Route.SignIn req 34 | 35 | else 36 | Cmd.none 37 | ) 38 | 39 | 40 | type Msg 41 | = StorageUpdated Storage 42 | 43 | 44 | update : Request -> Msg -> Model -> ( Model, Cmd Msg ) 45 | update req msg model = 46 | case msg of 47 | StorageUpdated storage -> 48 | ( { model | storage = storage } 49 | , if Gen.Route.SignIn == req.route then 50 | Request.pushRoute Gen.Route.Home_ req 51 | 52 | else 53 | Cmd.none 54 | ) 55 | 56 | 57 | subscriptions : Request -> Model -> Sub Msg 58 | subscriptions _ _ = 59 | Storage.load StorageUpdated 60 | -------------------------------------------------------------------------------- /examples/04-authentication/src/Storage.elm: -------------------------------------------------------------------------------- 1 | port module Storage exposing 2 | ( Storage, load 3 | , signIn, signOut 4 | , fromJson 5 | ) 6 | 7 | {-| 8 | 9 | @docs Storage, save, load 10 | @docs signIn, signOut 11 | 12 | -} 13 | 14 | import Domain.User as User exposing (User) 15 | import Json.Decode as Json 16 | import Json.Encode as Encode 17 | 18 | 19 | type alias Storage = 20 | { user : Maybe User 21 | } 22 | 23 | 24 | fromJson : Json.Value -> Storage 25 | fromJson json = 26 | json 27 | |> Json.decodeValue decoder 28 | |> Result.withDefault init 29 | 30 | 31 | init : Storage 32 | init = 33 | { user = Nothing 34 | } 35 | 36 | 37 | decoder : Json.Decoder Storage 38 | decoder = 39 | Json.map Storage 40 | (Json.field "user" (Json.maybe User.decoder)) 41 | 42 | 43 | save : Storage -> Json.Value 44 | save storage = 45 | Encode.object 46 | [ ( "user" 47 | , storage.user 48 | |> Maybe.map User.encode 49 | |> Maybe.withDefault Encode.null 50 | ) 51 | ] 52 | 53 | 54 | 55 | -- UPDATING STORAGE 56 | 57 | 58 | signIn : User -> Storage -> Cmd msg 59 | signIn user storage = 60 | saveToLocalStorage { storage | user = Just user } 61 | 62 | 63 | signOut : Storage -> Cmd msg 64 | signOut storage = 65 | saveToLocalStorage { storage | user = Nothing } 66 | 67 | 68 | 69 | -- PORTS 70 | 71 | 72 | saveToLocalStorage : Storage -> Cmd msg 73 | saveToLocalStorage = 74 | save >> save_ 75 | 76 | 77 | port save_ : Json.Value -> Cmd msg 78 | 79 | 80 | load : (Storage -> msg) -> Sub msg 81 | load fromStorage = 82 | load_ (fromJson >> fromStorage) 83 | 84 | 85 | port load_ : (Json.Value -> msg) -> Sub msg 86 | -------------------------------------------------------------------------------- /examples/04-authentication/src/UI.elm: -------------------------------------------------------------------------------- 1 | module UI exposing (h1, layout) 2 | 3 | import Gen.Route as Route exposing (Route) 4 | import Html exposing (Html) 5 | import Html.Attributes as Attr 6 | 7 | 8 | layout : List (Html msg) -> List (Html msg) 9 | layout children = 10 | let 11 | viewLink : String -> Route -> Html msg 12 | viewLink label route = 13 | Html.a [ Attr.href (Route.toHref route) ] [ Html.text label ] 14 | in 15 | [ Html.div [ Attr.style "margin" "2rem" ] 16 | [ Html.header [ Attr.style "margin-bottom" "1rem" ] 17 | [ Html.strong [ Attr.style "margin-right" "1rem" ] [ viewLink "Home" Route.Home_ ] 18 | , viewLink "Sign in" Route.SignIn 19 | ] 20 | , Html.main_ [] children 21 | ] 22 | ] 23 | 24 | 25 | h1 : String -> Html msg 26 | h1 label = 27 | Html.h1 [] [ Html.text label ] 28 | -------------------------------------------------------------------------------- /examples/05-vite/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/05-vite/README.md: -------------------------------------------------------------------------------- 1 | # examples/05-vite 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | npm start 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | npm run dev # run elm-spa and Vite without "npm install" 22 | npm run build # production codegen and vite build 23 | ``` 24 | 25 | ## learn more 26 | 27 | You can learn more at [elm-spa.dev](https://elm-spa.dev) 28 | -------------------------------------------------------------------------------- /examples/05-vite/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /examples/05-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "05-vite", 3 | "version": "1.0.0", 4 | "description": "built with [elm-spa](https://elm-spa.dev)", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm install && npm run dev", 8 | "dev": "concurrently \"elm-spa watch\" \"vite\"", 9 | "build": "elm-spa gen && vite build" 10 | }, 11 | "keywords": [], 12 | "author": "Ryan Haskell-Glatz", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "concurrently": "6.0.2", 16 | "elm-spa": "6.0.0", 17 | "vite-plugin-elm": "2.3.1", 18 | "vite": "2.2.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/05-vite/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/05-vite/public/main.js: -------------------------------------------------------------------------------- 1 | import { Elm } from '../.elm-spa/defaults/Main.elm' 2 | 3 | Elm.Main.init() 4 | -------------------------------------------------------------------------------- /examples/05-vite/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (view) 2 | 3 | import Html 4 | import View exposing (View) 5 | 6 | 7 | view : View msg 8 | view = 9 | { title = "Homepage" 10 | , body = [ Html.text "Hello, world!" ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/05-vite/vite.config.js: -------------------------------------------------------------------------------- 1 | import elmPlugin from 'vite-plugin-elm' 2 | 3 | export default { 4 | root: 'public', 5 | plugins: [elmPlugin()] 6 | } 7 | -------------------------------------------------------------------------------- /examples/06-testing/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/06-testing/README.md: -------------------------------------------------------------------------------- 1 | # examples/06-testing 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa elm-test 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | elm-test # run unit tests 25 | ``` 26 | 27 | ## learn more 28 | 29 | You can learn more at [elm-spa.dev](https://elm-spa.dev) 30 | -------------------------------------------------------------------------------- /examples/06-testing/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": { 25 | "elm-explorations/test": "1.2.2", 26 | "avh4/elm-program-test": "3.4.0" 27 | }, 28 | "indirect": { 29 | "avh4/elm-fifo": "1.0.4", 30 | "elm/bytes": "1.0.8", 31 | "elm/file": "1.0.5", 32 | "elm/http": "2.0.0", 33 | "elm/random": "1.0.0" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /examples/06-testing/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/06-testing/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (Model, Msg, init, page, update, view) 2 | 3 | import Gen.Params.Home_ exposing (Params) 4 | import Html exposing (Html) 5 | import Html.Events 6 | import Page 7 | import Request 8 | import Shared 9 | import View exposing (View) 10 | 11 | 12 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 13 | page shared req = 14 | Page.element 15 | { init = init 16 | , update = update 17 | , view = view 18 | , subscriptions = subscriptions 19 | } 20 | 21 | 22 | 23 | -- INIT 24 | 25 | 26 | type alias Model = 27 | { counter : Int 28 | } 29 | 30 | 31 | init : ( Model, Cmd Msg ) 32 | init = 33 | ( { counter = 0 }, Cmd.none ) 34 | 35 | 36 | 37 | -- UPDATE 38 | 39 | 40 | type Msg 41 | = Increment 42 | | Decrement 43 | 44 | 45 | update : Msg -> Model -> ( Model, Cmd Msg ) 46 | update msg model = 47 | case msg of 48 | Increment -> 49 | ( { model | counter = model.counter + 1 }, Cmd.none ) 50 | 51 | Decrement -> 52 | ( { model | counter = model.counter - 1 }, Cmd.none ) 53 | 54 | 55 | 56 | -- SUBSCRIPTIONS 57 | 58 | 59 | subscriptions : Model -> Sub Msg 60 | subscriptions model = 61 | Sub.none 62 | 63 | 64 | 65 | -- VIEW 66 | 67 | 68 | view : Model -> View Msg 69 | view model = 70 | { title = "Homepage" 71 | , body = 72 | [ Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] 73 | , Html.p [] [ Html.text ("Count: " ++ String.fromInt model.counter) ] 74 | , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /examples/06-testing/src/Utils/String.elm: -------------------------------------------------------------------------------- 1 | module Utils.String exposing (capitalizeFirstLetter) 2 | 3 | 4 | capitalizeFirstLetter : String -> String 5 | capitalizeFirstLetter str = 6 | String.toUpper (String.left 1 str) ++ String.dropLeft 1 str 7 | -------------------------------------------------------------------------------- /examples/06-testing/tests/ProgramTests/Homepage.elm: -------------------------------------------------------------------------------- 1 | module ProgramTests.Homepage exposing (all) 2 | 3 | import Pages.Home_ 4 | import ProgramTest exposing (ProgramTest, clickButton, expectViewHas) 5 | import Test exposing (Test, describe, test) 6 | import Test.Html.Selector exposing (text) 7 | 8 | 9 | start : ProgramTest Pages.Home_.Model Pages.Home_.Msg (Cmd Pages.Home_.Msg) 10 | start = 11 | ProgramTest.createDocument 12 | { init = \_ -> Pages.Home_.init 13 | , update = Pages.Home_.update 14 | , view = Pages.Home_.view 15 | } 16 | |> ProgramTest.start () 17 | 18 | 19 | all : Test 20 | all = 21 | describe "Pages.Homepage_" 22 | [ test "Counter increment works" <| 23 | \() -> 24 | start 25 | |> clickButton "+" 26 | |> expectViewHas 27 | [ text "Count: 1" 28 | ] 29 | , test "Counter decrement works" <| 30 | \() -> 31 | start 32 | |> clickButton "-" 33 | |> expectViewHas 34 | [ text "Count: -1" 35 | ] 36 | , test "Clicking multiple buttons works too" <| 37 | \() -> 38 | start 39 | |> clickButton "-" 40 | |> clickButton "+" 41 | |> clickButton "-" 42 | |> clickButton "-" 43 | |> expectViewHas 44 | [ text "Count: -2" 45 | ] 46 | ] 47 | -------------------------------------------------------------------------------- /examples/06-testing/tests/UnitTests/Utils/StringTest.elm: -------------------------------------------------------------------------------- 1 | module UnitTests.Utils.StringTest exposing (suite) 2 | 3 | import Expect 4 | import Test exposing (Test, describe, test) 5 | import Utils.String 6 | 7 | 8 | suite : Test 9 | suite = 10 | describe "Utils.String" 11 | [ describe "capitalizeFirstLetter" 12 | [ test "works with a single word" 13 | (\_ -> 14 | Utils.String.capitalizeFirstLetter "ryan" 15 | |> Expect.equal "Ryan" 16 | ) 17 | , test "doesn't affect already capitalized words" 18 | (\_ -> 19 | Utils.String.capitalizeFirstLetter "Ryan" 20 | |> Expect.equal "Ryan" 21 | ) 22 | , test "only capitalizes first word in sentence" 23 | (\_ -> 24 | Utils.String.capitalizeFirstLetter "ryan loves writing unit tests" 25 | |> Expect.equal "Ryan loves writing unit tests" 26 | ) 27 | ] 28 | ] 29 | -------------------------------------------------------------------------------- /examples/07-elm-ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /examples/07-elm-ui/README.md: -------------------------------------------------------------------------------- 1 | # examples/07-elm-ui 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) -------------------------------------------------------------------------------- /examples/07-elm-ui/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "mdgriffith/elm-ui": "1.1.8", 17 | "ryan-haskell/elm-spa": "1.0.0" 18 | }, 19 | "indirect": { 20 | "elm/time": "1.0.0", 21 | "elm/virtual-dom": "1.0.2" 22 | } 23 | }, 24 | "test-dependencies": { 25 | "direct": {}, 26 | "indirect": {} 27 | } 28 | } -------------------------------------------------------------------------------- /examples/07-elm-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/07-elm-ui/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (view) 2 | 3 | import Element 4 | import View exposing (View) 5 | 6 | 7 | view : View msg 8 | view = 9 | { title = "Homepage" 10 | , attributes = [] 11 | , element = 12 | Element.el 13 | [ Element.centerX 14 | , Element.centerY 15 | ] 16 | (Element.text "Woohoo, it's Elm UI!") 17 | } 18 | -------------------------------------------------------------------------------- /examples/07-elm-ui/src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (View, map, none, placeholder, toBrowserDocument) 2 | 3 | import Browser 4 | import Element exposing (Element) 5 | 6 | 7 | type alias View msg = 8 | { title : String 9 | , attributes : List (Element.Attribute msg) 10 | , element : Element msg 11 | } 12 | 13 | 14 | placeholder : String -> View msg 15 | placeholder str = 16 | { title = str 17 | , attributes = [] 18 | , element = Element.text str 19 | } 20 | 21 | 22 | none : View msg 23 | none = 24 | placeholder "" 25 | 26 | 27 | map : (a -> b) -> View a -> View b 28 | map fn view = 29 | { title = view.title 30 | , attributes = view.attributes |> List.map (Element.mapAttribute fn) 31 | , element = Element.map fn view.element 32 | } 33 | 34 | 35 | toBrowserDocument : View msg -> Browser.Document msg 36 | toBrowserDocument view = 37 | { title = view.title 38 | , body = 39 | [ Element.layout view.attributes view.element 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/ElmSpa/Request.elm: -------------------------------------------------------------------------------- 1 | module ElmSpa.Request exposing (Request, create) 2 | 3 | {-| 4 | 5 | 6 | # **( These docs are for CLI contributors )** 7 | 8 | 9 | ### If you are using **elm-spa**, check out [the official guide](https://elm-spa.dev/guide) instead! 10 | 11 | --- 12 | 13 | Every page gets access to a **request**, which has information about the 14 | current URL, route parameters, query parameters etc. 15 | 16 | page : Shared.Model -> Request Params -> Page Model Msg 17 | page _ request = 18 | Page.element 19 | { init = init 20 | , update = update 21 | , view = view request 22 | } 23 | 24 | You can choose to pass this request into `init`,`update`, or any other function 25 | that might need access to URL-related information. 26 | 27 | 28 | # Requests 29 | 30 | @docs Request, create 31 | 32 | -} 33 | 34 | import Browser.Navigation exposing (Key) 35 | import Dict exposing (Dict) 36 | import Url exposing (Url) 37 | 38 | 39 | {-| Here is an example request for the route `/people/:name` 40 | 41 | -- /people/ryan 42 | req.route == Route.People__Detail_ { name = "ryan" } 43 | 44 | req.params == { name = "ryan" } 45 | 46 | req.params.name == "ryan" 47 | 48 | req.query == Dict.empty 49 | 50 | And another example with a some query parameters: 51 | 52 | -- /people/scott?allowed=false 53 | req.route == Route.People__Detail_ { name = "scott" } 54 | 55 | req.params == { name = "scott" } 56 | 57 | req.params.name == "scott" 58 | 59 | Dict.get "allowed" req.query == Just "false" 60 | 61 | -} 62 | type alias Request route params = 63 | { url : Url 64 | , key : Key 65 | , route : route 66 | , params : params 67 | , query : Dict String String 68 | } 69 | 70 | 71 | {-| A convenience function for creating requests, used by elm-spa internally. 72 | 73 | request : Request Route { name : String } 74 | request = 75 | Request.create (Route.fromUrl url) 76 | { name = "ryan" } 77 | url 78 | key 79 | 80 | -} 81 | create : route -> params -> Url -> Key -> Request route params 82 | create route params url key = 83 | { url = url 84 | , key = key 85 | , params = params 86 | , route = route 87 | , query = 88 | url.query 89 | |> Maybe.map query 90 | |> Maybe.withDefault Dict.empty 91 | } 92 | 93 | 94 | query : String -> Dict String String 95 | query str = 96 | if String.isEmpty str then 97 | Dict.empty 98 | 99 | else 100 | let 101 | decode val = 102 | Url.percentDecode val 103 | |> Maybe.withDefault val 104 | in 105 | str 106 | |> String.split "&" 107 | |> List.filterMap 108 | (String.split "=" 109 | >> (\eq -> 110 | Maybe.map2 Tuple.pair 111 | (List.head eq) 112 | (eq |> List.drop 1 |> List.head |> Maybe.withDefault "" |> Just) 113 | ) 114 | ) 115 | |> List.map (Tuple.mapBoth decode decode) 116 | |> Dict.fromList 117 | -------------------------------------------------------------------------------- /src/cli/.npmignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | /node_modules 3 | /tests 4 | /jest.config.js 5 | /tsconfig.json 6 | /src/** 7 | !/src/new/** 8 | !/src/defaults/** 9 | !/src/templates/add/** -------------------------------------------------------------------------------- /src/cli/README.md: -------------------------------------------------------------------------------- 1 | # elm-spa cli 2 | > the command-line interface for __elm-spa__ 3 | 4 | ## installation 5 | 6 | ```bash 7 | npm install -g elm-spa@latest 8 | ``` 9 | 10 | ## usage 11 | 12 | ``` 13 | $ elm-spa help 14 | ``` 15 | ``` 16 | elm-spa – version 6.0.6 17 | 18 | Commands: 19 | elm-spa new . . . . . . . . . create a new project 20 | elm-spa add . . . . . . . . create a new page 21 | elm-spa build . . . . . . one-time production build 22 | elm-spa server . . . . . . start a live dev server 23 | 24 | Other commands: 25 | elm-spa gen . . . . generates code without elm make 26 | elm-spa watch . . . . runs elm-spa gen as you code 27 | 28 | Visit https://elm-spa.dev for more! 29 | ``` 30 | 31 | ## learn more 32 | 33 | Check out the official guide at https://elm-spa.dev! 34 | 35 | # contributing 36 | 37 | The CLI is written with TypeScript + NodeJS. Here's how you can get started contributing: 38 | 39 | ```bash 40 | git clone git@github.com:ryan-haskell/elm-spa # clone the repo 41 | cd elm-spa/src/cli # enter the CLI folder 42 | npm start # run first time dev setup 43 | ``` 44 | 45 | ```bash 46 | npm run dev # compiles as you code 47 | npm run build # one-time production build 48 | npm run test # run test suite 49 | ``` 50 | 51 | ## playing with the CLI locally 52 | 53 | Here's how you can make the `elm-spa` command work with your local build of this 54 | repo. 55 | 56 | ```bash 57 | npm remove -g elm-spa # remove any existing `elm-spa` installs 58 | npm link # make `elm-spa` refer to our local code 59 | ``` -------------------------------------------------------------------------------- /src/cli/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | rootDir: 'tests' 5 | } -------------------------------------------------------------------------------- /src/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-spa", 3 | "version": "6.0.6", 4 | "description": "single page apps made easy", 5 | "bin": "dist/src/index.js", 6 | "scripts": { 7 | "start": "npm install && npm run dev", 8 | "dev": "npm run build:watch & npm run test:watch", 9 | "build": "tsc", 10 | "build:watch": "tsc --watch", 11 | "test": "jest", 12 | "test:watch": "jest --watchAll", 13 | "publish:test": "npm run build && npm pack --dry-run" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/ryan-haskell/elm-spa.git" 18 | }, 19 | "keywords": [ 20 | "elm", 21 | "spa", 22 | "web", 23 | "framework" 24 | ], 25 | "author": "Ryan Haskell-Glatz", 26 | "license": "BSD-3-Clause", 27 | "bugs": { 28 | "url": "https://github.com/ryan-haskell/elm-spa/issues" 29 | }, 30 | "homepage": "https://github.com/ryan-haskell/elm-spa#readme", 31 | "devDependencies": { 32 | "@types/chokidar": "2.1.3", 33 | "@types/jest": "26.0.14", 34 | "@types/mime": "2.0.3", 35 | "@types/node": "14.11.8", 36 | "@types/websocket": "1.0.1", 37 | "jest": "26.5.3", 38 | "ts-jest": "26.4.1", 39 | "typescript": "4.0.3" 40 | }, 41 | "dependencies": { 42 | "chokidar": "3.4.2", 43 | "mime": "2.4.6", 44 | "node-elm-compiler": "5.0.5", 45 | "terser": "5.3.8", 46 | "websocket": "1.0.32" 47 | } 48 | } -------------------------------------------------------------------------------- /src/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import New from './cli/init' 2 | import Add from './cli/add' 3 | import Build from './cli/build' 4 | import Watch from './cli/watch' 5 | import Server from './cli/server' 6 | import Help from './cli/help' 7 | 8 | export default { 9 | new: New.run, 10 | add: Add.run, 11 | build: Build.build, 12 | server: Server.run, 13 | gen: Build.gen, 14 | watch: Watch.run, 15 | help: Help.run, 16 | // Aliases for Elm folks 17 | init: New.run, 18 | make: Build.build, 19 | } -------------------------------------------------------------------------------- /src/cli/src/cli/_common.ts: -------------------------------------------------------------------------------- 1 | import config from "../config" 2 | import * as File from '../file' 3 | 4 | export const createMissingAddTemplates = async () => { 5 | const folderAlreadyExists = await File.exists(config.folders.templates.user) 6 | if (folderAlreadyExists === false) { 7 | File.copy(config.folders.templates.defaults, config.folders.templates.user) 8 | } 9 | 10 | return (await File.scan(config.folders.templates.user)) 11 | .map(fp => fp.substring(config.folders.templates.user.length + 1, fp.length - 4)) 12 | } -------------------------------------------------------------------------------- /src/cli/src/cli/add.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import fs from "fs" 3 | import config from "../config" 4 | import * as File from '../file' 5 | import { urlArgumentToPages } from "../templates/utils" 6 | import Add from '../templates/add' 7 | import { createMissingAddTemplates } from "./_common" 8 | 9 | 10 | const bold = (str: string) => '\x1b[1m' + str + '\x1b[0m' 11 | const cyan = (str: string) => '\x1b[36m' + str + '\x1b[0m' 12 | const green = (str: string) => '\x1b[32m' + str + '\x1b[0m' 13 | const yellow = (str: string) => '\x1b[33m' + str + '\x1b[0m' 14 | const pink = (str: string) => '\x1b[35m' + str + '\x1b[0m' 15 | 16 | // Scaffold a new elm-spa page 17 | export default { 18 | run: async () => { 19 | let [ url, template ] = process.argv.slice(3) 20 | if (!url || url === '--help') { 21 | return Promise.reject(example) 22 | } 23 | const page = urlArgumentToPages(url) 24 | const outputFilepath = path.join(config.folders.pages.src, ...page ) + '.elm' 25 | let contents = Add(page) 26 | 27 | if (template) { 28 | const availableTemplates = await createMissingAddTemplates() 29 | const templateSrc = path.join(config.folders.templates.user, template + '.elm') 30 | 31 | contents = await File.read(templateSrc).catch(_ => Promise.reject(template404(url, template, availableTemplates))) 32 | contents = contents.split('{{module}}').join(page.join('.')) 33 | } 34 | 35 | await File.create(outputFilepath, contents) 36 | 37 | return ` ${bold('New page created at:')}\n ${outputFilepath}\n` 38 | } 39 | } 40 | 41 | const example = ' ' + ` 42 | ${bold(`elm-spa add`)} [template] 43 | 44 | Examples: 45 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/')} . . . . . . . . adds a homepage 46 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/about-us')} . . . . adds a static route 47 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/people/:id')} . . . adds a dynamic route 48 | 49 | Examples with templates: 50 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/')} ${pink('static')} 51 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/about-us')} ${pink('sandbox')} 52 | ${bold(`elm-spa ${cyan(`add`)}`)} ${yellow('/people/:id')} ${pink('element')} 53 | 54 | Visit ${green(`https://elm-spa.dev/guide/01-cli`)} for more details! 55 | `.trim() 56 | 57 | const template404 = (url : string, template : string, suggestions: string[]) => { 58 | const suggest = ` 59 | Here are the available templates: 60 | 61 | ${suggestions.map(temp => `${yellow(`elm-spa add`)} ${yellow(url)} ${bold(pink(temp))}`).join('\n ')} 62 | ` 63 | 64 | return ' ' + ` 65 | ${bold(`elm-spa`)} couldn't find a ${bold(pink(template))} template 66 | in the ${cyan('.elm-spa/templates')} folder. 67 | ${suggestions.length ? suggest : ''} 68 | Visit ${green(`https://elm-spa.dev/guide/01-cli`)} for more details! 69 | 70 | `.trim()} -------------------------------------------------------------------------------- /src/cli/src/cli/help.ts: -------------------------------------------------------------------------------- 1 | import pkg from '../../package.json' 2 | 3 | export default { 4 | run: () => helpText.trimLeft() 5 | } 6 | 7 | const bold = (str: string) => '\x1b[1m' + str + '\x1b[0m' 8 | const cyan = (str: string) => '\x1b[36m' + str + '\x1b[0m' 9 | const green = (str: string) => '\x1b[32m' + str + '\x1b[0m' 10 | const yellow = (str: string) => '\x1b[33m' + str + '\x1b[0m' 11 | 12 | const helpText = ` 13 | ${bold(`elm-spa`)} – version ${yellow(pkg.version)} 14 | 15 | Commands: 16 | ${bold(`elm-spa ${cyan(`new`)}`)} . . . . . . . . . create a new project 17 | ${bold(`elm-spa ${cyan(`add`)}`)} . . . . . . . . create a new page 18 | ${bold(`elm-spa ${cyan(`build`)}`)} . . . . . . one-time production build 19 | ${bold(`elm-spa ${cyan(`server`)}`)} . . . . . . start a live dev server 20 | 21 | Other commands: 22 | ${bold(`elm-spa ${cyan(`gen`)}`)} . . . . generates code without elm make 23 | ${bold(`elm-spa ${cyan(`watch`)}`)} . . . . runs elm-spa gen as you code 24 | 25 | Visit ${green(`https://elm-spa.dev`)} for more! 26 | ` -------------------------------------------------------------------------------- /src/cli/src/cli/init.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import fs from "fs" 3 | import config from "../config" 4 | import * as File from '../file' 5 | import { bold, check, colors, dim, dot, reset, warn } from "../terminal" 6 | import { createInterface } from "readline" 7 | 8 | // Scaffold a new elm-spa project 9 | export default { 10 | run: async () => { 11 | return new Promise(offerToInitializeProject) 12 | } 13 | } 14 | 15 | const offerToInitializeProject = (resolve: (value: unknown) => void, reject: (reason: unknown) => void) => { 16 | const rl = createInterface({ 17 | input: process.stdin, 18 | output: process.stdout 19 | }) 20 | 21 | rl.question(`\n May I create a ${colors.cyan}new project${reset} in the ${colors.yellow}current folder${reset}? ${dim}[y/n]${reset} `, answer => { 22 | if (answer.toLowerCase() === 'n') { 23 | reject(` ${bold}No changes made!${reset}`) 24 | } else { 25 | resolve(initializeNewProject()) 26 | rl.close() 27 | } 28 | }) 29 | } 30 | 31 | const initializeNewProject = () => { 32 | const dest = process.cwd() 33 | File.copy(config.folders.init, dest) 34 | try { fs.renameSync(path.join(dest, '_gitignore'), path.join(dest, '.gitignore')) } catch (_) {} 35 | return ` ${check} ${bold}New project created in:${reset}\n ${process.cwd()}\n` 36 | } -------------------------------------------------------------------------------- /src/cli/src/cli/server.ts: -------------------------------------------------------------------------------- 1 | import chokidar from "chokidar" 2 | import Url from 'url' 3 | import http from 'http' 4 | import websocket, { connection } from 'websocket' 5 | import path from 'path' 6 | import * as File from "../file" 7 | import { watch } from './watch' 8 | import { colors, reset } from "../terminal" 9 | import mime from 'mime' 10 | import { createReadStream } from "fs" 11 | 12 | const start = async () => new Promise((resolve, reject) => { 13 | const config = { 14 | port: process.env.PORT || 1234, 15 | base: path.join(process.cwd(), 'public'), 16 | index: 'index.html' 17 | } 18 | 19 | const contentType = (extension : string) : string => 20 | mime.getType(extension) || 'text/plain' 21 | 22 | const server = http.createServer(async (req, res) => { 23 | const url = Url.parse(req.url || '') 24 | const error = () => { 25 | res.statusCode = 404 26 | res.setHeader('Content-Type', 'text/plain') 27 | res.write('File not found.') 28 | res.end() 29 | } 30 | if ((url.pathname || '').includes('.')) { 31 | try { 32 | const filepath = path.join(config.base, ...(url.pathname || '').split('/')) 33 | const s = createReadStream(filepath) 34 | s.on('open', () => { 35 | const extension = (url.pathname || '').split('.').slice(-1)[0] 36 | res.setHeader('Content-Type', contentType(extension)) 37 | s.pipe(res) 38 | }) 39 | s.on('error', error) 40 | } catch (_) { error() } 41 | } else { 42 | let file = await File.read(path.join(config.base, config.index)) 43 | file = file.split('').join(` \n`) 44 | res.setHeader('Content-Type', contentType('html')) 45 | res.write(file) 46 | res.end() 47 | } 48 | }) 49 | 50 | // Websockets for live-reloading 51 | const connections : { [key: string]: connection } = {} 52 | const ws = new websocket.server({ httpServer: server }) 53 | const script = ` new WebSocket('ws://' + window.location.host, 'elm-spa').onmessage = function () { window.location.reload() } ` 54 | ws.on('request', (req) => { 55 | try { 56 | const conn = req.accept('elm-spa', req.origin) 57 | connections[req.remoteAddress] = conn 58 | conn.on('close', () => delete connections[conn.remoteAddress]) 59 | } catch (_) { /* Safely ignores unknown requests */ } 60 | }) 61 | 62 | // Send reload if any files change 63 | chokidar.watch(config.base, { ignoreInitial: true }) 64 | .on('all', () => Object.values(connections).forEach(conn => conn.sendUTF('reload'))) 65 | 66 | // Start server 67 | server.listen(config.port, () => resolve(`Ready at ${colors.cyan}http://localhost:${config.port}${reset}`)) 68 | server.on('error', _ => { 69 | reject(`Unable to start server... is port ${config.port} in use?`) 70 | }) 71 | }) 72 | 73 | export default { 74 | run: async () => { 75 | const output = await watch(true) 76 | return start().then(serverOutput => [ serverOutput, output ]) 77 | } 78 | } -------------------------------------------------------------------------------- /src/cli/src/cli/watch.ts: -------------------------------------------------------------------------------- 1 | import { build } from './build' 2 | import chokidar from 'chokidar' 3 | import config from '../config' 4 | 5 | export const watch = (runElmMake : boolean) => { 6 | const runBuild = build({ env: 'development', runElmMake }) 7 | 8 | chokidar 9 | .watch(config.folders.src, { ignoreInitial: true }) 10 | .on('all', () => 11 | runBuild() 12 | .then(output => { 13 | console.info('') 14 | console.info(output) 15 | console.info('') 16 | }) 17 | .catch(reason => { 18 | console.info('') 19 | console.error(reason) 20 | console.info('') 21 | }) 22 | ) 23 | 24 | return runBuild() 25 | } 26 | 27 | export default { 28 | run: () => watch(false) 29 | } -------------------------------------------------------------------------------- /src/cli/src/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const reserved = { 4 | homepage: 'Home_', 5 | redirecting: 'Redirecting_', 6 | notFound: 'NotFound' 7 | } 8 | 9 | const root = path.join(__dirname, '..', '..') 10 | const cwd = process.cwd() 11 | 12 | const config = { 13 | reserved, 14 | folders: { 15 | init: path.join(root, 'src', 'new'), 16 | src: path.join(cwd, 'src'), 17 | pages: { 18 | src: path.join(cwd, 'src', 'Pages'), 19 | defaults: path.join(cwd, '.elm-spa', 'defaults', 'Pages') 20 | }, 21 | defaults: { 22 | src: path.join(root, 'src', 'defaults'), 23 | dest: path.join(cwd, '.elm-spa', 'defaults') 24 | }, 25 | generated: path.join(cwd, '.elm-spa', 'generated'), 26 | templates: { 27 | defaults: path.join(root, 'src', 'templates', 'add'), 28 | user: path.join(cwd, '.elm-spa', 'templates') 29 | }, 30 | package: path.join(cwd, '.elm-spa', 'package'), 31 | public: path.join(cwd, 'public'), 32 | dist: path.join(cwd, 'public', 'dist'), 33 | }, 34 | defaults: [ 35 | [ 'Auth.elm' ], 36 | [ 'Effect.elm' ], 37 | [ 'Main.elm' ], 38 | [ 'Shared.elm' ], 39 | [ `Pages`, `${reserved.notFound}.elm` ], 40 | [ 'View.elm' ] 41 | ] 42 | } 43 | 44 | export default config -------------------------------------------------------------------------------- /src/cli/src/defaults/Auth.elm: -------------------------------------------------------------------------------- 1 | module Auth exposing 2 | ( User 3 | , beforeProtectedInit 4 | ) 5 | 6 | {-| 7 | 8 | @docs User 9 | @docs beforeProtectedInit 10 | 11 | -} 12 | 13 | import ElmSpa.Page as ElmSpa 14 | import Gen.Route exposing (Route) 15 | import Request exposing (Request) 16 | import Shared 17 | 18 | 19 | {-| Replace the "()" with your actual User type 20 | -} 21 | type alias User = 22 | () 23 | 24 | 25 | {-| This function will run before any `protected` pages. 26 | 27 | Here, you can provide logic on where to redirect if a user is not signed in. Here's an example: 28 | 29 | case shared.user of 30 | Just user -> 31 | ElmSpa.Provide user 32 | 33 | Nothing -> 34 | ElmSpa.RedirectTo Gen.Route.SignIn 35 | 36 | -} 37 | beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route 38 | beforeProtectedInit shared req = 39 | ElmSpa.RedirectTo Gen.Route.NotFound 40 | -------------------------------------------------------------------------------- /src/cli/src/defaults/Effect.elm: -------------------------------------------------------------------------------- 1 | module Effect exposing 2 | ( Effect, none, map, batch 3 | , fromCmd, fromShared 4 | , toCmd 5 | ) 6 | 7 | {-| 8 | 9 | @docs Effect, none, map, batch 10 | @docs fromCmd, fromShared 11 | @docs toCmd 12 | 13 | -} 14 | 15 | import Shared 16 | import Task 17 | 18 | 19 | type Effect msg 20 | = None 21 | | Cmd (Cmd msg) 22 | | Shared Shared.Msg 23 | | Batch (List (Effect msg)) 24 | 25 | 26 | none : Effect msg 27 | none = 28 | None 29 | 30 | 31 | map : (a -> b) -> Effect a -> Effect b 32 | map fn effect = 33 | case effect of 34 | None -> 35 | None 36 | 37 | Cmd cmd -> 38 | Cmd (Cmd.map fn cmd) 39 | 40 | Shared msg -> 41 | Shared msg 42 | 43 | Batch list -> 44 | Batch (List.map (map fn) list) 45 | 46 | 47 | fromCmd : Cmd msg -> Effect msg 48 | fromCmd = 49 | Cmd 50 | 51 | 52 | fromShared : Shared.Msg -> Effect msg 53 | fromShared = 54 | Shared 55 | 56 | 57 | batch : List (Effect msg) -> Effect msg 58 | batch = 59 | Batch 60 | 61 | 62 | 63 | -- Used by Main.elm 64 | 65 | 66 | toCmd : ( Shared.Msg -> msg, pageMsg -> msg ) -> Effect pageMsg -> Cmd msg 67 | toCmd ( fromSharedMsg, fromPageMsg ) effect = 68 | case effect of 69 | None -> 70 | Cmd.none 71 | 72 | Cmd cmd -> 73 | Cmd.map fromPageMsg cmd 74 | 75 | Shared msg -> 76 | Task.succeed msg 77 | |> Task.perform fromSharedMsg 78 | 79 | Batch list -> 80 | Cmd.batch (List.map (toCmd ( fromSharedMsg, fromPageMsg )) list) 81 | -------------------------------------------------------------------------------- /src/cli/src/defaults/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser 4 | import Browser.Navigation as Nav exposing (Key) 5 | import Effect 6 | import Gen.Model 7 | import Gen.Pages as Pages 8 | import Gen.Route as Route 9 | import Request 10 | import Shared 11 | import Url exposing (Url) 12 | import View 13 | 14 | 15 | main : Program Shared.Flags Model Msg 16 | main = 17 | Browser.application 18 | { init = init 19 | , update = update 20 | , view = view 21 | , subscriptions = subscriptions 22 | , onUrlChange = ChangedUrl 23 | , onUrlRequest = ClickedLink 24 | } 25 | 26 | 27 | 28 | -- INIT 29 | 30 | 31 | type alias Model = 32 | { url : Url 33 | , key : Key 34 | , shared : Shared.Model 35 | , page : Pages.Model 36 | } 37 | 38 | 39 | init : Shared.Flags -> Url -> Key -> ( Model, Cmd Msg ) 40 | init flags url key = 41 | let 42 | ( shared, sharedCmd ) = 43 | Shared.init (Request.create () url key) flags 44 | 45 | ( page, effect ) = 46 | Pages.init (Route.fromUrl url) shared url key 47 | in 48 | ( Model url key shared page 49 | , Cmd.batch 50 | [ Cmd.map Shared sharedCmd 51 | , Effect.toCmd ( Shared, Page ) effect 52 | ] 53 | ) 54 | 55 | 56 | 57 | -- UPDATE 58 | 59 | 60 | type Msg 61 | = ChangedUrl Url 62 | | ClickedLink Browser.UrlRequest 63 | | Shared Shared.Msg 64 | | Page Pages.Msg 65 | 66 | 67 | update : Msg -> Model -> ( Model, Cmd Msg ) 68 | update msg model = 69 | case msg of 70 | ClickedLink (Browser.Internal url) -> 71 | ( model 72 | , Nav.pushUrl model.key (Url.toString url) 73 | ) 74 | 75 | ClickedLink (Browser.External url) -> 76 | ( model 77 | , Nav.load url 78 | ) 79 | 80 | ChangedUrl url -> 81 | if url.path /= model.url.path then 82 | let 83 | ( page, effect ) = 84 | Pages.init (Route.fromUrl url) model.shared url model.key 85 | in 86 | ( { model | url = url, page = page } 87 | , Effect.toCmd ( Shared, Page ) effect 88 | ) 89 | 90 | else 91 | ( { model | url = url }, Cmd.none ) 92 | 93 | Shared sharedMsg -> 94 | let 95 | ( shared, sharedCmd ) = 96 | Shared.update (Request.create () model.url model.key) sharedMsg model.shared 97 | 98 | ( page, effect ) = 99 | Pages.init (Route.fromUrl model.url) shared model.url model.key 100 | in 101 | if page == Gen.Model.Redirecting_ then 102 | ( { model | shared = shared, page = page } 103 | , Cmd.batch 104 | [ Cmd.map Shared sharedCmd 105 | , Effect.toCmd ( Shared, Page ) effect 106 | ] 107 | ) 108 | 109 | else 110 | ( { model | shared = shared } 111 | , Cmd.map Shared sharedCmd 112 | ) 113 | 114 | Page pageMsg -> 115 | let 116 | ( page, effect ) = 117 | Pages.update pageMsg model.page model.shared model.url model.key 118 | in 119 | ( { model | page = page } 120 | , Effect.toCmd ( Shared, Page ) effect 121 | ) 122 | 123 | 124 | 125 | -- VIEW 126 | 127 | 128 | view : Model -> Browser.Document Msg 129 | view model = 130 | Pages.view model.page model.shared model.url model.key 131 | |> View.map Page 132 | |> View.toBrowserDocument 133 | 134 | 135 | 136 | -- SUBSCRIPTIONS 137 | 138 | 139 | subscriptions : Model -> Sub Msg 140 | subscriptions model = 141 | Sub.batch 142 | [ Pages.subscriptions model.page model.shared model.url model.key |> Sub.map Page 143 | , Shared.subscriptions (Request.create () model.url model.key) model.shared |> Sub.map Shared 144 | ] 145 | -------------------------------------------------------------------------------- /src/cli/src/defaults/Pages/NotFound.elm: -------------------------------------------------------------------------------- 1 | module Pages.NotFound exposing (view) 2 | 3 | import View exposing (View) 4 | 5 | 6 | view : View msg 7 | view = 8 | View.placeholder "Page not found." 9 | -------------------------------------------------------------------------------- /src/cli/src/defaults/Shared.elm: -------------------------------------------------------------------------------- 1 | module Shared exposing 2 | ( Flags 3 | , Model 4 | , Msg 5 | , init 6 | , subscriptions 7 | , update 8 | ) 9 | 10 | import Json.Decode as Json 11 | import Request exposing (Request) 12 | 13 | 14 | type alias Flags = 15 | Json.Value 16 | 17 | 18 | type alias Model = 19 | {} 20 | 21 | 22 | type Msg 23 | = NoOp 24 | 25 | 26 | init : Request -> Flags -> ( Model, Cmd Msg ) 27 | init _ _ = 28 | ( {}, Cmd.none ) 29 | 30 | 31 | update : Request -> Msg -> Model -> ( Model, Cmd Msg ) 32 | update _ msg model = 33 | case msg of 34 | NoOp -> 35 | ( model, Cmd.none ) 36 | 37 | 38 | subscriptions : Request -> Model -> Sub Msg 39 | subscriptions _ _ = 40 | Sub.none 41 | -------------------------------------------------------------------------------- /src/cli/src/defaults/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (View, map, none, placeholder, toBrowserDocument) 2 | 3 | import Browser 4 | import Html exposing (Html) 5 | 6 | 7 | type alias View msg = 8 | { title : String 9 | , body : List (Html msg) 10 | } 11 | 12 | 13 | placeholder : String -> View msg 14 | placeholder str = 15 | { title = str 16 | , body = [ Html.text str ] 17 | } 18 | 19 | 20 | none : View msg 21 | none = 22 | placeholder "" 23 | 24 | 25 | map : (a -> b) -> View a -> View b 26 | map fn view = 27 | { title = view.title 28 | , body = List.map (Html.map fn) view.body 29 | } 30 | 31 | 32 | toBrowserDocument : View msg -> Browser.Document msg 33 | toBrowserDocument view = 34 | { title = view.title 35 | , body = view.body 36 | } 37 | -------------------------------------------------------------------------------- /src/cli/src/file.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import oldFs from 'fs' 3 | import path from "path" 4 | 5 | /** 6 | * Create a new file, creating the containing folder if missing. 7 | * @param filepath - the absolute path of the file to create 8 | * @param contents - the raw string contents of the file 9 | */ 10 | export const create = async (filepath : string, contents : string) => { 11 | await ensureFolderExists(filepath) 12 | return fs.writeFile(filepath, contents, { encoding: 'utf8' }) 13 | } 14 | 15 | /** 16 | * Removes a file or folder at the given path. 17 | * @param filepath - the path of the file or folder to remove 18 | */ 19 | export const remove = async (filepath: string) => { 20 | const stats = await fs.stat(filepath) 21 | return stats.isFile() 22 | ? fs.unlink(filepath) 23 | : fs.rmdir(filepath, { recursive: true }) 24 | } 25 | 26 | export const scan = async (dir: string, extension = '.elm'): Promise => { 27 | const doesExist = await exists(dir) 28 | if (!doesExist) return [] 29 | const items = await ls(dir) 30 | const [folders, files] = await Promise.all([ 31 | keepFolders(items), 32 | items.filter(f => f.endsWith(extension)) 33 | ]) 34 | const listOfFiles = await Promise.all(folders.map(f => scan(f, extension))) 35 | const nestedFiles = listOfFiles.reduce((a, b) => a.concat(b), []) 36 | return files.concat(nestedFiles) 37 | } 38 | 39 | const ls = (dir: string): Promise => 40 | fs.readdir(dir) 41 | .then(data => data.map(p => path.join(dir, p))) 42 | 43 | const isDirectory = (dir: string): Promise => 44 | fs.lstat(dir).then(data => data.isDirectory()).catch(_ => false) 45 | 46 | const keepFolders = async (files: string[]): Promise => { 47 | const possibleFolders = await Promise.all( 48 | files.map(f => isDirectory(f).then(isDir => isDir ? f : undefined)) 49 | ) 50 | return possibleFolders.filter(a => a !== undefined) as string[] 51 | } 52 | 53 | export const exists = (filepath: string) => 54 | fs.stat(filepath) 55 | .then(_ => true) 56 | .catch(_ => false) 57 | 58 | 59 | /** 60 | * Copy the file or folder at the given path. 61 | * @param filepath - the path of the file or folder to copy 62 | */ 63 | export const copy = (src : string, dest : string) => { 64 | const exists = oldFs.existsSync(src) 65 | const stats = exists && oldFs.statSync(src) 66 | if (stats && stats.isDirectory()) { 67 | try { oldFs.mkdirSync(dest, { recursive: true }) } catch (_) {} 68 | oldFs.readdirSync(src).forEach(child => 69 | copy(path.join(src, child), path.join(dest, child)) 70 | ) 71 | } else { 72 | oldFs.copyFileSync(src, dest) 73 | } 74 | } 75 | 76 | export const copyFile = async (src : string, dest : string) => { 77 | await ensureFolderExists(dest) 78 | return fs.copyFile(src, dest) 79 | } 80 | 81 | 82 | const ensureFolderExists = async (filepath : string) => { 83 | const folder = filepath.split(path.sep).slice(0, -1).join(path.sep) 84 | return fs.mkdir(folder, { recursive: true }) 85 | } 86 | 87 | export const mkdir = (folder : string) : Promise => 88 | fs.mkdir(folder, { recursive: true }) 89 | 90 | export const read = async (path: string) => 91 | fs.readFile(path, { encoding: 'utf-8' }) 92 | -------------------------------------------------------------------------------- /src/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import CLI from './cli' 4 | import { Commands } from './types' 5 | 6 | const commands : Commands = { 7 | new: CLI.new, 8 | add: CLI.add, 9 | build: CLI.build, 10 | gen: CLI.gen, 11 | watch: CLI.watch, 12 | server: CLI.server, 13 | help: CLI.help, 14 | // Aliases for Elm folks 15 | init: CLI.new, 16 | make: CLI.build, 17 | } 18 | 19 | const command : string | undefined = process.argv[2] 20 | 21 | Promise.resolve(command) 22 | .then(cmd => commands[cmd as keyof Commands] || commands.help) 23 | .then(task => task()) 24 | .then(output => { 25 | const message = output instanceof Array ? output : [ output ] 26 | console.info('') 27 | console.info(message.join('\n\n')) 28 | }) 29 | .catch(reason => { 30 | console.info('') 31 | console.error(reason) 32 | console.info('') 33 | process.exit(1) 34 | }) -------------------------------------------------------------------------------- /src/cli/src/new/README.md: -------------------------------------------------------------------------------- 1 | # my new project 2 | > 🌳 built with [elm-spa](https://elm-spa.dev) 3 | 4 | ## dependencies 5 | 6 | This project requires the latest LTS version of [Node.js](https://nodejs.org/) 7 | 8 | ```bash 9 | npm install -g elm elm-spa 10 | ``` 11 | 12 | ## running locally 13 | 14 | ```bash 15 | elm-spa server # starts this app at http:/localhost:1234 16 | ``` 17 | 18 | ### other commands 19 | 20 | ```bash 21 | elm-spa add # add a new page to the application 22 | elm-spa build # production build 23 | elm-spa watch # runs build as you code (without the server) 24 | ``` 25 | 26 | ## learn more 27 | 28 | You can learn more at [elm-spa.dev](https://elm-spa.dev) -------------------------------------------------------------------------------- /src/cli/src/new/_gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .elm-spa 3 | elm-stuff 4 | node_modules 5 | dist -------------------------------------------------------------------------------- /src/cli/src/new/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | ".elm-spa/defaults", 6 | ".elm-spa/generated" 7 | ], 8 | "elm-version": "0.19.1", 9 | "dependencies": { 10 | "direct": { 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.5", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/url": "1.0.0", 16 | "ryan-haskell/elm-spa": "1.0.0" 17 | }, 18 | "indirect": { 19 | "elm/time": "1.0.0", 20 | "elm/virtual-dom": "1.0.2" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /src/cli/src/new/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/cli/src/new/src/Pages/Home_.elm: -------------------------------------------------------------------------------- 1 | module Pages.Home_ exposing (view) 2 | 3 | import Html 4 | import View exposing (View) 5 | 6 | 7 | view : View msg 8 | view = 9 | { title = "Homepage" 10 | , body = [ Html.text "Hello, world!" ] 11 | } 12 | -------------------------------------------------------------------------------- /src/cli/src/templates/add.ts: -------------------------------------------------------------------------------- 1 | export default (page: string[]): string => ` 2 | module Pages.${page.join('.')} exposing (view) 3 | 4 | import View exposing (View) 5 | 6 | 7 | view : View msg 8 | view = 9 | View.placeholder "${page.join('.')}" 10 | 11 | `.trimLeft() -------------------------------------------------------------------------------- /src/cli/src/templates/add/advanced.elm: -------------------------------------------------------------------------------- 1 | module Pages.{{module}} exposing (Model, Msg, page) 2 | 3 | import Effect exposing (Effect) 4 | import Gen.Params.{{module}} exposing (Params) 5 | import Page 6 | import Request 7 | import Shared 8 | import View exposing (View) 9 | import Page 10 | 11 | 12 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 13 | page shared req = 14 | Page.advanced 15 | { init = init 16 | , update = update 17 | , view = view 18 | , subscriptions = subscriptions 19 | } 20 | 21 | 22 | 23 | -- INIT 24 | 25 | 26 | type alias Model = 27 | {} 28 | 29 | 30 | init : ( Model, Effect Msg ) 31 | init = 32 | ( {}, Effect.none ) 33 | 34 | 35 | 36 | -- UPDATE 37 | 38 | 39 | type Msg 40 | = ReplaceMe 41 | 42 | 43 | update : Msg -> Model -> ( Model, Effect Msg ) 44 | update msg model = 45 | case msg of 46 | ReplaceMe -> 47 | ( model, Effect.none ) 48 | 49 | 50 | 51 | -- SUBSCRIPTIONS 52 | 53 | 54 | subscriptions : Model -> Sub Msg 55 | subscriptions model = 56 | Sub.none 57 | 58 | 59 | 60 | -- VIEW 61 | 62 | 63 | view : Model -> View Msg 64 | view model = 65 | View.placeholder "{{module}}" 66 | -------------------------------------------------------------------------------- /src/cli/src/templates/add/element.elm: -------------------------------------------------------------------------------- 1 | module Pages.{{module}} exposing (Model, Msg, page) 2 | 3 | import Gen.Params.{{module}} exposing (Params) 4 | import Page 5 | import Request 6 | import Shared 7 | import View exposing (View) 8 | 9 | 10 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 11 | page shared req = 12 | Page.element 13 | { init = init 14 | , update = update 15 | , view = view 16 | , subscriptions = subscriptions 17 | } 18 | 19 | 20 | 21 | -- INIT 22 | 23 | 24 | type alias Model = 25 | {} 26 | 27 | 28 | init : ( Model, Cmd Msg ) 29 | init = 30 | ( {}, Cmd.none ) 31 | 32 | 33 | 34 | -- UPDATE 35 | 36 | 37 | type Msg 38 | = ReplaceMe 39 | 40 | 41 | update : Msg -> Model -> ( Model, Cmd Msg ) 42 | update msg model = 43 | case msg of 44 | ReplaceMe -> 45 | ( model, Cmd.none ) 46 | 47 | 48 | 49 | -- SUBSCRIPTIONS 50 | 51 | 52 | subscriptions : Model -> Sub Msg 53 | subscriptions model = 54 | Sub.none 55 | 56 | 57 | 58 | -- VIEW 59 | 60 | 61 | view : Model -> View Msg 62 | view model = 63 | View.placeholder "{{module}}" 64 | -------------------------------------------------------------------------------- /src/cli/src/templates/add/sandbox.elm: -------------------------------------------------------------------------------- 1 | module Pages.{{module}} exposing (Model, Msg, page) 2 | 3 | import Gen.Params.{{module}} exposing (Params) 4 | import Page 5 | import Request 6 | import Shared 7 | import View exposing (View) 8 | 9 | 10 | page : Shared.Model -> Request.With Params -> Page.With Model Msg 11 | page shared req = 12 | Page.sandbox 13 | { init = init 14 | , update = update 15 | , view = view 16 | } 17 | 18 | 19 | 20 | -- INIT 21 | 22 | 23 | type alias Model = 24 | {} 25 | 26 | 27 | init : Model 28 | init = 29 | {} 30 | 31 | 32 | 33 | -- UPDATE 34 | 35 | 36 | type Msg 37 | = ReplaceMe 38 | 39 | 40 | update : Msg -> Model -> Model 41 | update msg model = 42 | case msg of 43 | ReplaceMe -> 44 | model 45 | 46 | 47 | 48 | -- VIEW 49 | 50 | 51 | view : Model -> View Msg 52 | view model = 53 | View.placeholder "{{module}}" 54 | -------------------------------------------------------------------------------- /src/cli/src/templates/add/static.elm: -------------------------------------------------------------------------------- 1 | module Pages.{{module}} exposing (page) 2 | 3 | import Gen.Params.{{module}} exposing (Params) 4 | import Page exposing (Page) 5 | import Request 6 | import Shared 7 | import View exposing (View) 8 | 9 | 10 | page : Shared.Model -> Request.With Params -> Page 11 | page shared req = 12 | Page.static 13 | { view = view 14 | } 15 | 16 | 17 | view : View msg 18 | view = 19 | View.placeholder "{{module}}" 20 | -------------------------------------------------------------------------------- /src/cli/src/templates/model.ts: -------------------------------------------------------------------------------- 1 | import config from "../config" 2 | import { 3 | pagesImports, paramsImports, 4 | pagesModelDefinition, 5 | Options 6 | } from "./utils" 7 | 8 | export default (pages : string[][], options : Options) : string => ` 9 | module Gen.Model exposing (Model(..)) 10 | 11 | ${paramsImports(pages)} 12 | ${pagesImports(pages)} 13 | 14 | 15 | ${pagesModelDefinition([ [ config.reserved.redirecting ] ].concat(pages), options)} 16 | 17 | `.trimLeft() 18 | -------------------------------------------------------------------------------- /src/cli/src/templates/msg.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pagesImports, paramsImports, 3 | pagesMsgDefinition, 4 | Options, 5 | } from "./utils" 6 | 7 | export default (pages : string[][], options : Options) : string => ` 8 | module Gen.Msg exposing (Msg(..)) 9 | 10 | ${paramsImports(pages)} 11 | ${pagesImports(pages)} 12 | 13 | 14 | ${pagesMsgDefinition(pages.filter(path => !options.isStaticView(path)), options)} 15 | 16 | `.trimLeft() 17 | -------------------------------------------------------------------------------- /src/cli/src/templates/page.ts: -------------------------------------------------------------------------------- 1 | export default (): string => ` 2 | module Page exposing 3 | ( Page, With 4 | , static, sandbox, element, advanced 5 | , protected 6 | ) 7 | 8 | {-| 9 | 10 | @docs Page, With 11 | @docs static, sandbox, element, advanced 12 | @docs protected 13 | 14 | -} 15 | 16 | import Auth exposing (User) 17 | import Effect exposing (Effect) 18 | import ElmSpa.Page as ElmSpa 19 | import Gen.Route exposing (Route) 20 | import Request exposing (Request) 21 | import Shared 22 | import View exposing (View) 23 | 24 | 25 | 26 | -- PAGES 27 | 28 | 29 | type alias Page = 30 | With () Never 31 | 32 | 33 | type alias With model msg = 34 | ElmSpa.Page Shared.Model Route (Effect msg) (View msg) model msg 35 | 36 | 37 | static : 38 | { view : View Never 39 | } 40 | -> Page 41 | static = 42 | ElmSpa.static Effect.none 43 | 44 | 45 | sandbox : 46 | { init : model 47 | , update : msg -> model -> model 48 | , view : model -> View msg 49 | } 50 | -> With model msg 51 | sandbox = 52 | ElmSpa.sandbox Effect.none 53 | 54 | 55 | element : 56 | { init : ( model, Cmd msg ) 57 | , update : msg -> model -> ( model, Cmd msg ) 58 | , view : model -> View msg 59 | , subscriptions : model -> Sub msg 60 | } 61 | -> With model msg 62 | element = 63 | ElmSpa.element Effect.fromCmd 64 | 65 | 66 | advanced : 67 | { init : ( model, Effect msg ) 68 | , update : msg -> model -> ( model, Effect msg ) 69 | , view : model -> View msg 70 | , subscriptions : model -> Sub msg 71 | } 72 | -> With model msg 73 | advanced = 74 | ElmSpa.advanced 75 | 76 | 77 | 78 | -- PROTECTED PAGES 79 | 80 | 81 | protected : 82 | { static : 83 | (User 84 | -> 85 | { view : View msg 86 | } 87 | ) 88 | -> With () msg 89 | , sandbox : 90 | (User 91 | -> 92 | { init : model 93 | , update : msg -> model -> model 94 | , view : model -> View msg 95 | } 96 | ) 97 | -> With model msg 98 | , element : 99 | (User 100 | -> 101 | { init : ( model, Cmd msg ) 102 | , update : msg -> model -> ( model, Cmd msg ) 103 | , view : model -> View msg 104 | , subscriptions : model -> Sub msg 105 | } 106 | ) 107 | -> With model msg 108 | , advanced : 109 | (User 110 | -> 111 | { init : ( model, Effect msg ) 112 | , update : msg -> model -> ( model, Effect msg ) 113 | , view : model -> View msg 114 | , subscriptions : model -> Sub msg 115 | } 116 | ) 117 | -> With model msg 118 | } 119 | protected = 120 | ElmSpa.protected 121 | { effectNone = Effect.none 122 | , fromCmd = Effect.fromCmd 123 | , beforeInit = Auth.beforeProtectedInit 124 | } 125 | 126 | `.trimLeft() -------------------------------------------------------------------------------- /src/cli/src/templates/pages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pagesImports, paramsImports, 3 | pagesBundleAnnotation, 4 | pagesBundleDefinition, 5 | pagesInitBody, 6 | pagesSubscriptionsBody, 7 | pagesViewBody, 8 | pagesUpdateBody, 9 | pagesUpdateCatchAll, 10 | Options 11 | } from "./utils" 12 | 13 | export default (pages : string[][], options : Options) : string => ` 14 | module Gen.Pages exposing (Model, Msg, init, subscriptions, update, view) 15 | 16 | import Browser.Navigation exposing (Key) 17 | import Effect exposing (Effect) 18 | import ElmSpa.Page 19 | ${paramsImports(pages)} 20 | import Gen.Model as Model 21 | import Gen.Msg as Msg 22 | import Gen.Route as Route exposing (Route) 23 | import Page exposing (Page) 24 | ${pagesImports(pages)} 25 | import Request exposing (Request) 26 | import Shared 27 | import Task 28 | import Url exposing (Url) 29 | import View exposing (View) 30 | 31 | 32 | type alias Model = 33 | Model.Model 34 | 35 | 36 | type alias Msg = 37 | Msg.Msg 38 | 39 | 40 | init : Route -> Shared.Model -> Url -> Key -> ( Model, Effect Msg ) 41 | init route = 42 | ${pagesInitBody(pages)} 43 | 44 | 45 | update : Msg -> Model -> Shared.Model -> Url -> Key -> ( Model, Effect Msg ) 46 | update msg_ model_ = 47 | ${pagesUpdateBody(pages.filter(page => !options.isStaticView(page)), options)} 48 | ${pages.length > 1 ? pagesUpdateCatchAll : ''} 49 | 50 | 51 | view : Model -> Shared.Model -> Url -> Key -> View Msg 52 | view model_ = 53 | ${pagesViewBody(pages, options)} 54 | 55 | 56 | subscriptions : Model -> Shared.Model -> Url -> Key -> Sub Msg 57 | subscriptions model_ = 58 | ${pagesSubscriptionsBody(pages, options)} 59 | 60 | 61 | 62 | -- INTERNALS 63 | 64 | 65 | pages : 66 | ${pagesBundleAnnotation(pages, options)} 67 | pages = 68 | ${pagesBundleDefinition(pages, options)} 69 | 70 | 71 | type alias Bundle params model msg = 72 | ElmSpa.Page.Bundle params model msg Shared.Model (Effect Msg) Model Msg (View Msg) 73 | 74 | 75 | bundle page toModel toMsg = 76 | ElmSpa.Page.bundle 77 | { redirecting = 78 | { model = Model.Redirecting_ 79 | , view = View.none 80 | } 81 | , toRoute = Route.fromUrl 82 | , toUrl = Route.toHref 83 | , fromCmd = Effect.fromCmd 84 | , mapEffect = Effect.map toMsg 85 | , mapView = View.map toMsg 86 | , toModel = toModel 87 | , toMsg = toMsg 88 | , page = page 89 | } 90 | 91 | 92 | type alias Static params = 93 | Bundle params () Never 94 | 95 | 96 | static : View Never -> (params -> Model) -> Static params 97 | static view_ toModel = 98 | { init = \\params _ _ _ -> ( toModel params, Effect.none ) 99 | , update = \\params _ _ _ _ _ -> ( toModel params, Effect.none ) 100 | , view = \\_ _ _ _ _ -> View.map never view_ 101 | , subscriptions = \\_ _ _ _ _ -> Sub.none 102 | } 103 | 104 | `.trimLeft() 105 | -------------------------------------------------------------------------------- /src/cli/src/templates/params.ts: -------------------------------------------------------------------------------- 1 | import { Options, routeParameters, routeParser } from "./utils" 2 | 3 | export default (page : string[], options : Options) : string => ` 4 | module Gen.Params.${page.join('.')} exposing (Params, parser) 5 | 6 | import Url.Parser as Parser exposing ((), Parser) 7 | 8 | 9 | type alias Params = 10 | ${routeParameters(page)} 11 | 12 | 13 | parser = 14 | ${routeParser(page)} 15 | 16 | `.trimLeft() -------------------------------------------------------------------------------- /src/cli/src/templates/request.ts: -------------------------------------------------------------------------------- 1 | export default (): string => ` 2 | module Request exposing 3 | ( Request, With 4 | , create 5 | , pushRoute, replaceRoute 6 | ) 7 | 8 | {-| 9 | 10 | @docs Request, With 11 | @docs create 12 | @docs pushRoute, replaceRoute 13 | 14 | -} 15 | 16 | import Browser.Navigation exposing (Key) 17 | import ElmSpa.Request as ElmSpa 18 | import Gen.Route as Route exposing (Route) 19 | import Url exposing (Url) 20 | 21 | 22 | type alias Request = 23 | With () 24 | 25 | 26 | type alias With params = 27 | ElmSpa.Request Route params 28 | 29 | 30 | create : params -> Url -> Key -> With params 31 | create params url key = 32 | ElmSpa.create (Route.fromUrl url) params url key 33 | 34 | 35 | pushRoute : Route -> With params -> Cmd msg 36 | pushRoute route req = 37 | Browser.Navigation.pushUrl req.key (Route.toHref route) 38 | 39 | 40 | replaceRoute : Route -> With params -> Cmd msg 41 | replaceRoute route req = 42 | Browser.Navigation.replaceUrl req.key (Route.toHref route) 43 | 44 | `.trimLeft() -------------------------------------------------------------------------------- /src/cli/src/templates/routes.ts: -------------------------------------------------------------------------------- 1 | import config from "../config" 2 | import { routeTypeDefinition, indent, routeParserList, paramsImports, Options, routeToHref } from "./utils" 3 | 4 | const routeParserOrder = (pages: string[][]) => 5 | [...pages].sort(sorter) 6 | 7 | const isHomepage = (list: string[]) => list.join('.') === config.reserved.homepage 8 | const isDynamic = (piece: string) => piece.endsWith('_') 9 | const alphaSorter = (a: string, b: string) => a < b ? -1 : b < a ? 1 : 0 10 | 11 | const sorter = (a: string[], b: string[]): (-1 | 1 | 0) => { 12 | if (isHomepage(a)) return -1 13 | if (isHomepage(b)) return 1 14 | 15 | if (a.length < b.length) return -1 16 | if (a.length > b.length) return 1 17 | 18 | for (let i in a) { 19 | const [isA, isB] = [isDynamic(a[i]), isDynamic(b[i])] 20 | if (isA && isB) return alphaSorter(a[i], b[i]) 21 | if (isA) return 1 22 | if (isB) return -1 23 | } 24 | 25 | return 0 26 | } 27 | 28 | export default (pages: string[][], _options: Options): string => ` 29 | module Gen.Route exposing 30 | ( Route(..) 31 | , fromUrl 32 | , toHref 33 | ) 34 | 35 | ${paramsImports(pages)} 36 | import Url exposing (Url) 37 | import Url.Parser as Parser exposing ((), Parser) 38 | 39 | 40 | ${routeTypeDefinition(pages)} 41 | 42 | 43 | fromUrl : Url -> Route 44 | fromUrl = 45 | Parser.parse (Parser.oneOf routes) >> Maybe.withDefault NotFound 46 | 47 | 48 | routes : List (Parser (Route -> a) a) 49 | routes = 50 | ${indent(routeParserList(routeParserOrder(pages)), 1)} 51 | 52 | 53 | toHref : Route -> String 54 | toHref route = 55 | let 56 | joinAsHref : List String -> String 57 | joinAsHref segments = 58 | "/" ++ String.join "/" segments 59 | in 60 | ${indent(routeToHref(pages), 1)} 61 | 62 | `.trimLeft() -------------------------------------------------------------------------------- /src/cli/src/terminal.ts: -------------------------------------------------------------------------------- 1 | export const reset = '\x1b[0m' 2 | export const bold = '\x1b[1m' 3 | export const dim = '\x1b[2m' 4 | export const underline = '\x1b[4m' 5 | export const colors = { 6 | green: '\x1b[32m', 7 | yellow: '\x1b[33m', 8 | cyan: '\x1b[36m', 9 | RED: '\x1b[31m' 10 | } 11 | export const error = colors.RED + '⨉' + reset 12 | export const dot = colors.cyan + '•' + reset 13 | export const check = colors.green + '✓' + reset 14 | export const warn = colors.yellow + '!' + reset -------------------------------------------------------------------------------- /src/cli/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Commands = { 2 | new: () => any 3 | add: () => any 4 | build: () => any 5 | gen: () => any 6 | watch: () => any 7 | server: () => any 8 | help: () => any 9 | // Aliases for Elm folks 10 | init: () => any 11 | make: () => any 12 | } -------------------------------------------------------------------------------- /src/cli/tests/file.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { create, remove } from '../src/file' 3 | 4 | const temp = path.join(__dirname, 'dist') 5 | 6 | describe("file", () => { 7 | describe("create", () => { 8 | test('can create files', async () => { 9 | await create(path.join(temp, 'hello.txt'), 'Hello!') 10 | await create(path.join(temp, 'apple', 'banana', 'cherry.txt'), 'abc') 11 | }) 12 | test('can remove files', async () => { 13 | await remove(path.join(temp, 'hello.txt')) 14 | }) 15 | test('can remove folders', async () => { 16 | await remove(temp) 17 | }) 18 | }) 19 | }) -------------------------------------------------------------------------------- /src/cli/tests/templates/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import config from '../../src/config' 2 | import * as Utils from '../../src/templates/utils' 3 | 4 | describe("Templates.Utils", () => { 5 | 6 | test.each([ 7 | [ '/', [ 'Home_' ] ], 8 | [ '/about-us', [ 'AboutUs' ] ], 9 | [ '/people/:name', [ 'People', 'Name_' ] ], 10 | [ '/users/:name/posts/:id', [ 'Users', 'Name_', 'Posts', 'Id_' ] ], 11 | [ 'Pages.AboutUs', [ 'AboutUs' ] ], 12 | [ 'Pages/AboutUs.elm', [ 'AboutUs' ] ], 13 | [ 'Pages.People.Name_', [ 'People', 'Name_' ] ], 14 | [ 'People.Name_', [ 'People', 'Name_' ] ], 15 | [ 'Pages/People/Name_.elm', [ 'People', 'Name_' ] ], 16 | [ 'People/Name_.elm', [ 'People', 'Name_' ] ], 17 | [ 'Home_', [ 'Home_' ] ], 18 | [ 'Pages.Home_', [ 'Home_' ] ], 19 | [ 'Home_.elm', [ 'Home_' ] ], 20 | [ 'Pages/Home_.elm', [ 'Home_' ] ], 21 | ])(".urlArgumentToPages(%p)", (input, output) => { 22 | expect(Utils.urlArgumentToPages(input)).toEqual(output) 23 | }) 24 | 25 | test.each([ 26 | [ [ config.reserved.homepage ], config.reserved.homepage ], 27 | [ [ "AboutUs" ], "AboutUs" ], 28 | [ [ "AboutUs", "Offices" ], "AboutUs__Offices" ], 29 | [ [ "Posts" ], "Posts" ], 30 | [ [ "Posts", "Id_" ], "Posts__Id_" ], 31 | [ [ "Users", "Name_", "Settings" ], "Users__Name___Settings" ], 32 | [ [ "Users", "Name_", "Posts", "Id_" ], "Users__Name___Posts__Id_" ], 33 | ])(".routeVariant(%p)", (input, output) => { 34 | expect(Utils.routeVariant(input)).toBe(output) 35 | }) 36 | 37 | test.each([ 38 | [ [ config.reserved.homepage ], `()` ], 39 | [ [ "AboutUs" ], "()" ], 40 | [ [ "AboutUs", "Offices" ], "()" ], 41 | [ [ "Posts" ], "()" ], 42 | [ [ "Posts", "Id_" ], "{ id : String }" ], 43 | [ [ "Users", "Name_", "Settings" ], "{ name : String }" ], 44 | [ [ "Users", "Name_", "Posts", "Id_" ], "{ name : String, id : String }" ], 45 | ])(".routeParameters(%p)", (input, output) => { 46 | expect(Utils.routeParameters(input)).toBe(output) 47 | }) 48 | 49 | test.each([ 50 | [ [ config.reserved.homepage ], `(Parser.top)` ], 51 | [ [ "AboutUs" ], `(Parser.s "about-us")` ], 52 | [ [ "AboutUs", "Offices" ], `(Parser.s "about-us" Parser.s "offices")` ], 53 | [ [ "Posts" ], `(Parser.s "posts")` ], 54 | [ [ "Posts", "Id_" ], `Parser.map Params (Parser.s "posts" Parser.string)` ], 55 | [ [ "Users", "Name_", "Settings" ], `Parser.map Params (Parser.s "users" Parser.string Parser.s "settings")` ], 56 | [ [ "Users", "Name_", "Posts", "Id_" ], `Parser.map Params (Parser.s "users" Parser.string Parser.s "posts" Parser.string)` ], 57 | ])(".routeParser(%p)", (input, output) => { 58 | expect(Utils.routeParser(input)).toBe(output) 59 | }) 60 | 61 | test.each([ 62 | [ [ config.reserved.homepage ], `Parser.map ${config.reserved.homepage} Gen.Params.${config.reserved.homepage}.parser` ], 63 | [ [ `AboutUs` ], `Parser.map AboutUs Gen.Params.AboutUs.parser` ], 64 | [ [ `AboutUs`, `Offices` ], `Parser.map AboutUs__Offices Gen.Params.AboutUs.Offices.parser` ], 65 | [ [ `Posts` ], `Parser.map Posts Gen.Params.Posts.parser` ], 66 | [ [ `Posts`, `Id_` ], `Parser.map Posts__Id_ Gen.Params.Posts.Id_.parser` ], 67 | [ [ `Users`, `Name_`, `Settings` ], `Parser.map Users__Name___Settings Gen.Params.Users.Name_.Settings.parser` ], 68 | [ [ `Users`, `Name_`, `Posts`, `Id_` ], `Parser.map Users__Name___Posts__Id_ Gen.Params.Users.Name_.Posts.Id_.parser` ] 69 | ])(".routeFunction(%p)", (input, output) => { 70 | expect(Utils.routeParserMap(input)).toBe(output) 71 | }) 72 | 73 | test.each([ 74 | [ [], `[]`, 75 | [ '1' ], ` 76 | [ 1 77 | ] 78 | `.trim(), 79 | [ '1', '2', '3' ], ` 80 | [ 1 81 | , 2 82 | , 3 83 | ] 84 | `.trim() 85 | ] 86 | ])(`.multilineList(%p)`, (input, output) => { 87 | expect(Utils.multilineList(input)).toBe(output) 88 | }) 89 | 90 | test(`.indent([ 1, 2, 3 ])`, () => { 91 | expect(Utils.indent(Utils.multilineList([ '1', '2', '3' ]))).toBe(` 92 | [ 1 93 | , 2 94 | , 3 95 | ]`.substr(1)) 96 | }) 97 | 98 | test(`.indent([ 1, 2, 3 ], 2)`, () => { 99 | expect(Utils.indent(Utils.multilineList([ '1', '2', '3' ]), 2)).toBe(` 100 | [ 1 101 | , 2 102 | , 3 103 | ]`.substr(1)) 104 | }) 105 | 106 | test(".customType(%p)", () => { 107 | expect(Utils.customType(`Color`, [ `Red`, `Green`, `Blue`, `Other String` ])) 108 | .toBe(` 109 | type Color 110 | = Red 111 | | Green 112 | | Blue 113 | | Other String 114 | `.trim()) 115 | }) 116 | 117 | test(`.routeTypeDefinition`, () => { 118 | expect(Utils.routeTypeDefinition([ 119 | [ config.reserved.homepage ], 120 | [ 'AboutUs' ], 121 | [ 'Users', 'Name_' ], 122 | [ 'Settings', 'Section_', 'New' ] 123 | ])).toBe(` 124 | type Route 125 | = ${config.reserved.homepage} 126 | | AboutUs 127 | | Users__Name_ { name : String } 128 | | Settings__Section___New { section : String } 129 | `.trim()) 130 | }) 131 | 132 | test(`.routeParserList`, () => { 133 | expect(Utils.routeParserList([ 134 | [ config.reserved.homepage ], 135 | [ 'AboutUs' ], 136 | [ 'Users', 'Name_' ], 137 | [ 'Settings', 'Section_', 'New' ] 138 | ])).toBe(` 139 | [ Parser.map ${config.reserved.homepage} Gen.Params.${config.reserved.homepage}.parser 140 | , Parser.map AboutUs Gen.Params.AboutUs.parser 141 | , Parser.map Users__Name_ Gen.Params.Users.Name_.parser 142 | , Parser.map Settings__Section___New Gen.Params.Settings.Section_.New.parser 143 | ] 144 | `.trim()) 145 | }) 146 | 147 | test.each([ 148 | [ [ config.reserved.homepage ], `[]` ], 149 | [ [ "AboutUs" ], `[ "about-us" ]` ], 150 | [ [ "AboutUs", "Offices" ], `[ "about-us", "offices" ]` ], 151 | [ [ "Posts" ], `[ "posts" ]` ], 152 | [ [ "Posts", "Id_" ], `[ "posts", params.id ]` ], 153 | [ [ "Users", "Name_", "Settings" ], `[ "users", params.name, "settings" ]` ], 154 | [ [ "Users", "Name_", "Posts", "Id_" ], `[ "users", params.name, "posts", params.id ]` ], 155 | ])(".routeVariant(%p)", (input, output) => { 156 | expect(Utils.routeToHrefSegments(input)).toBe(output) 157 | }) 158 | }) 159 | 160 | describe.each([['Model'], ['Msg']]) 161 | ('Utils.exposes%s', (name: string) => { 162 | const fn = (Utils as any)[`exposes${name}`] as (val: string) => boolean 163 | 164 | test('fails for exposing all', () => 165 | expect(fn(`module Layout exposing (..)`)).toBe(false) 166 | ) 167 | 168 | test(`fails if missing keyword`, () => { 169 | expect(fn(`module Layout exposing (OtherImport)`)).toBe(false) 170 | expect(fn(`module Layout exposing 171 | ( OtherImport 172 | ) 173 | `)).toBe(false) 174 | }) 175 | 176 | test(`works with single-line exposing "${name}"`, () => { 177 | expect(fn(`module Layout exposing (${name})`)).toBe(true) 178 | expect(fn(`module Layout exposing (OtherImport, ${name})`)).toBe(true) 179 | expect(fn(`module Layout exposing (${name}, OtherImport)`)).toBe(true) 180 | }) 181 | 182 | test(`works with multi-line exposing "${name}"`, () => { 183 | expect(fn(` 184 | module Layout exposing 185 | ( ${name} 186 | ) 187 | `.trim())).toBe(true) 188 | expect(fn(` 189 | module Layout exposing 190 | ( OtherImport 191 | , ${name} 192 | ) 193 | `.trim())).toBe(true) 194 | expect(fn(` 195 | module Layout exposing 196 | ( ${name} 197 | , OtherImport 198 | ) 199 | `.trim())).toBe(true) 200 | }) 201 | }) 202 | 203 | describe('exposes', () => { 204 | it('works with unexposed variants', () =>{ 205 | expect(Utils.exposesMsg('module Page exposing (Msg)')).toBe(true) 206 | expect(Utils.exposesMsg('module Page exposing (Model, Msg)')).toBe(true) 207 | expect(Utils.exposesMsg('module Page exposing (Model, Msg, view)')).toBe(true) 208 | }) 209 | it('works with exposed variants', () =>{ 210 | expect(Utils.exposesMsg('module Page exposing (Msg(..))')).toBe(true) 211 | expect(Utils.exposesMsg('module Page exposing (Model, Msg(..))')).toBe(true) 212 | expect(Utils.exposesMsg('module Page exposing (Model, Msg(..), view)')).toBe(true) 213 | }) 214 | it(`exposed variants don't cause issues for other keywords`, () => { 215 | expect(Utils.exposesModel('module Page exposing (Model, Msg(..), view, page)')).toBe(true) 216 | expect(Utils.exposesViewFunction('module Page exposing (Model, Msg(..), view, page)')).toBe(true) 217 | expect(Utils.exposesPageFunction('module Page exposing (Model, Msg(..), view, page)')).toBe(true) 218 | 219 | expect(Utils.exposesModel('module Page exposing (Msg(..), view) Model')).toBe(false) 220 | expect(Utils.exposesViewFunction('module Page exposing (Model, Msg(..)) view')).toBe(false) 221 | expect(Utils.exposesPageFunction('module Page exposing (Model, Msg(..)) page')).toBe(false) 222 | }) 223 | }) -------------------------------------------------------------------------------- /src/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | "dist", 16 | "tests" 17 | ] 18 | } --------------------------------------------------------------------------------