├── examples ├── app │ ├── .gitignore │ ├── posts.json │ ├── index.html │ ├── README.md │ ├── elm-package.json │ ├── src │ │ ├── Data.elm │ │ ├── Routes.elm │ │ └── Main.elm │ └── server.py └── Custom.elm ├── tests ├── .gitignore ├── elm-package.json └── Tests.elm ├── .gitignore ├── elm-package.json ├── CHANGELOG.md ├── .travis.yml ├── LICENSE ├── README.md └── src └── Route.elm /examples/app/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /elm.js -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | elm.js 3 | index.html -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /elm.js 3 | /tests/elm-stuff 4 | /tests/tests.js 5 | -------------------------------------------------------------------------------- /examples/app/posts.json: -------------------------------------------------------------------------------- 1 | [{"id": 1, "title": "First Post", "body": "This is the first post"}, 2 | {"id": 2, "title": "Second Post", "body": "This is the second post"}] 3 | -------------------------------------------------------------------------------- /examples/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Route Example 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/app/README.md: -------------------------------------------------------------------------------- 1 | # Example app 2 | 3 | This is a simple example demonstrating client-side routing using the 4 | `elm-route` parser. To run it locally do the following after cloning 5 | the repository: 6 | 7 | ``` shell 8 | $ elm make src/Main.elm --output=elm.js 9 | $ python3 server.py 10 | ``` 11 | 12 | Visit http://localhost:8000 in your browser. 13 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "summary": "A route parsing library", 4 | "repository": "https://github.com/elm-community/elm-route.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "examples", 8 | "src" 9 | ], 10 | "exposed-modules": [ 11 | "Route" 12 | ], 13 | "dependencies": { 14 | "Bogdanp/elm-combine": "3.0.0 <= v < 4.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0" 16 | }, 17 | "elm-version": "0.18.0 <= v < 0.19.0" 18 | } 19 | -------------------------------------------------------------------------------- /tests/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../src" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "Bogdanp/elm-combine": "3.0.0 <= v < 4.0.0", 13 | "elm-community/elm-test": "4.0.0 <= v < 5.0.0", 14 | "elm-lang/core": "5.0.0 <= v < 6.0.0" 15 | }, 16 | "elm-version": "0.18.0 <= v < 0.19.0" 17 | } 18 | -------------------------------------------------------------------------------- /examples/app/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "elm-route example app", 4 | "repository": "https://github.com/Bogdanp/elm-route.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "Bogdanp/elm-route": "4.0.0 <= v < 5.0.0", 12 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 13 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 14 | "elm-lang/navigation": "2.0.0 <= v < 3.0.0", 15 | "elm-lang/http": "1.0.0 <= v < 2.0.0" 16 | }, 17 | "elm-version": "0.18.0 <= v < 0.19.0" 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # elm-route changelog 2 | 3 | ## elm-route 4.0.0 (TBD) 4 | 5 | ### Changes 6 | 7 | * Upgraded to Elm 0.18 8 | * `Route.QueryString` has been extracted into its own package: `Bogdanp/elm-querystring` 9 | * `map` has been removed 10 | 11 | ## elm-route 2.0.1 (2016-05-11) 12 | 13 | ### Changes 14 | 15 | * Upgraded to Elm 0.17 16 | 17 | ## elm-route 2.0.0 (2016-03-20) 18 | 19 | ### Changes 20 | 21 | * Merged `Route` and `RouteComponent` types to improve composability of `Route`s 22 | 23 | ### Upgrading 24 | 25 | * Replace all instances of `RouteComponent` with `Route` in your code 26 | 27 | ## elm-route 1.1.0 (2016-02-28) 28 | 29 | ### Additions 30 | 31 | * Added `Route.QueryString` module 32 | -------------------------------------------------------------------------------- /examples/app/src/Data.elm: -------------------------------------------------------------------------------- 1 | module Data exposing (Post, fetchPosts, lookupPost) 2 | 3 | import Http 4 | import Json.Decode as JD exposing (Decoder, int, string) 5 | import Task exposing (Task) 6 | 7 | 8 | type alias Post = 9 | { id : Int 10 | , title : String 11 | , body : String 12 | } 13 | 14 | 15 | lookupPost : Int -> List Post -> Maybe Post 16 | lookupPost id posts = 17 | List.filter (\p -> p.id == id) posts 18 | |> List.head 19 | 20 | 21 | posts : Decoder (List Post) 22 | posts = 23 | let 24 | post = 25 | JD.map3 Post 26 | (JD.field "id" int) 27 | (JD.field "title" string) 28 | (JD.field "body" string) 29 | in 30 | JD.list post 31 | 32 | 33 | fetchPosts : Task Http.Error (List Post) 34 | fetchPosts = 35 | Http.get "/api/posts" posts 36 | |> Http.toTask 37 | -------------------------------------------------------------------------------- /examples/app/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from http.server import HTTPServer, BaseHTTPRequestHandler 3 | from time import sleep 4 | 5 | 6 | class Handler(BaseHTTPRequestHandler): 7 | def serve_file(self, filename, content_type="text/html"): 8 | self.send_response(200) 9 | self.send_header("Content-type", content_type) 10 | self.end_headers() 11 | 12 | with open(filename, "rb") as f: 13 | self.wfile.write(f.read()) 14 | 15 | def do_GET(self): 16 | if self.path == "/api/posts": 17 | sleep(1) 18 | return self.serve_file("posts.json", "application/json") 19 | if self.path == "/elm.js": 20 | return self.serve_file("elm.js", "application/javascript") 21 | return self.serve_file("index.html") 22 | 23 | print("Listening on port 8000...") 24 | httpd = HTTPServer(("", 8000), Handler) 25 | httpd.serve_forever() 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | 5 | 6 | cache: 7 | directories: 8 | - elm-stuff/build-artifacts 9 | - elm-stuff/packages 10 | - sysconfcpus 11 | os: 12 | - linux 13 | 14 | 15 | before_install: 16 | - echo -e "Host github.com\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config 17 | 18 | install: 19 | - npm install -g elm elm-test 20 | - | 21 | if [ ! -d sysconfcpus/bin ]; 22 | then 23 | git clone https://github.com/obmarg/libsysconfcpus.git; 24 | cd libsysconfcpus; 25 | ./configure --prefix=$TRAVIS_BUILD_DIR/sysconfcpus; 26 | make && make install; 27 | cd ..; 28 | fi 29 | 30 | 31 | before_script: 32 | - $TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make --yes src/Route.elm 33 | - cd tests && $TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make --yes Tests.elm && cd .. 34 | 35 | script: 36 | - $TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-test 37 | -------------------------------------------------------------------------------- /examples/app/src/Routes.elm: -------------------------------------------------------------------------------- 1 | module Routes exposing (Sitemap(..), parsePath, navigateTo, toString) 2 | 3 | import Navigation exposing (Location) 4 | import Route exposing (..) 5 | 6 | 7 | type Sitemap 8 | = HomeR 9 | | PostsR 10 | | PostR Int 11 | | AboutR 12 | | NotFoundR 13 | 14 | 15 | homeR = 16 | HomeR := static "" 17 | 18 | 19 | postsR = 20 | PostsR := static "posts" 21 | 22 | 23 | postR = 24 | PostR := static "posts" int 25 | 26 | 27 | aboutR = 28 | AboutR := static "about" 29 | 30 | 31 | sitemap = 32 | router [ homeR, postsR, postR, aboutR ] 33 | 34 | 35 | match : String -> Sitemap 36 | match = 37 | Route.match sitemap 38 | >> Maybe.withDefault NotFoundR 39 | 40 | 41 | toString : Sitemap -> String 42 | toString r = 43 | case r of 44 | HomeR -> 45 | reverse homeR [] 46 | 47 | PostsR -> 48 | reverse postsR [] 49 | 50 | PostR id -> 51 | reverse postR [ Basics.toString id ] 52 | 53 | AboutR -> 54 | reverse aboutR [] 55 | 56 | NotFoundR -> 57 | Debug.crash "cannot render NotFound" 58 | 59 | 60 | parsePath : Location -> Sitemap 61 | parsePath = 62 | .pathname >> match 63 | 64 | 65 | navigateTo : Sitemap -> Cmd msg 66 | navigateTo = 67 | toString >> Navigation.newUrl 68 | -------------------------------------------------------------------------------- /examples/Custom.elm: -------------------------------------------------------------------------------- 1 | module Custom exposing (Category(..), Sitemap(..), match, route) 2 | 3 | {-| This module demonstrates how you can use custom parsers to parse 4 | paths. This example parses routes like `/categories/snippet` and 5 | `/categories/post/5` into `CategoryR Snippet` and `CategoryR (Post 5)`, 6 | respectively. 7 | -} 8 | 9 | import Combine exposing (..) 10 | import Combine.Num 11 | import Route exposing (..) 12 | 13 | 14 | type Category 15 | = Snippet 16 | | Post Int 17 | 18 | 19 | category : Parser s Category 20 | category = 21 | choice 22 | [ Snippet <$ Combine.string "snippet" 23 | , Post <$> (Combine.string "post/" *> Combine.Num.int) 24 | ] 25 | 26 | 27 | show : Category -> String 28 | show c = 29 | case c of 30 | Snippet -> 31 | "snippet" 32 | 33 | Post id -> 34 | "post/" ++ Basics.toString id 35 | 36 | 37 | type Sitemap 38 | = CategoryR Category 39 | 40 | 41 | categoryR = 42 | CategoryR := static "categories" custom category 43 | 44 | 45 | sitemap : Router Sitemap 46 | sitemap = 47 | router [ categoryR ] 48 | 49 | 50 | match : String -> Maybe Sitemap 51 | match = 52 | Route.match sitemap 53 | 54 | 55 | route : Sitemap -> String 56 | route r = 57 | case r of 58 | CategoryR c -> 59 | reverse categoryR [ show c ] 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Bogdan Paul Popa 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-route [![Build Status](https://travis-ci.org/elm-community/elm-route.svg)](https://travis-ci.org/elm-community/elm-route) 2 | 3 | ``` shell 4 | elm package install elm-community/elm-route 5 | ``` 6 | 7 | This library defines functions for constructing route parsers. See 8 | the documentation of the [Route][route] module for more information. 9 | A full example is available at `examples/app`. 10 | 11 | ## Usage 12 | 13 | First define your routes: 14 | 15 | ```elm 16 | module App.Routes exposing ( Route(..), match, route ) 17 | 18 | import Route exposing (..) 19 | 20 | type Route 21 | = Home 22 | | About 23 | | Users 24 | | User Int 25 | | UserPosts Int 26 | | UserPost Int String 27 | | NotFound 28 | 29 | home = Home := static "" 30 | about = About := static "about" 31 | users = Users := static "users" 32 | user = User := static "users" int 33 | userPosts = UserPosts := static "users" int static "posts" 34 | userPost = UserPost := static "users" int string 35 | routes = router [home, about, users, user, userPosts, userPost] 36 | 37 | match : String -> Maybe Route 38 | match = Route.match routes 39 | 40 | toString : Route -> String 41 | toString route = 42 | case route of 43 | Home -> reverse home [] 44 | About -> reverse about [] 45 | Users -> reverse users [] 46 | User id -> reverse user [toString id] 47 | UserPosts id -> reverse userPosts [toString id] 48 | UserPost uid pid -> reverse userPost [toString uid, pid] 49 | NotFound -> Debug.crash "cannot route to NotFound" 50 | ``` 51 | 52 | You may then use them to match routes: 53 | 54 | ```elm 55 | > import App.Routes as Routes exposing (Route(..), match) 56 | 57 | > match "/" 58 | Just Home : Maybe Route 59 | 60 | > match "/users" 61 | Just Users : Maybe Route 62 | 63 | > match "/i-dont-exist" 64 | Nothing : Maybe Route 65 | 66 | > match "/users/a" 67 | Nothing : Maybe Route 68 | 69 | > match "/users/1" 70 | Just (User 1) : Maybe Route 71 | 72 | > match "/users/1/hello-world" 73 | Just (UserPost 1 "hello-world") : Maybe Route 74 | ``` 75 | 76 | And to convert routes to strings: 77 | 78 | ```elm 79 | > import App.Routes as Routes 80 | 81 | > Routes.toString Home 82 | "/" : String 83 | 84 | > Routes.toString About 85 | "/about" : String 86 | 87 | > Routes.toString (UserPost 1 "hello") 88 | "/users/1/hello" : String 89 | ``` 90 | 91 | To use it with [Navigation][nav], define `match` in terms of `Location` 92 | 93 | ``` elm 94 | match : Location -> Route 95 | match location = 96 | location.pathname 97 | |> Routes.match routes 98 | |> Maybe.withDefault NotFound 99 | ``` 100 | 101 | then use it in your `Program`: 102 | 103 | ``` elm 104 | import App.Routes as Routes 105 | import Navigation exposing (Location) 106 | 107 | type Msg 108 | = ChangeRoute Route 109 | 110 | parseRoute : Location -> Msg 111 | parseRoute = 112 | Routes.match >> ChangeRoute 113 | 114 | main : Program Never Model Msg 115 | main = 116 | Navigation.program parseRoute 117 | { init = init 118 | , update = update 119 | , view = view 120 | , subscriptions = subscriptions 121 | } 122 | ``` 123 | 124 | See the `examples` directory and `tests/Tests.elm` for more. 125 | 126 | 127 | [route]: http://package.elm-lang.org/packages/elm-community/elm-route/latest/Route 128 | [nav]: http://package.elm-lang.org/packages/elm-lang/navigation/latest 129 | -------------------------------------------------------------------------------- /examples/app/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Data exposing (Post) 4 | import Html as H exposing (Html) 5 | import Html.Attributes as A 6 | import Html.Events as E 7 | import Http 8 | import Json.Decode as JD 9 | import Navigation exposing (Location) 10 | import Routes exposing (Sitemap(..)) 11 | import Task 12 | 13 | 14 | -- Update 15 | -- ------ 16 | 17 | 18 | type alias Model = 19 | { route : Sitemap 20 | , ready : Bool 21 | , posts : List Post 22 | , post : Maybe Post 23 | , error : Maybe String 24 | } 25 | 26 | 27 | type Msg 28 | = RouteChanged Sitemap 29 | | RouteTo Sitemap 30 | | Fetch (Result Http.Error (List Post)) 31 | 32 | 33 | parseRoute : Location -> Msg 34 | parseRoute = 35 | Routes.parsePath >> RouteChanged 36 | 37 | 38 | init : Location -> ( Model, Cmd Msg ) 39 | init location = 40 | let 41 | route = 42 | Routes.parsePath location 43 | in 44 | handleRoute route 45 | { route = route 46 | , ready = False 47 | , posts = [] 48 | , post = Nothing 49 | , error = Nothing 50 | } 51 | 52 | 53 | update : Msg -> Model -> ( Model, Cmd Msg ) 54 | update msg model = 55 | case msg of 56 | RouteChanged route -> 57 | handleRoute route model 58 | 59 | RouteTo route -> 60 | model ! [ Routes.navigateTo route ] 61 | 62 | Fetch (Err error) -> 63 | { model | error = Just (toString error) } ! [] 64 | 65 | Fetch (Ok posts) -> 66 | handleRoute model.route 67 | { model 68 | | ready = True 69 | , error = Nothing 70 | , posts = posts 71 | } 72 | 73 | 74 | handleRoute : Sitemap -> Model -> ( Model, Cmd Msg ) 75 | handleRoute route ({ ready } as m) = 76 | let 77 | model = 78 | { m | route = route } 79 | in 80 | case route of 81 | PostsR -> 82 | if ready then 83 | model ! [] 84 | else 85 | model ! [ fetchPosts ] 86 | 87 | PostR id -> 88 | if ready then 89 | { model | post = Data.lookupPost id model.posts } ! [] 90 | else 91 | model ! [ fetchPosts ] 92 | 93 | _ -> 94 | model ! [] 95 | 96 | 97 | fetchPosts : Cmd Msg 98 | fetchPosts = 99 | Task.attempt Fetch Data.fetchPosts 100 | 101 | 102 | subscriptions : Model -> Sub Msg 103 | subscriptions model = 104 | Sub.none 105 | 106 | 107 | 108 | -- View 109 | -- ---- 110 | 111 | 112 | notFound : Html Msg 113 | notFound = 114 | H.h1 [] [ H.text "Page not found" ] 115 | 116 | 117 | home : Html Msg 118 | home = 119 | H.div [] 120 | [ H.h1 [] [ H.text "Home Page" ] 121 | , H.p [] [ link (PostR 123) "This post does not exist" ] 122 | ] 123 | 124 | 125 | about : Html Msg 126 | about = 127 | H.h1 [] [ H.text "About" ] 128 | 129 | 130 | loading : Html Msg 131 | loading = 132 | H.h1 [] [ H.text "Loading..." ] 133 | 134 | 135 | post : Data.Post -> Html Msg 136 | post post = 137 | H.div [] 138 | [ H.h1 [] [ H.text post.title ] 139 | , H.p [] [ H.text post.body ] 140 | ] 141 | 142 | 143 | posts : List Data.Post -> Html Msg 144 | posts posts = 145 | let 146 | postLink post = 147 | H.li [] [ link (PostR post.id) post.title ] 148 | in 149 | H.div [] 150 | [ H.h1 [] [ H.text "Posts" ] 151 | , H.ul [] (List.map postLink posts) 152 | ] 153 | 154 | 155 | view : Model -> Html Msg 156 | view model = 157 | H.div [] 158 | [ nav 159 | , content model 160 | ] 161 | 162 | 163 | nav : Html Msg 164 | nav = 165 | H.ul [] 166 | [ H.li [] [ link HomeR "Home" ] 167 | , H.li [] [ link PostsR "Posts" ] 168 | , H.li [] [ link AboutR "About" ] 169 | ] 170 | 171 | 172 | content : Model -> Html Msg 173 | content ({ route } as model) = 174 | case model.route of 175 | HomeR -> 176 | home 177 | 178 | PostsR -> 179 | if model.ready then 180 | posts model.posts 181 | else 182 | loading 183 | 184 | PostR id -> 185 | case ( model.ready, model.post ) of 186 | ( False, _ ) -> 187 | loading 188 | 189 | ( True, Nothing ) -> 190 | notFound 191 | 192 | ( True, Just p ) -> 193 | post p 194 | 195 | AboutR -> 196 | about 197 | 198 | NotFoundR -> 199 | notFound 200 | 201 | 202 | link : Sitemap -> String -> Html Msg 203 | link route label = 204 | let 205 | opts = 206 | { preventDefault = True, stopPropagation = True } 207 | in 208 | H.a 209 | [ A.href (Routes.toString route) 210 | , E.onWithOptions "click" opts (JD.succeed <| RouteTo route) 211 | ] 212 | [ H.text label ] 213 | 214 | 215 | 216 | -- Main 217 | -- ---- 218 | 219 | 220 | main : Program Never Model Msg 221 | main = 222 | Navigation.program parseRoute 223 | { init = init 224 | , update = update 225 | , view = view 226 | , subscriptions = subscriptions 227 | } 228 | -------------------------------------------------------------------------------- /tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Tests exposing (..) 2 | 3 | import Combine exposing ((<$)) 4 | import Expect 5 | import Fuzz exposing (Fuzzer, tuple, tuple3) 6 | import Route exposing (..) 7 | import String 8 | import Test exposing (..) 9 | 10 | 11 | type Sitemap 12 | = Home 13 | | Users 14 | | User Int 15 | | UserEmails Int 16 | | UserEmail Int Int 17 | | Deep Int Int Int 18 | | CustomR Foo 19 | 20 | 21 | type Foo 22 | = Foo 23 | | Bar 24 | 25 | 26 | fooP = 27 | Combine.choice 28 | [ Foo <$ Combine.string "Foo" 29 | , Bar <$ Combine.string "Bar" 30 | ] 31 | 32 | 33 | homeR : Route Sitemap 34 | homeR = 35 | Home := static "" 36 | 37 | 38 | usersR : Route Sitemap 39 | usersR = 40 | Users := static "users" 41 | 42 | 43 | userR : Route Sitemap 44 | userR = 45 | User := static "users" int 46 | 47 | 48 | userEmailsR : Route Sitemap 49 | userEmailsR = 50 | UserEmails := static "users" int static "emails" 51 | 52 | 53 | userEmailR : Route Sitemap 54 | userEmailR = 55 | UserEmail := static "users" int static "emails" int 56 | 57 | 58 | deepR : Route Sitemap 59 | deepR = 60 | Deep := static "deep" int int int 61 | 62 | 63 | customR : Route Sitemap 64 | customR = 65 | CustomR := static "custom" custom fooP 66 | 67 | 68 | routes : Router Sitemap 69 | routes = 70 | router 71 | [ homeR 72 | , usersR 73 | , userR 74 | , userEmailsR 75 | , userEmailR 76 | , deepR 77 | , customR 78 | ] 79 | 80 | 81 | render : Sitemap -> String 82 | render r = 83 | case r of 84 | Home -> 85 | reverse homeR [] 86 | 87 | Users -> 88 | reverse usersR [] 89 | 90 | User id -> 91 | reverse userR [ toString id ] 92 | 93 | UserEmails id -> 94 | reverse userEmailsR [ toString id ] 95 | 96 | UserEmail uid eid -> 97 | reverse userEmailR [ toString uid, toString eid ] 98 | 99 | Deep x y z -> 100 | reverse deepR [ toString x, toString y, toString z ] 101 | 102 | CustomR x -> 103 | reverse customR [ toString x ] 104 | 105 | 106 | ints1 : Fuzzer Int 107 | ints1 = 108 | Fuzz.int 109 | 110 | 111 | ints2 : Fuzzer ( Int, Int ) 112 | ints2 = 113 | tuple ( Fuzz.int, Fuzz.int ) 114 | 115 | 116 | ints3 : Fuzzer ( Int, Int, Int ) 117 | ints3 = 118 | tuple3 ( Fuzz.int, Fuzz.int, Fuzz.int ) 119 | 120 | 121 | matching : Test 122 | matching = 123 | let 124 | equal x path _ = 125 | Expect.equal x (match routes path) 126 | 127 | matches = 128 | Just >> equal 129 | 130 | matches_ x path = 131 | matches x path () 132 | 133 | fails = 134 | equal Nothing 135 | in 136 | describe "match" 137 | [ test 138 | "fails to match on parse failure" 139 | (fails "/users/a") 140 | , test 141 | "fails to match on custom parse failure" 142 | (fails "/custom/abc") 143 | , test 144 | "fails to match nonexistent paths" 145 | (fails "/i-dont-exist") 146 | , test 147 | "matches the root path" 148 | (matches Home "/") 149 | , test 150 | "matches static paths" 151 | (matches Users "/users") 152 | , test 153 | "matches custom ADT routes - Foo constructor" 154 | (matches (CustomR Foo) "/custom/Foo") 155 | , test 156 | "matches custom ADT routes - Bar constructor" 157 | (matches (CustomR Bar) "/custom/Bar") 158 | , fuzz ints1 159 | "matches one dynamic segment" 160 | (\x -> matches_ (User x) ("/users/" ++ toString x)) 161 | , fuzz ints1 162 | "matches one suffixed dynamic segment" 163 | (\x -> matches_ (UserEmails x) ("/users/" ++ toString x ++ "/emails")) 164 | , fuzz ints2 165 | "matches two dynamic segments around a static segment" 166 | (\( x, y ) -> matches_ (UserEmail x y) ("/users/" ++ toString x ++ "/emails/" ++ toString y)) 167 | , fuzz ints3 168 | "matches many dynamic segments" 169 | (\( x, y, z ) -> matches_ (Deep x y z) ("/deep/" ++ String.join "/" (List.map toString [ x, y, z ]))) 170 | ] 171 | 172 | 173 | reversing : Test 174 | reversing = 175 | let 176 | compare parts route params _ = 177 | Expect.equal 178 | ("/" ++ String.join "/" parts) 179 | (reverse route params) 180 | in 181 | describe "reverse" 182 | [ test 183 | "reverses the root route" 184 | (compare [] homeR []) 185 | , test 186 | "reverses custom routes" 187 | (\x -> compare [ "custom", "Foo" ] customR [ "Foo" ] ()) 188 | , fuzz ints1 189 | "reverses dynamic routes" 190 | (\x -> compare [ "users", toString x ] userR [ toString x ] ()) 191 | ] 192 | 193 | 194 | rendering : Test 195 | rendering = 196 | let 197 | compare parts route _ = 198 | Expect.equal 199 | ("/" ++ String.join "/" parts) 200 | (render route) 201 | in 202 | describe "render" 203 | [ test 204 | "renders the root route" 205 | (compare [] Home) 206 | , test 207 | "renders static routes" 208 | (compare [ "users" ] Users) 209 | , test 210 | "renders custom parser routes" 211 | (compare [ "custom", "Foo" ] (CustomR Foo)) 212 | , fuzz ints1 213 | "renders dynamic routes" 214 | (\x -> compare [ "users", toString x ] (User x) ()) 215 | , fuzz ints3 216 | "renders deep dynamic routes" 217 | (\( x, y, z ) -> compare [ "deep", toString x, toString y, toString z ] (Deep x y z) ()) 218 | ] 219 | 220 | 221 | all : Test 222 | all = 223 | describe "elm-route" 224 | [ matching 225 | , reversing 226 | , rendering 227 | ] 228 | -------------------------------------------------------------------------------- /src/Route.elm: -------------------------------------------------------------------------------- 1 | module Route 2 | exposing 3 | ( Router 4 | , Route 5 | , route 6 | , (:=) 7 | , router 8 | , match 9 | , reverse 10 | , static 11 | , custom 12 | , string 13 | , int 14 | , and 15 | , () 16 | ) 17 | 18 | {-| This module exposes combinators for creating route parsers. 19 | 20 | @docs Route, Router 21 | 22 | ## Routing 23 | @docs route, (:=), router, match, reverse 24 | 25 | ## Route combinators 26 | @docs static, custom, string, int, and, () 27 | -} 28 | 29 | import Combine exposing (..) 30 | import Combine.Num 31 | import String 32 | 33 | 34 | type Component 35 | = CStatic String 36 | | CCustom (String -> Result String ()) 37 | | CString 38 | | CInt 39 | 40 | 41 | {-| Routes represent concrete parsers for paths. Routes can be combined 42 | and they keep track of their path components in order to provide 43 | automatic reverse routing. 44 | -} 45 | type Route a 46 | = Route 47 | { parser : Parser () a 48 | , components : List Component 49 | } 50 | 51 | 52 | {-| A Router is, at its core, a List of Routes. 53 | 54 | sitemap : Router a 55 | sitemap = router [routeA, routeB] 56 | 57 | -} 58 | type Router a 59 | = Router (Parser () a) 60 | 61 | 62 | {-| Declare a Route. 63 | 64 | type Sitemap 65 | = HomeR 66 | 67 | homeR : Route Sitemap 68 | homeR = route HomeR (static "") 69 | 70 | -} 71 | route : a -> Route (a -> b) -> Route b 72 | route x (Route r) = 73 | Route 74 | { parser = r.parser >>= (\k -> Combine.succeed <| k x) 75 | , components = r.components 76 | } 77 | 78 | 79 | {-| A synonym for `route`. 80 | 81 | type Sitemap 82 | = HomeR 83 | 84 | homeR : Route Sitemap 85 | homeR = HomeR := static "" 86 | 87 | -} 88 | (:=) : a -> Route (a -> b) -> Route b 89 | (:=) = 90 | route 91 | infixl 7 := 92 | 93 | 94 | {-| Construct a Router from a list of Routes. 95 | 96 | type Sitemap 97 | = HomeR 98 | | BlogR 99 | 100 | homeR = HomeR := static "" 101 | blogR = BlogR := static "blog" 102 | sitemap = router [homeR, blogR] 103 | 104 | -} 105 | router : List (Route a) -> Router a 106 | router rs = 107 | List.map (\(Route r) -> r.parser <* Combine.end) rs 108 | |> Combine.choice 109 | |> Router 110 | 111 | 112 | {-| Create a Route that matches a static String. 113 | 114 | type Sitemap 115 | = BlogR 116 | 117 | blogR = BlogR := static "blog" 118 | sitemap = router [blogR] 119 | 120 | > match sitemap "/blog" 121 | Just BlogR : Maybe Sitemap 122 | 123 | -} 124 | static : String -> Route (a -> a) 125 | static s = 126 | Route 127 | { parser = identity <$ Combine.string s 128 | , components = [ CStatic s ] 129 | } 130 | 131 | 132 | {-| Create a Route with a custom Parser. 133 | 134 | import Combine exposing (..) 135 | import Combine.Infix exposing (..) 136 | 137 | type Category 138 | = Snippet 139 | | Post Int 140 | 141 | type Sitemap 142 | = CategoryR Category 143 | 144 | categoryR = CategoryR := static "categories" custom categoryParser 145 | sitemap = router [categoryR] 146 | 147 | > match sitemap "/categories/a" 148 | Nothing : Maybe Sitemap 149 | 150 | > match sitemap "/categories/Post/5" 151 | Just (CategoryR (Post 5)) : Maybe Sitemap 152 | 153 | > match sitemap "/categories/Snippet" 154 | Just (CategoryR Snippet) : Maybe Sitemap 155 | 156 | See `examples/Custom.elm` for a complete example. 157 | 158 | -} 159 | custom : Parser () a -> Route ((a -> b) -> b) 160 | custom p = 161 | let 162 | validator s = 163 | case Combine.parse p s of 164 | Ok _ -> 165 | Ok () 166 | 167 | Err ( _, _, ms ) -> 168 | Err (String.join " or " ms) 169 | in 170 | Route 171 | { parser = (|>) <$> p 172 | , components = [ CCustom validator ] 173 | } 174 | 175 | 176 | {-| A Route that matches any string. 177 | 178 | type Sitemap 179 | = PostR String 180 | 181 | postR = PostR := static "posts" string 182 | sitemap = router [postR] 183 | 184 | > match sitemap "/posts/" 185 | Nothing : Maybe Sitemap 186 | 187 | > match sitemap "/posts/hello-world/test" 188 | Nothing : Maybe Sitemap 189 | 190 | > match sitemap "/posts/hello-world" 191 | Just (PostR "hello-world") : Maybe Sitemap 192 | 193 | -} 194 | string : Route ((String -> a) -> a) 195 | string = 196 | Route 197 | { parser = (|>) <$> Combine.regex "[^/]+" 198 | , components = [ CString ] 199 | } 200 | 201 | 202 | {-| A Route that matches any integer. 203 | 204 | type Sitemap 205 | = UserR Int 206 | 207 | userR = UserR := static "users" int 208 | sitemap = router [userR] 209 | 210 | > match sitemap "/users/a" 211 | Nothing : Maybe Sitemap 212 | 213 | > match sitemap "/users/1" 214 | Just (UserR 1) : Maybe Sitemap 215 | 216 | > match sitemap "/users/-1" 217 | Just (UserR -1) : Maybe Sitemap 218 | 219 | -} 220 | int : Route ((Int -> a) -> a) 221 | int = 222 | Route 223 | { parser = (|>) <$> Combine.Num.int 224 | , components = [ CInt ] 225 | } 226 | 227 | 228 | {-| Compose two Routes. 229 | 230 | type Sitemap 231 | = AddR Int Int 232 | 233 | addR = AddR := int `and` int 234 | sitemap = router [addR] 235 | 236 | > match sitemap "/1/2" 237 | Just (AddR 1 2) : Maybe Sitemap 238 | 239 | -} 240 | and : Route (a -> b) -> Route (b -> c) -> Route (a -> c) 241 | and (Route l) (Route r) = 242 | Route 243 | { parser = (>>) <$> l.parser <*> (Combine.string "/" *> r.parser) 244 | , components = l.components ++ r.components 245 | } 246 | 247 | 248 | {-| A synonym for `and`. 249 | 250 | type Sitemap 251 | = AddR Int Int 252 | 253 | addR = AddR := int int 254 | sitemap = router [addR] 255 | 256 | > match sitemap "/1/2" 257 | Just (AddR 1 2) : Maybe Sitemap 258 | 259 | -} 260 | () : Route (a -> b) -> Route (b -> c) -> Route (a -> c) 261 | () = 262 | and 263 | infixl 8 264 | 265 | 266 | {-| Given a Router and an arbitrary String representing a path, this 267 | function will return the first Route that matches that path. 268 | 269 | type Sitemap 270 | = HomeR 271 | | UsersR 272 | | UserR Int 273 | 274 | homeR = HomeR := static "" 275 | usersR = UsersR := static "users" 276 | usersR = UserR := static "users" int 277 | sitemap = router [homeR, userR, usersR] 278 | 279 | > match sitemap "/a" 280 | Nothing : Maybe Sitemap 281 | 282 | > match sitemap "/" 283 | Just HomeR : Maybe Sitemap 284 | 285 | > match sitemap "/users" 286 | Just UsersR : Maybe Sitemap 287 | 288 | > match sitemap "/users/1" 289 | Just (UserR 1) : Maybe Sitemap 290 | 291 | > match sitemap "/users/1" 292 | Just (UserR 1) : Maybe Sitemap 293 | 294 | -} 295 | match : Router a -> String -> Maybe a 296 | match (Router r) path = 297 | case String.uncons path of 298 | Just ( '/', path ) -> 299 | Combine.parse r path 300 | |> Result.toMaybe 301 | |> Maybe.map (\( _, _, x ) -> x) 302 | 303 | _ -> 304 | Nothing 305 | 306 | 307 | {-| Render a path given a Route and a list of route components. 308 | 309 | type Sitemap 310 | = HomeR 311 | | UsersR 312 | | UserR Int 313 | 314 | homeR = HomeR := static "" 315 | usersR = UsersR := static "users" 316 | usersR = UserR := static "users" int 317 | sitemap = router [homeR, userR, usersR] 318 | 319 | > reverse homeR [] 320 | "/" 321 | 322 | > reverse usersR [] 323 | "/users" 324 | 325 | > reverse userR ["1"] 326 | "/users/1" 327 | 328 | If you are willing to write some boilerplate, `reverse` can be used to 329 | construct a type safe reverse routing function specific to your 330 | application: 331 | 332 | toString : Sitemap -> String 333 | toString r = 334 | case r of 335 | HomeR -> reverse homeR [] 336 | UsersR -> reverse usersR [] 337 | UserR uid -> reverse userR [toString uid] 338 | 339 | > toString HomeR 340 | "/" 341 | 342 | > toString UsersR 343 | "/users" 344 | 345 | > toString (UserR 1) 346 | "/users/1" 347 | 348 | `reverse` will crash at runtime if there is a mismatch between the 349 | route and the list of arguments that is passed in. For example: 350 | 351 | > reverse deepR [] 352 | Error: Ran into a `Debug.crash` in module `Route` 353 | 354 | This was caused by the `case` expression between lines 145 and 175. 355 | One of the branches ended with a crash and the following value got through: 356 | 357 | ([],[CInt,CInt,CInt]) 358 | 359 | The message provided by the code author is: 360 | 361 | 'reverse' called with an unexpected number of arguments 362 | 363 | > reverse deepR ["a"] 364 | Error: Ran into a `Debug.crash` in module `Route` 365 | 366 | This was caused by the `case` expression between lines 171 and 176. 367 | One of the branches ended with a crash and the following value got through: 368 | 369 | Err ("could not convert string 'a' to an Int") 370 | 371 | The message provided by the code author is: 372 | 373 | could not convert string 'a' to an Int in a call to 'reverse' 374 | 375 | -} 376 | reverse : Route a -> List String -> String 377 | reverse (Route r) inputs = 378 | let 379 | accumulate cs is xs = 380 | case ( is, xs ) of 381 | ( [], [] ) -> 382 | "/" ++ (String.join "/" (List.reverse cs)) 383 | 384 | ( _, (CStatic c) :: xs ) -> 385 | accumulate (c :: cs) is xs 386 | 387 | ( i :: is, (CCustom p) :: xs ) -> 388 | case p i of 389 | Ok _ -> 390 | accumulate (i :: cs) is xs 391 | 392 | Err m -> 393 | Debug.crash (m ++ " in a call to 'reverse' but received '" ++ i ++ "'") 394 | 395 | ( i :: is, CString :: xs ) -> 396 | accumulate (i :: cs) is xs 397 | 398 | ( i :: is, CInt :: xs ) -> 399 | case String.toInt i of 400 | Ok _ -> 401 | accumulate (i :: cs) is xs 402 | 403 | Err m -> 404 | Debug.crash m ++ " in a call to 'reverse'" 405 | 406 | _ -> 407 | Debug.crash "'reverse' called with an unexpected number of arguments" 408 | in 409 | accumulate [] inputs r.components 410 | --------------------------------------------------------------------------------