Repository: sporto/hop Branch: master Commit: 3113fe73d78e Files: 66 Total size: 77.8 KB Directory structure: gitextract_i1usn_9t/ ├── .editorconfig ├── .gitignore ├── .vscode/ │ └── settings.json ├── Makefile ├── assets/ │ └── logo.idraw ├── docs/ │ ├── building-routes.md │ ├── changelog.md │ ├── matching-routes.md │ ├── navigating.md │ ├── nesting-routes.md │ ├── reverse-routing.md │ ├── testing.md │ ├── upgrade-2-to-3.md │ ├── upgrade-3-to-4.md │ ├── upgrade-4-to-5.md │ └── upgrade-5-to-6.md ├── elm-package.json ├── examples/ │ ├── basic/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Main.elm │ │ ├── elm-package.json │ │ ├── install-packages.sh │ │ └── readme.md │ └── full/ │ ├── .gitignore │ ├── dev_server.js │ ├── elm-package.json │ ├── install-packages.sh │ ├── package.json │ ├── readme.md │ ├── src/ │ │ ├── Languages/ │ │ │ ├── Edit.elm │ │ │ ├── Filter.elm │ │ │ ├── List.elm │ │ │ ├── Messages.elm │ │ │ ├── Models.elm │ │ │ ├── Routing.elm │ │ │ ├── Show.elm │ │ │ ├── Update.elm │ │ │ └── View.elm │ │ ├── Main.elm │ │ ├── Messages.elm │ │ ├── Models.elm │ │ ├── Routing.elm │ │ ├── Update.elm │ │ ├── View.elm │ │ └── index.js │ └── webpack.config.js ├── license.md ├── package.json ├── readme.md ├── src/ │ ├── Hop/ │ │ ├── Address.elm │ │ ├── AddressTest.elm │ │ ├── In.elm │ │ ├── InTest.elm │ │ ├── Out.elm │ │ ├── OutTest.elm │ │ ├── TestHelper.elm │ │ ├── Types.elm │ │ └── Utils.elm │ ├── Hop.elm │ └── HopTest.elm └── tests/ ├── IntegrationTest.elm ├── Main.elm ├── Tests.elm ├── elm-package.json ├── install-packages.sh └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 [*.js] indent_style = tab indent_size = 2 [*.elm] indent_style = space indent_size = 4 [Makefile] indent_style = tab indent_size = 2 ================================================ FILE: .gitignore ================================================ .DS_Store elm-stuff node_modules elm.js documentation.json .vscode/tasks.json npm-debug.log .envrc tests.js ================================================ FILE: .vscode/settings.json ================================================ // Place your settings in this file to overwrite default and user settings. { "search.exclude": { "**/node_modules": true, "**/bower_components": true, "**/*.elmo": true, "documentation.json": true } } ================================================ FILE: Makefile ================================================ # Start basic application example locally basic-up: cd ./examples/basic && elm reactor full-up: cd ./examples/full && npm run dev # Generate documentation for preview docs: elm make --docs=documentation.json # Run unit tests locally test-unit: npm test test-unit-ci: npm install -g elm npm install -g elm-test elm-package install -y cd tests && elm-package install -y && cd .. elm-test build-basic: cd examples/basic/ && elm make --yes Main.elm build-full: cd examples/full/ && elm make --yes src/Main.elm test-ci: make test-unit-ci make build-basic make build-full .PHONY: docs test ================================================ FILE: docs/building-routes.md ================================================ # Building routes As of version 6 Hop doesn't provide matchers anymore, instead you can use [__UrlParser__](http://package.elm-lang.org/packages/evancz/url-parser). You build your routes by using union types: ```elm type Route = HomeRoute | UserRoute Int | UserStatusRoute Int | NotFoundRoute ``` Then you need to create matchers for these routes: ```elm import UrlParser exposing ((), format, oneOf, int, s) matchers = oneOf [ UrlParser.format HomeRoute (s "") , UrlParser.format UserRoute (s "users" int) , UrlParser.format UserStatusRoute (s "users" int s "status") ] ``` These matchers will match: - "/" - "users/1" - "users/1/status" ## Order matters The order of the matchers makes a big difference. See these examples. Given you have some routes and matchers: ```elm import UrlParser exposing (format, s, parse, int, oneOf, ()) type Route = UserRoute Int | UserEditRoute Int -- match 'users/1' userMatcher = format UserRoute (s "users" int) -- match '/uses/1/edit' userEditMatcher = format UserEditRoute (s "users" int s "edit") ``` ### Incorrect order ```elm matchers = oneOf [userMatcher, userEditMatcher] parse identity matchers "users/1" == Ok (UserRoute 1) : Result.Result String Repl.Route parse identity matchers "users/1/edit" == Err "The parser worked, but /edit was left over." ``` The `userEditMatcher` doesn't even run in this case. The `userMatcher` fails and stops the flow. ## Correct order ```elm matchers = oneOf [userEditMatcher, userMatcher] parse identity matchers "users/1" == Ok (UserRoute 1) : Result.Result String Repl.Route parse identity matchers "users/1/edit" == Ok (UserEditRoute 1) : Result.Result String Repl.Route ``` This works as expected, so is important to put the more specific matchers first. ================================================ FILE: docs/changelog.md ================================================ # Changelog ### 6.0.0 - Remove matchers (Use UrlParser instead) - Rename input and output functions - Encode and decode path segments ### 5.0.1 - Encode query string when converting location to URL. ## 5.0.0 - Update for Elm 0.17 ### 4.0.3 - Fix issue where root path wouldn't match when using hash routing. ### 4.0.2 - Futher fix for navigating to root path, use / instead of . ### 4.0.1 - Fix https://github.com/sporto/hop/issues/20 ## 4.0.0 - Support setState (No hashes) - Most functions take a config record ## 3.0.0 - Typed values in routes - Nested routes - Reverse routing ### 2.1.1 - Remove unnecessary dependency to `elm-test` ### 2.1.0 - Expose `Query` and `Url` types ## 2.0.0 - Remove dependency on `Erl`. - Change order of arguments on `addQuery`, `clearQuery`, `removeQuery` and `setQuery` ### 1.2.1 - Url is normalized before navigation i.e append `#/` if necessary ### 1.2.0 - Added `addQuery`, changed behaviour of `setQuery`. ### 1.1.1 - Fixed issue where query string won't be set when no hash wash present ================================================ FILE: docs/matching-routes.md ================================================ # Matching routes Create a parser using `Navigation.makeParser` combined with `Hop.makeResolver`. There are serveral strategies you can use. ## Given you have some configuration ``` routes = oneOf [...] hopConfig = { ... } ``` ## A parser that returns `(Route, Address)` ``` urlParserRouteAddress : Navigation.Parser ( MainRoute, Address ) urlParserRouteAddress = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver) ``` This parser: - Takes the `.href` from the `Location` record given by `Navigation`. - Converts that to a normalised path (done inside `makeResolver`). - Passes the normalised path to your `parse` function, which returns a matched route or `NotFoundRoute`. - When run returns a tuple `(Route, Address)`. ## A parser that returns only the matched route ``` urlParserOnlyRoute : Navigation.Parser MainRoute urlParserOnlyRoute = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver >> fst) ``` This parser only returns the matched route. The `address` record is discarded. However you probably need the address record for doing things with the query later. ## A parser that returns the parser result + Address ``` urlParserResultAddress : Navigation.Parser (Result String MainRoute, Address) urlParserResultAddress = let parse path = path |> UrlParser.parse identity routes solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver) ``` This parser returns the result from `parse` e.g. `Result String MainRoute` and the address record. ================================================ FILE: docs/navigating.md ================================================ # Navigating ## Changing the location Use `Hop.outputFromPath` for changing the browser location. Add a message: ```elm type Msg ... | NavigateTo String ``` Trigger this message from you view: ```elm button [ onClick (NavigateTo "/users") ] [ text "Users" ] ``` React to this message in update: ```elm NavigateTo path -> let command = Hop.outputFromPath routerConfig path |> Navigation.newUrl in ( model, command ) ``` ## Changing the query string Add actions for changing the query string: ```elm type Msg = ... | AddQuery (Dict.Dict String String) | SetQuery (Dict.Dict String String) | ClearQuery ``` Change update to respond to these actions: ```elm import Hop exposing(addQuery, setQuery, clearQuery) update msg model = case msg of ... AddQuery query -> let command = model.address |> Hop.addQuery query |> Hop.output routerConfig |> Navigation.newUrl in (model, command) ``` You need to pass the current `address` record to these functions. Then you use that `address` record to generate a url using `output`. Trigger these messages from your views: ```elm button [ onClick (AddQuery (Dict.singleton "color" "red")) ] [ text "Set query" ] ``` See details of available functions at ================================================ FILE: docs/nesting-routes.md ================================================ # Nesting routes UrlParser supports nested routes: ```elm type UserRoute = UsersRoute | UserRoute UserId type MainRoute = HomeRoute | AboutRoute | UsersRoutes UserRoute | NotFoundRoute usersMatchers = [ UrlParser.format UserRoute (int) , UrlParser.format UsersRoute (s "") ] mainMatchers = [ UrlParser.format HomeRoute (s "") , UrlParser.format AboutRoute (s "about") , UrlParser.format UsersRoutes (s "users" (oneOf usersMatchers)) ] matchers = oneOf mainMatchers ``` With a setup like this UrlParser will be able to match routes like: - "" -> HomeRoute - "/about" -> AboutRoute - "/users" -> UsersRoutes UsersRoute - "/users/2" -> UsersRoutes (UserRoute 2) ================================================ FILE: docs/reverse-routing.md ================================================ # Reverse routing Reverse routing means converting a route tag back to an url e.g. ``` UserRoute 1 --> "/users/1" ``` In the current version Hop doesn't have any helpers for reverse routing. You can do this manually: ```elm reverse : Route -> String reverse route = case route of HomeRoute -> "" AboutRoute -> "about" UserRoute id -> "users/" ++ id NotFoundRoute -> "" ``` ================================================ FILE: docs/testing.md ================================================ # Testing Hop ## Unit tests ```bash cd tests elm package install -y cd .. npm i npm test ``` ================================================ FILE: docs/upgrade-2-to-3.md ================================================ # Upgrading from 2 to 3 Hop has changed in many ways in version 3. Please review the [Getting started guide](https://github.com/sporto/hop/blob/master/docs/getting-started.md). Following is an overview of the major changes. ## Routes Now routes can take values, instead of: ```elm type Route = Home | Users | User | Token ``` You can have values attached: ```elm type Route = Home | Users | User Int | Token String ``` Routes are now defined using matchers. So instead of ```elm routes = [ ("/users/:int", User) ] ``` You do: ```elm import Hop.Matchers exposing(match2) userMatcher = match2 User "/users/" int matchers = [userMatcher] ``` This is so we can have stronger types e.g. `User Int`. ## Actions Hop.signal now returns a tuple with `(Route, Location)`. Your application needs to map this to an action. e.g. ```elm type Action = HopAction () | ApplyRoute ( Route, Location ) taggedRouterSignal : Signal Action taggedRouterSignal = Signal.map ApplyRoute router.signal ``` This is so routes (`Route`) are different type than the application actions. ## Payload Before Hop returned a `payload` with a dictionary with matched parameters. Now it returns the matched route with the values e.g. `User 1` and a `Location` record in the form of a tuple: ```elm (User 1, location) ``` `location` has the information about the current path and the query: ```elm { path = ["users", "1"], query = } ``` ## Views In your views you don't need to 'search' for the correct parameter in the payload anymore. The parameters are now in the route e.g. `User 1`. So instead of doing: ```elm userId = model.routerPayload.params |> Dict.get "userId" |> Maybe.withDefault "" ``` You simply get the id from the route: ```elm case User userId -> ... ``` The query is still a dictionary. The query is now in the `location` record as shown above. ================================================ FILE: docs/upgrade-3-to-4.md ================================================ # Upgrading from 3 to 4 Version 4 introduces push state. There are two major changes: ## Config Config now includes `hash` and `basePath`. ```elm routerConfig : Config Route routerConfig = { hash = True , basePath = "" , matchers = matchers , notFound = NotFoundRoute } ``` ## Functions require the router config Most functions in Hop now require your router config. For example instead of: ```elm navigateTo path addQuery query location ``` It is now: ```elm navigateTo config path addQuery config query location ``` This is because Hop needs to know if you are using hash or path routing. ================================================ FILE: docs/upgrade-4-to-5.md ================================================ # Upgrading from 4 to 5 Version 5 works with Navigation and Elm 0.17 ## Matchers All matchers stay the same as in version 4. ## Navigation Navigation is now handled by the Navigation module. See example app at `examples/basic/Main.elm`. Hop doesn't return effects / commands anymore, this should be done by passing a path to `Navigation.modifyUrl`. ================================================ FILE: docs/upgrade-5-to-6.md ================================================ # Upgrading from 5 to 6 Version 6 removes Hop matchers. Use UrlParser instead. See Building Routes document for examples. ================================================ FILE: elm-package.json ================================================ { "version": "6.0.1", "summary": "Routing and Navigation helpers for SPAs in Elm", "repository": "https://github.com/sporto/hop.git", "license": "MIT", "source-directories": [ "examples", "src" ], "exposed-modules": [ "Hop", "Hop.Types" ], "dependencies": { "elm-lang/core": "4.0.0 <= v < 5.0.0", "evancz/elm-http": "3.0.1 <= v < 4.0.0" }, "elm-version": "0.16.0 <= v < 0.18.0" } ================================================ FILE: examples/basic/.dockerignore ================================================ Dockefile elm-stuff ================================================ FILE: examples/basic/.gitignore ================================================ index.html ================================================ FILE: examples/basic/Main.elm ================================================ module Main exposing (..) {-| You will need Navigation, UrlParser and Hop. ``` elm package install elm-lang/navigation elm package install evancz/url-parser elm package install sporto/hop ``` -} import Html exposing (..) import Html.Attributes exposing (class) import Html.Events exposing (onClick) import Dict import Navigation import UrlParser exposing (()) import Hop import Hop.Types exposing (Config, Address, Query) -- ROUTES {-| Define your routes as union types. You need to provide a route for when the current URL doesn't match any known route i.e. NotFoundRoute. -} type Route = AboutRoute | MainRoute | NotFoundRoute {-| Define route matchers. See `docs/building-routes.md` for more examples. -} routes : UrlParser.Parser (Route -> a) a routes = UrlParser.oneOf [ UrlParser.format MainRoute (UrlParser.s "") , UrlParser.format AboutRoute (UrlParser.s "about") ] {-| Define your router configuration. Use `hash = True` for hash routing e.g. `#/users/1`. Use `hash = False` for push state e.g. `/users/1`. The `basePath` is only used for path routing. This is useful if you application is not located at the root of a url e.g. `/app/v1/users/1` where `/app/v1` is the base path. -} hopConfig : Config hopConfig = { hash = True , basePath = "" } -- MESSAGES {-| Add messages for navigation and changing the query. -} type Msg = NavigateTo String | SetQuery Query -- MODEL {-| Add the current route and address to your model. - `Route` is your Route union type defined above. - `Hop.Address` is record to aid with changing the query string. `route` will be used for determining the current route in the views. `address` is needed because: - Some navigation functions in Hop need this information to rebuild the current address. - Your views might need information about the current query string. -} type alias Model = { address : Address , route : Route } {-| Respond to navigation messages in update i.e. NavigateTo and SetQuery -} update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case (Debug.log "msg" msg) of NavigateTo path -> let command = -- First generate the URL using your config (`outputFromPath`). -- Then generate a command using Navigation.newUrl. Hop.outputFromPath hopConfig path |> Navigation.newUrl in ( model, command ) SetQuery query -> let command = -- First modify the current stored address record (setting the query) -- Then generate a URL using Hop.output -- Finally, create a command using Navigation.newUrl model.address |> Hop.setQuery query |> Hop.output hopConfig |> Navigation.newUrl in ( model, command ) {-| Create a URL Parser for Navigation -} urlParser : Navigation.Parser ( Route, Address ) urlParser = let -- A parse function takes the normalised path from Hop after taking -- in consideration the basePath and the hash. -- This function then returns a result. parse path = -- First we parse using UrlParser.parse. -- Then we return the parsed route or NotFoundRoute if the parsed failed. -- You can choose to return the parse return directly. path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute resolver = -- Create a function that parses and formats the URL -- This function takes 2 arguments: The Hop Config and the parse function. Hop.makeResolver hopConfig parse in -- Create a Navigation URL parser Navigation.makeParser (.href >> resolver) {-| Navigation will call urlUpdate when the address changes. This function gets the result from `urlParser`, which is a tuple with (Route, Hop.Types.Address) Address is a record that has: ```elm { path: List String, query: Hop.Types.Query } ``` - `path` is an array of strings that has the current path e.g. `["users", "1"]` for `"/users/1"` - `query` Is dictionary of String String. You can access this information in your views to show the relevant content. We store these two things in our model. We keep the address because it is needed for matching a query string. -} urlUpdate : ( Route, Address ) -> Model -> ( Model, Cmd Msg ) urlUpdate ( route, address ) model = ( { model | route = route, address = address }, Cmd.none ) -- VIEWS view : Model -> Html Msg view model = div [] [ menu model , pageView model ] menu : Model -> Html Msg menu model = div [] [ div [] [ button [ class "btnMain" , onClick (NavigateTo "") ] [ text "Main" ] , button [ class "btnAbout" , onClick (NavigateTo "about") ] [ text "About" ] , button [ class "btnQuery" , onClick (SetQuery (Dict.singleton "keyword" "el/m")) ] [ text "Set query string" ] , currentQuery model ] ] currentQuery : Model -> Html msg currentQuery model = let query = toString model.address.query in span [ class "labelQuery" ] [ text query ] {-| Views can decide what to show using `model.route`. -} pageView : Model -> Html msg pageView model = case model.route of MainRoute -> div [] [ h2 [ class "title" ] [ text "Main" ] ] AboutRoute -> div [] [ h2 [ class "title" ] [ text "About" ] ] NotFoundRoute -> div [] [ h2 [ class "title" ] [ text "Not found" ] ] -- APP {-| Your init function will receive an initial payload from Navigation, this payload is the initial matched location. Here we store the `route` and `address` in our model. -} init : ( Route, Address ) -> ( Model, Cmd Msg ) init ( route, address ) = ( Model address route, Cmd.none ) {-| Wire everything using Navigation. -} main : Program Never main = Navigation.program urlParser { init = init , view = view , update = update , urlUpdate = urlUpdate , subscriptions = (always Sub.none) } ================================================ FILE: examples/basic/elm-package.json ================================================ { "version": "1.0.0", "summary": "helpful summary of your project, less than 80 characters", "repository": "https://github.com/user/project.git", "license": "BSD3", "source-directories": [ "../../src/" ], "exposed-modules": [], "dependencies": { "elm-lang/core": "4.0.0 <= v < 5.0.0", "elm-lang/html": "1.0.0 <= v < 2.0.0", "elm-lang/navigation": "1.0.0 <= v < 2.0.0", "evancz/elm-http": "3.0.1 <= v < 4.0.0", "evancz/url-parser": "1.0.0 <= v < 2.0.0" }, "elm-version": "0.16.0 <= v < 0.18.0" } ================================================ FILE: examples/basic/install-packages.sh ================================================ #!/bin/bash n=0 until [ $n -ge 5 ] do elm package install -y && break n=$[$n+1] sleep 15 done ================================================ FILE: examples/basic/readme.md ================================================ # Basic Hop Example - Install packages `elm package install -y` - Run `elm reactor` - Open `http://localhost:8000/Main.elm` ================================================ FILE: examples/full/.gitignore ================================================ index.html ================================================ FILE: examples/full/dev_server.js ================================================ var path = require('path'); var express = require('express'); var http = require('http'); var webpack = require('webpack'); var config = require('./webpack.config'); var app = express(); var compiler = webpack(config); var host = 'localhost'; var port = 3000; app.use(require('webpack-dev-middleware')(compiler, { // contentBase: 'src', noInfo: true, publicPath: config.output.publicPath, inline: true, stats: { colors: true }, })) app.get('/app', function(req, res) { res.sendFile(path.join(__dirname, 'public/index.html')); }); app.get('/app/*', function(req, res) { res.sendFile(path.join(__dirname, 'public/index.html')); }); // When hitting / redirect to app app.get('/', function(req, res) { res.redirect('/app'); }); // Server images app.use(express.static('public')); var server = http.createServer(app); server.listen(port, function(err) { if (err) { console.log(err); return; } var addr = server.address(); console.log('Listening at http://%s:%d', addr.address, addr.port); }) ================================================ FILE: examples/full/elm-package.json ================================================ { "version": "1.0.0", "summary": "helpful summary of your project, less than 80 characters", "repository": "https://github.com/user/project.git", "license": "BSD3", "source-directories": [ "./src", "../../src/" ], "exposed-modules": [], "dependencies": { "elm-lang/core": "4.0.0 <= v < 5.0.0", "elm-lang/html": "1.0.0 <= v < 2.0.0", "elm-lang/navigation": "1.0.0 <= v < 2.0.0", "evancz/elm-http": "3.0.1 <= v < 4.0.0", "evancz/url-parser": "1.0.0 <= v < 2.0.0" }, "elm-version": "0.16.0 <= v < 0.18.0" } ================================================ FILE: examples/full/install-packages.sh ================================================ #!/bin/bash n=0 until [ $n -ge 5 ] do elm package install -y && break n=$[$n+1] sleep 15 done ================================================ FILE: examples/full/package.json ================================================ { "name": "full", "version": "1.0.0", "description": "", "main": "elm.js", "scripts": { "dev": "node dev_server.js" }, "author": "", "license": "ISC", "dependencies": { "ace-css": "^1.0.1", "css-loader": "^0.23.1", "elm-webpack-loader": "^3.0.1", "express": "^4.13.4", "file-loader": "^0.8.5", "style-loader": "^0.13.1", "url-loader": "^0.5.7", "webpack": "^1.12.14", "webpack-dev-middleware": "^1.5.1" } } ================================================ FILE: examples/full/readme.md ================================================ # Full Hop Example This example uses push state. To run: ``` elm package install -y npm i npm run dev ``` Open http://localhost:3000 ## Webpack config The Webpack config included in this example is not ready for production. Please refer to the Webpack site. ================================================ FILE: examples/full/src/Languages/Edit.elm ================================================ module Languages.Edit exposing (..) import Html exposing (..) import Html.Events exposing (on, targetValue) import Html.Attributes exposing (href, style, src, value, name) import Json.Decode as Json import Languages.Models exposing (..) import Languages.Messages exposing (..) styles : Html.Attribute a styles = style [ ( "float", "left" ) ] view : Language -> Html Msg view language = div [ styles ] [ h2 [] [ text language.name ] , form [] [ input [ value language.name , name "name" , on "input" (Json.map (Update language.id "name") targetValue) ] [] ] ] ================================================ FILE: examples/full/src/Languages/Filter.elm ================================================ module Languages.Filter exposing (..) import Html exposing (..) import Html.Events exposing (onClick) import Html.Attributes exposing (id, class, href, style) import Dict import Languages.Messages exposing (..) type alias ViewModel = {} styles : Html.Attribute a styles = style [ ( "float", "left" ) , ( "margin-left", "2rem" ) , ( "margin-right", "2rem" ) ] view : ViewModel -> Html Msg view model = div [ styles ] [ h2 [] [ text "Filter" ] , btn "btnSetQuery" "SetQuery" (SetQuery (Dict.singleton "latests" "true")) , div [] [ h3 [] [ text "Kind" ] , div [] [ btn "btnAll" "All" (AddQuery (Dict.singleton "typed" "")) , btn "btnDynamic" "Dynamic" (AddQuery (Dict.singleton "typed" "dynamic")) , btn "btnStatic" "Static" (AddQuery (Dict.singleton "typed" "static")) ] ] ] btn : String -> String -> Msg -> Html Msg btn viewId label action = button [ id viewId, class "btn btn-primary btn-small inline-block mr1", onClick action ] [ text label ] ================================================ FILE: examples/full/src/Languages/List.elm ================================================ module Languages.List exposing (..) import Html exposing (..) import Html.Events exposing (onClick) import Html.Attributes exposing (class, href, style) import Hop.Types exposing (Address) import Dict import Languages.Models exposing (..) import Languages.Messages exposing (..) type alias ViewModel = { languages : List Language , address : Address } styles : Html.Attribute a styles = style [ ( "float", "left" ) , ( "margin-right", "2rem" ) ] hasTag : String -> Language -> Bool hasTag tag language = List.any (\t -> t == tag) language.tags filteredLanguages : ViewModel -> List Language filteredLanguages model = let typed = model.address.query |> Dict.get "typed" |> Maybe.withDefault "" in case typed of "" -> model.languages _ -> List.filter (hasTag typed) model.languages view : ViewModel -> Html Msg view model = div [ styles ] [ h2 [] [ text "Languages" ] , table [] [ tbody [] (tableRows (filteredLanguages model)) ] ] tableRows : List Language -> List (Html Msg) tableRows collection = List.map rowView collection rowView : Language -> Html Msg rowView language = tr [] [ td [] [ text (toString language.id) ] , td [] [ text language.name ] , td [] [ actionBtn (Show language.id) "Show" , actionBtn (Edit language.id) "Edit" ] ] actionBtn : Msg -> String -> Html Msg actionBtn action label = button [ class "regular btn btn-small", onClick action ] [ text label ] ================================================ FILE: examples/full/src/Languages/Messages.elm ================================================ module Languages.Messages exposing (..) import Dict import Languages.Models exposing (..) type alias Prop = String type alias Value = String type Msg = Show LanguageId | Edit LanguageId | Update LanguageId Prop Value | AddQuery (Dict.Dict String String) | SetQuery (Dict.Dict String String) ================================================ FILE: examples/full/src/Languages/Models.elm ================================================ module Languages.Models exposing (..) type alias LanguageId = Int type alias Language = { id : LanguageId , name : String , image : String , tags : List String } -- ROUTING type Route = LanguagesRoute | LanguageRoute LanguageId | LanguageEditRoute LanguageId languages : List Language languages = [ { id = 1 , name = "Elm" , image = "elm" , tags = [ "functional", "browser", "static" ] } , { id = 2 , name = "JavaScript" , image = "js" , tags = [ "functional", "oo", "browser", "dynamic", "prototypical" ] } , { id = 3 , name = "Go" , image = "go" , tags = [ "oo", "google", "static" ] } , { id = 4 , name = "Rust" , image = "rust" , tags = [ "functional", "mozilla", "static" ] } , { id = 5 , name = "Elixir" , image = "elixir" , tags = [ "functional", "erlang", "dynamic" ] } , { id = 6 , name = "Ruby" , image = "ruby" , tags = [ "oo", "japan", "1996", "dynamic", "classical" ] } , { id = 7 , name = "Python" , image = "python" , tags = [ "oo", "dynamic", "classical" ] } , { id = 8 , name = "Swift" , image = "swift" , tags = [ "functional", "apple", "static", "classical" ] } , { id = 9 , name = "Haskell" , image = "haskell" , tags = [ "functional", "static" ] } , { id = 10 , name = "Java" , image = "java" , tags = [ "oo", "static", "classical" ] } , { id = 11 , name = "C#" , image = "csharp" , tags = [ "oo", "microsoft", "static", "classical" ] } , { id = 12 , name = "PHP" , image = "php" , tags = [ "oo", "server", "1994", "dynamic", "classical" ] } ] ================================================ FILE: examples/full/src/Languages/Routing.elm ================================================ module Languages.Routing exposing (..) import Languages.Models exposing (..) import UrlParser exposing ((), int, s, string) matchers : List (UrlParser.Parser (Route -> a) a) matchers = [ UrlParser.format LanguageEditRoute (int s "edit") , UrlParser.format LanguageRoute (int) , UrlParser.format LanguagesRoute (s "") ] toS : a -> String toS = toString reverseWithPrefix : Route -> String reverseWithPrefix route = "/languages" ++ (reverse route) reverse : Route -> String reverse route = case route of LanguagesRoute -> "/" LanguageRoute id -> "/" ++ (toS id) LanguageEditRoute id -> "/" ++ (toS id) ++ "/edit" ================================================ FILE: examples/full/src/Languages/Show.elm ================================================ module Languages.Show exposing (..) import Html exposing (..) import Html.Attributes exposing (id, href, style, src) import Languages.Models exposing (..) import Languages.Messages exposing (..) styles : Html.Attribute a styles = style [ ( "float", "left" ) ] view : Language -> Html Msg view language = div [ styles ] [ h2 [ id "titleLanguage" ] [ text language.name ] , img [ src ("/images/" ++ language.image ++ ".png") ] [] , tags language ] tags : Language -> Html Msg tags language = div [] (List.map tag language.tags) tag : String -> Html Msg tag tagName = span [] [ text (tagName ++ ", ") ] ================================================ FILE: examples/full/src/Languages/Update.elm ================================================ module Languages.Update exposing (..) import Debug import Navigation import Hop exposing (output, outputFromPath, addQuery, setQuery) import Hop.Types exposing (Config, Address) import Routing import Languages.Models exposing (..) import Languages.Messages exposing (Msg(..)) import Languages.Routing type alias UpdateModel = { languages : List Language , address : Address } routerConfig : Config routerConfig = Routing.config navigationCmd : String -> Cmd a navigationCmd path = path |> outputFromPath routerConfig |> Navigation.modifyUrl update : Msg -> UpdateModel -> ( UpdateModel, Cmd Msg ) update message model = case Debug.log "message" message of Show id -> let path = Languages.Routing.reverseWithPrefix (Languages.Models.LanguageRoute id) in ( model, navigationCmd path ) Edit id -> let path = Languages.Routing.reverseWithPrefix (Languages.Models.LanguageEditRoute id) in ( model, navigationCmd path ) Update id prop value -> let udpatedLanguages = List.map (updateWithId id prop value) model.languages in ( { model | languages = udpatedLanguages }, Cmd.none ) AddQuery query -> let command = model.address |> addQuery query |> output routerConfig |> Navigation.modifyUrl in ( model, command ) SetQuery query -> let command = model.address |> setQuery query |> output routerConfig |> Navigation.modifyUrl in ( model, command ) updateWithId : LanguageId -> String -> String -> Language -> Language updateWithId id prop value language = if id == language.id then case prop of "name" -> { language | name = value } _ -> language else language ================================================ FILE: examples/full/src/Languages/View.elm ================================================ module Languages.View exposing (..) import Html exposing (..) import Html.Attributes exposing (href, style) import Hop.Types exposing (Address) import Languages.Models exposing (LanguageId, Language, Route, Route(..)) import Languages.Messages exposing (..) import Languages.Filter import Languages.List import Languages.Show import Languages.Edit type alias ViewModel = { languages : List Language , address : Address , route : Route } containerStyle : Html.Attribute a containerStyle = style [ ( "margin-bottom", "5rem" ) , ( "overflow", "auto" ) ] view : ViewModel -> Html Msg view model = div [ containerStyle ] [ Languages.Filter.view {} , Languages.List.view { languages = model.languages, address = model.address } , subView model ] subView : ViewModel -> Html Msg subView model = case model.route of LanguageRoute languageId -> let maybeLanguage = getLanguage model.languages languageId in case maybeLanguage of Just language -> Languages.Show.view language Nothing -> notFoundView model LanguageEditRoute languageId -> let maybeLanguage = getLanguage model.languages languageId in case maybeLanguage of Just language -> Languages.Edit.view language _ -> notFoundView model LanguagesRoute -> emptyView emptyView : Html msg emptyView = span [] [] notFoundView : ViewModel -> Html msg notFoundView model = div [] [ text "Not Found" ] getLanguage : List Language -> LanguageId -> Maybe Language getLanguage languages id = languages |> List.filter (\lang -> lang.id == id) |> List.head ================================================ FILE: examples/full/src/Main.elm ================================================ module Main exposing (..) import Navigation import Hop import Hop.Types exposing (Address) import Messages exposing (..) import Models exposing (..) import Update exposing (..) import View exposing (..) import Routing import String import UrlParser urlParser : Navigation.Parser ( Route, Address ) urlParser = let parse path = path |> UrlParser.parse identity Routing.routes |> Result.withDefault NotFoundRoute matcher = Hop.makeResolver Routing.config parse in Navigation.makeParser (.href >> matcher) urlUpdate : ( Route, Address ) -> AppModel -> ( AppModel, Cmd Msg ) urlUpdate ( route, address ) model = let _ = Debug.log "urlUpdate address" address in ( { model | route = route, address = address }, Cmd.none ) init : ( Route, Address ) -> ( AppModel, Cmd Msg ) init ( route, address ) = ( newAppModel route address, Cmd.none ) main : Program Never main = Navigation.program urlParser { init = init , view = view , update = update , urlUpdate = urlUpdate , subscriptions = (always Sub.none) } ================================================ FILE: examples/full/src/Messages.elm ================================================ module Messages exposing (..) import Hop.Types exposing (Query) import Languages.Messages type Msg = SetQuery Query | LanguagesMsg Languages.Messages.Msg | ShowHome | ShowLanguages | ShowAbout ================================================ FILE: examples/full/src/Models.elm ================================================ module Models exposing (..) import Hop.Types exposing (Address, newAddress) import Languages.Models exposing (Language, languages) type Route = HomeRoute | AboutRoute | LanguagesRoutes Languages.Models.Route | NotFoundRoute type alias AppModel = { languages : List Language , address : Address , route : Route , selectedLanguage : Maybe Language } newAppModel : Route -> Address -> AppModel newAppModel route address = { languages = languages , address = address , route = route , selectedLanguage = Maybe.Nothing } ================================================ FILE: examples/full/src/Routing.elm ================================================ module Routing exposing (..) import Models exposing (..) import Hop.Types exposing (Config) import Languages.Routing import UrlParser exposing ((), oneOf, int, s) import Languages.Routing matchers : List (UrlParser.Parser (Route -> a) a) matchers = [ UrlParser.format HomeRoute (s "") , UrlParser.format AboutRoute (s "about") , UrlParser.format LanguagesRoutes (s "languages" (oneOf Languages.Routing.matchers)) ] routes : UrlParser.Parser (Route -> a) a routes = oneOf matchers config : Config config = { basePath = "/app" , hash = False } reverse : Route -> String reverse route = case route of HomeRoute -> "" AboutRoute -> "about" LanguagesRoutes subRoute -> "languages" ++ Languages.Routing.reverse subRoute NotFoundRoute -> "" ================================================ FILE: examples/full/src/Update.elm ================================================ module Update exposing (..) import Debug import Navigation import Hop exposing (output, outputFromPath, setQuery) import Hop.Types exposing (Config) import Messages exposing (..) import Models exposing (..) import Routing import Languages.Update import Languages.Models navigationCmd : String -> Cmd a navigationCmd path = path |> outputFromPath Routing.config |> Navigation.newUrl routerConfig : Config routerConfig = Routing.config update : Msg -> AppModel -> ( AppModel, Cmd Msg ) update message model = case Debug.log "message" message of LanguagesMsg subMessage -> let updateModel = { languages = model.languages , address = model.address } ( updatedModel, cmd ) = Languages.Update.update subMessage updateModel in ( { model | languages = updatedModel.languages }, Cmd.map LanguagesMsg cmd ) SetQuery query -> let command = model.address |> setQuery query |> output routerConfig |> Navigation.newUrl in ( model, command ) ShowHome -> let path = Routing.reverse HomeRoute in ( model, navigationCmd path ) ShowLanguages -> let path = Routing.reverse (LanguagesRoutes Languages.Models.LanguagesRoute) in ( model, navigationCmd path ) ShowAbout -> let path = Routing.reverse AboutRoute in ( model, navigationCmd path ) ================================================ FILE: examples/full/src/View.elm ================================================ module View exposing (..) import Html exposing (..) import Html.App import Html.Events exposing (onClick) import Html.Attributes exposing (id, class, href, style) import Models exposing (..) import Messages exposing (..) import Languages.View view : AppModel -> Html Msg view model = div [] [ menu model , pageView model ] menu : AppModel -> Html Msg menu model = div [ class "p2 white bg-black" ] [ div [] [ menuLink ShowHome "btnHome" "Home" , text "|" , menuLink ShowLanguages "btnLanguages" "Languages" , text "|" , menuLink ShowAbout "btnAbout" "About" ] ] menuLink : Msg -> String -> String -> Html Msg menuLink message viewId label = a [ id viewId , href "javascript://" , onClick message , class "white px2" ] [ text label ] pageView : AppModel -> Html Msg pageView model = case model.route of HomeRoute -> div [ class "p2" ] [ h1 [ id "title", class "m0" ] [ text "Home" ] , div [] [ text "Click on Languages to start routing" ] ] AboutRoute -> div [ class "p2" ] [ h1 [ id "title", class "m0" ] [ text "About" ] ] LanguagesRoutes languagesRoute -> let viewModel = { languages = model.languages , route = languagesRoute , address = model.address } in div [ class "p2" ] [ h1 [ id "title", class "m0" ] [ text "Languages" ] , Html.App.map LanguagesMsg (Languages.View.view viewModel) ] NotFoundRoute -> notFoundView model notFoundView : AppModel -> Html msg notFoundView model = div [] [ text "Not Found" ] ================================================ FILE: examples/full/src/index.js ================================================ 'use strict'; require('ace-css/css/ace.css'); var Elm = require('./Main.elm'); var mountNode = document.getElementById('main'); var app = Elm.Main.embed(mountNode); ================================================ FILE: examples/full/webpack.config.js ================================================ var path = require("path"); /* publicPath is used for finding the bundles during dev e.g. http://localhost:3000/bundles/app.js When the index.html is served using the webpack server then just specify the path. When index.html is served using a framework e.g. from Rails, Phoenix or Go then you must specify the full url where the webpack dev server is running e.g. http://localhost:4000/bundles/ This path is also used for resolving relative assets e.g. fonts from css. So for production and staging this path has to be overriden. See webpack.prod.config.js */ var publicPath = '/bundles/' module.exports = { entry: { app: [ './src/index.js' ] }, output: { path: path.resolve(__dirname + '/dist'), filename: '[name].js', publicPath: publicPath, }, module: { loaders: [ { test: /\.(css|scss)$/, loaders: [ 'style-loader', 'css-loader', ] }, { test: /\.html$/, exclude: /node_modules/, loader: 'file?name=[name].[ext]', }, { test: /\.elm$/, exclude: [/elm-stuff/, /node_modules/], loader: 'elm-webpack', }, { test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'url-loader?limit=10000&minetype=application/font-woff', }, { test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'file-loader', }, ], noParse: /\.elm$/, }, }; ================================================ FILE: license.md ================================================ The MIT License (MIT) Copyright (c) 2015-present, Sebastian Porto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: package.json ================================================ { "name": "Hop", "version": "1.0.0", "description": "", "main": "", "scripts": { "test": "elm-test" }, "author": "", "license": "MIT", "devDependencies": { "elm-test": "^0.17.3" } } ================================================ FILE: readme.md ================================================ # Hop: Navigation and routing helpers for Elm SPAs [![Build Status](https://semaphoreci.com/api/v1/sporto/hop/branches/master/badge.svg)](https://semaphoreci.com/sporto/hop) ![alt Hop](https://raw.githubusercontent.com/sporto/hop/master/assets/logo.png) __With the release of Elm 0.18 the official libraries `Navigation` and `UrlParser` have become a lot more robust and useful. They now integrate a lot of the functionality that Hop used to provide for previous versions of Elm. For example `UrlParser` now has `parseHash` and `parsePath`. Because of this I'm not convinced that Hop needs to be upgraded to Elm 0.18. I'll wait and see if this library could provide value in 0.18.__ Hop is a helper library meant to be used with: - [__Navigation v1__](http://package.elm-lang.org/packages/elm-lang/navigation) for listening to location changes in the browser and pushing changes to it. - [__UrlParser v1__](http://package.elm-lang.org/packages/evancz/url-parser) for constructing routes and parsing URLs. ## What Hop provides On top of these two packages above, Hop helps with: - Abstracting the differences between push or hash routing - Providing helpers for working with the query string - Encode / Decode the location path - Encode / Decode the query string ## Getting Started Please see this [example app](https://github.com/sporto/hop/blob/master/examples/basic/Main.elm). It explains how to wire everything in the comments. ## Docs ### [Building routes](https://github.com/sporto/hop/blob/master/docs/building-routes.md) ### [Nesting routes](https://github.com/sporto/hop/blob/master/docs/nesting-routes.md) ### [Matching routes](https://github.com/sporto/hop/blob/master/docs/matching-routes.md) ### [Navigating](https://github.com/sporto/hop/blob/master/docs/navigating.md) ### [Reverse routing](https://github.com/sporto/hop/blob/master/docs/reverse-routing.md) ### [API](http://package.elm-lang.org/packages/sporto/hop/latest/) ### [Changelog](./docs/changelog.md) ### [Testing Hop](https://github.com/sporto/hop/blob/master/docs/testing.md) ## More docs ### [Upgrade guide 5 to 6](https://github.com/sporto/hop/blob/master/docs/upgrade-5-to-6.md) ### [Upgrade guide 4 to 5](https://github.com/sporto/hop/blob/master/docs/upgrade-4-to-5.md) ### [Upgrade guide 3 to 4](https://github.com/sporto/hop/blob/master/docs/upgrade-3-to-4.md) ### [Upgrade guide 2.1 to 3.0](https://github.com/sporto/hop/blob/master/docs/upgrade-2-to-3.md) ### [Version 5 documentation](https://github.com/sporto/hop/tree/v5) ### [Version 4 documentation](https://github.com/sporto/hop/tree/v4) ### [Version 3 documentation](https://github.com/sporto/hop/tree/v3) ### [Version 2 documentation](https://github.com/sporto/hop/tree/v2) ### Hash routing A proper url should have the query before the hash e.g. `?keyword=Ja#/users/1`, but when using hash routing, query parameters are appended after the hash path e.g. `#/users/1?keyword=Ja`. This is done for aesthetics and so the router is fully controlled by the hash fragment. ## Examples See `examples/basic` and `examples/full` folders. To run the example apps: - Clone this repo - Go to example folder - Follow the readme in that folder ================================================ FILE: src/Hop/Address.elm ================================================ module Hop.Address exposing (..) import Dict import String import Http exposing (uriEncode, uriDecode) import Hop.Types exposing (..) {-| Get the Path -} getPath : Address -> String getPath address = address.path |> List.map uriEncode |> String.join "/" |> String.append "/" {-| Get the query string from a Address. Including ? -} getQuery : Address -> String getQuery address = if Dict.isEmpty address.query then "" else address.query |> Dict.toList |> List.map (\( k, v ) -> ( uriEncode k, uriEncode v )) |> List.map (\( k, v ) -> k ++ "=" ++ v) |> String.join "&" |> String.append "?" -------------------------------------------------------------------------------- -- PARSING -- Parse a path into a Address -------------------------------------------------------------------------------- parse : String -> Address parse route = { path = parsePath route , query = parseQuery route } extractPath : String -> String extractPath route = route |> String.split "#" |> List.reverse |> List.head |> Maybe.withDefault "" |> String.split "?" |> List.head |> Maybe.withDefault "" parsePath : String -> List String parsePath route = route |> extractPath |> String.split "/" |> List.filter (\segment -> not (String.isEmpty segment)) |> List.map uriDecode extractQuery : String -> String extractQuery route = route |> String.split "?" |> List.drop 1 |> List.head |> Maybe.withDefault "" parseQuery : String -> Query parseQuery route = route |> extractQuery |> String.split "&" |> List.filter (not << String.isEmpty) |> List.map queryKVtoTuple |> Dict.fromList {-| @priv Convert a string to a tuple. Decode on the way. "k=1" --> ("k", "1") -} queryKVtoTuple : String -> ( String, String ) queryKVtoTuple kv = let splitted = kv |> String.split "=" first = splitted |> List.head |> Maybe.withDefault "" firstDecoded = uriDecode first second = splitted |> List.drop 1 |> List.head |> Maybe.withDefault "" secondDecoded = uriDecode second in ( firstDecoded, secondDecoded ) ================================================ FILE: src/Hop/AddressTest.elm ================================================ module Hop.AddressTest exposing (..) import Dict import Expect import Hop.Address as Address import Hop.Types as Types import Test exposing (..) getPathTest : Test getPathTest = let inputs = [ ( "it works" , { path = [ "users", "1" ], query = Dict.singleton "k" "1" } , "/users/1" ) , ( "it encodes" , { path = [ "us/ers", "1" ], query = Dict.empty } , "/us%2Fers/1" ) ] run ( testCase, address, expected ) = test testCase <| \() -> let actual = Address.getPath address in Expect.equal expected actual in describe "getPath" (List.map run inputs) getQuery : Test getQuery = let inputs = [ ( "it works" , { path = [], query = Dict.singleton "k" "1" } , "?k=1" ) , ( "it encoders" , { path = [], query = Dict.singleton "k" "a/b" } , "?k=a%2Fb" ) ] run ( testCase, address, expected ) = test testCase <| \() -> let actual = Address.getQuery address in Expect.equal expected actual in describe "getQuery" (List.map run inputs) parseTest : Test parseTest = let inputs = [ ( "it parses" , "/users/1?a=1" , { path = [ "users", "1" ], query = Dict.singleton "a" "1" } ) , ( "it decodes" , "/a%2Fb/1?k=x%2Fy" , { path = [ "a/b", "1" ], query = Dict.singleton "k" "x/y" } ) ] run ( testCase, location, expected ) = test testCase <| \() -> let actual = Address.parse location in Expect.equal expected actual in describe "parse" (List.map run inputs) all : Test all = describe "Location" [ getPathTest , getQuery , parseTest ] ================================================ FILE: src/Hop/In.elm ================================================ module Hop.In exposing (..) import Regex import String import Hop.Types exposing (Address, Config) import Hop.Address exposing (parse) {-| @priv -} ingest : Config -> String -> Address ingest config href = href |> removeProtocol |> removeDomain |> getRelevantPathWithQuery config |> parse {-| @priv -} removeProtocol : String -> String removeProtocol href = href |> String.split "//" |> List.reverse |> List.head |> Maybe.withDefault "" {-| @priv -} removeDomain : String -> String removeDomain href = href |> String.split "/" |> List.tail |> Maybe.withDefault [] |> String.join "/" |> String.append "/" {-| @priv -} getRelevantPathWithQuery : Config -> String -> String getRelevantPathWithQuery config href = if config.hash then href |> String.split "#" |> List.drop 1 |> List.head |> Maybe.withDefault "" else href |> String.split "#" |> List.head |> Maybe.withDefault "" |> removeBase config {-| @priv Remove the basePath from a path "/basepath/a/b?k=1" -> "/a/b?k=1" -} removeBase : Config -> String -> String removeBase config pathWithQuery = let regex = Regex.regex config.basePath in Regex.replace (Regex.AtMost 1) regex (always "") pathWithQuery ================================================ FILE: src/Hop/InTest.elm ================================================ module Hop.InTest exposing (..) import Dict import Expect import Hop.In exposing (ingest) import Test exposing (..) type Route = NotFound config = { hash = True , basePath = "" } configWithPath = { config | hash = False } configWithPathAndBase = { configWithPath | basePath = "/app/v1" } inputTest : Test inputTest = let inputs = [ ( "it parses an empty hash" , config , "http://localhost:3000/basepath" , { path = [], query = Dict.empty } ) , ( "it parses a hash" , config , "http://localhost:3000/basepath#/users/1" , { path = [ "users", "1" ], query = Dict.empty } ) , ( "it parses a path" , configWithPath , "http://localhost:3000/users/1" , { path = [ "users", "1" ], query = Dict.empty } ) , ( "it parses a path with basepath" , configWithPathAndBase , "http://localhost:3000/app/v1/users/1" , { path = [ "users", "1" ], query = Dict.empty } ) , ( "it parses a hash with query" , config , "http://localhost:3000/basepath#/users/1?a=1" , { path = [ "users", "1" ], query = Dict.singleton "a" "1" } ) , ( "it parses a path with query" , configWithPath , "http://localhost:3000/users/1?a=1" , { path = [ "users", "1" ], query = Dict.singleton "a" "1" } ) , ( "it parses a path with basepath and query" , configWithPathAndBase , "http://localhost:3000/app/v1/users/1?a=1" , { path = [ "users", "1" ], query = Dict.singleton "a" "1" } ) , ( "it decodes the query" , config , "http://localhost:3000/basepath#/?a%20b%26c%3Fd=1%202%263%3F4" , { path = [], query = Dict.singleton "a b&c?d" "1 2&3?4" } ) ] run ( testCase, config, href, expected ) = test testCase <| \() -> let actual = ingest config href in Expect.equal expected actual in describe "ingest" (List.map run inputs) all : Test all = describe "In" [ inputTest ] ================================================ FILE: src/Hop/Out.elm ================================================ module Hop.Out exposing (..) import String import Hop.Types exposing (Address, Config) import Hop.Utils exposing (dedupSlash) import Hop.Address exposing (parse) {-| Make a real path from an address record. This will add the hash and the basePath as necessary. fromAddress config { path = ["users", "1"], query = Dict.empty } == "#/users/1" -} output : Config -> Address -> String output config address = let -- path -> "/a/1" path = Hop.Address.getPath address -- query -> "?a=1" query = Hop.Address.getQuery address url = if config.hash then "#" ++ path ++ query else if String.isEmpty config.basePath then path ++ query else if path == "/" then "/" ++ config.basePath ++ query else "/" ++ config.basePath ++ path ++ query realPath = dedupSlash url in if realPath == "" then "/" else realPath {-| Make a real path from a simulated path. This will add the hash and the basePath as necessary. toRealPath config "/users" == "#/users" -} outputFromPath : Config -> String -> String outputFromPath config path = path |> Hop.Address.parse |> output config ================================================ FILE: src/Hop/OutTest.elm ================================================ module Hop.OutTest exposing (..) import Dict import Expect import Hop.Out as Out import Hop.Types exposing (newAddress) import Hop.TestHelper exposing (configWithHash, configWithPath, configPathAndBasePath) import Test exposing (..) outputTest : Test outputTest = let empty = newAddress inputs = [ ( "hash: it is empty when empty" , configWithHash , empty , "" , "#/" ) , ( "path: it is empty when empty" , configWithPath , empty , "" , "/" ) , ( "basepath: it has the basepath" , configPathAndBasePath , empty , "" , "/app/v1" ) , ( "basepath: adds slash when missing" , { configPathAndBasePath | basePath = "app/v1" } , empty , "" , "/app/v1" ) -- path , ( "hash: it adds the path" , configWithHash , { empty | path = [ "a", "b" ] } , "/a/b" , "#/a/b" ) , ( "path: it adds the path" , configWithPath , { empty | path = [ "a", "b" ] } , "/a/b" , "/a/b" ) , ( "path: it adds the basepath and path" , configPathAndBasePath , { empty | path = [ "a", "b" ] } , "/a/b" , "/app/v1/a/b" ) -- query , ( "hash: it adds the query as pseudo query" , configWithHash , { empty | query = Dict.singleton "k" "1" } , "?k=1" , "#/?k=1" ) , ( "path: it adds the query" , configWithPath , { empty | query = Dict.singleton "k" "1" } , "?k=1" , "/?k=1" ) , ( "path: it adds the basepath query" , configPathAndBasePath , { empty | query = Dict.singleton "k" "1" } , "?k=1" , "/app/v1?k=1" ) -- path and query , ( "hash: it adds the path and query" , configWithHash , { empty | query = Dict.singleton "k" "1", path = [ "a", "b" ] } , "/a/b?k=1" , "#/a/b?k=1" ) , ( "path: it adds the path and query" , configWithPath , { empty | query = Dict.singleton "k" "1", path = [ "a", "b" ] } , "/a/b?k=1" , "/a/b?k=1" ) , ( "path: it adds the basepath, path and query" , configPathAndBasePath , { empty | query = Dict.singleton "k" "1", path = [ "a", "b" ] } , "/a/b?k=1" , "/app/v1/a/b?k=1" ) , ( "hash: it encodes" , configWithHash , { empty | query = Dict.singleton "a/d" "1/4", path = [ "a/b", "1" ] } , "/a%2Fb/1?a%2Fd=1%2F4" , "#/a%2Fb/1?a%2Fd=1%2F4" ) ] run ( testCase, config, address, path, expected ) = [ test testCase <| \() -> let actual = Out.output config address in Expect.equal expected actual , test testCase <| \() -> let actual = Out.outputFromPath config path in Expect.equal expected actual ] tests = List.concatMap run inputs in describe "output and outputFromPath" tests all : Test all = describe "In" [ outputTest ] ================================================ FILE: src/Hop/TestHelper.elm ================================================ module Hop.TestHelper exposing (..) import Hop.Types exposing (Config) configWithHash : Config configWithHash = { basePath = "" , hash = True } configWithPath : Config configWithPath = { basePath = "" , hash = False } configPathAndBasePath : Config configPathAndBasePath = { basePath = "/app/v1" , hash = False } ================================================ FILE: src/Hop/Types.elm ================================================ module Hop.Types exposing (Config, Query, Address, newQuery, newAddress) {-| Types used in Hop #Types @docs Config, Address, Query #Factories @docs newQuery, newAddress -} import Dict {-| A Dict that holds query parameters Dict.Dict String String -} type alias Query = Dict.Dict String String {-| A Record that represents the current location Includes a `path` and a `query` { path: List String, query: Query } -} type alias Address = { path : List String , query : Query } {-| Hop Configuration - basePath: Only for pushState routing (not hash). e.g. "/app". - hash: True for hash routing, False for pushState routing. -} type alias Config = { basePath : String , hash : Bool } {-| Create an empty Query record -} newQuery : Query newQuery = Dict.empty {-| Create an empty Address record -} newAddress : Address newAddress = { query = newQuery , path = [] } ================================================ FILE: src/Hop/Utils.elm ================================================ module Hop.Utils exposing (..) import Regex dedupSlash : String -> String dedupSlash = Regex.replace Regex.All (Regex.regex "/+") (\_ -> "/") ================================================ FILE: src/Hop.elm ================================================ module Hop exposing ( addQuery , clearQuery , ingest , makeResolver , output , outputFromPath , pathFromAddress , queryFromAddress , removeQuery , setQuery ) {-| Navigation and routing utilities for single page applications. See [readme](https://github.com/sporto/hop) for usage. # Consuming an URL from the browser @docs ingest, makeResolver # Preparing a URL for changing the browser location @docs output, outputFromPath # Work with an Address record @docs pathFromAddress, queryFromAddress # Modify the query string @docs addQuery, setQuery, removeQuery, clearQuery -} import Dict import String import Hop.Address import Hop.In import Hop.Out import Hop.Types exposing (Address, Config, Query) --------------------------------------- -- INGEST --------------------------------------- {-| Convert a raw url to an Address record. Use this function for 'normalizing' the URL before parsing it. This conversion will take in account your basePath and hash configuration. E.g. with path routing config = { basePath = "" , hash = False } ingest config "http://localhost:3000/app/languages/1?k=1" --> { path = ["app", "languages", "1" ], query = Dict.singleton "k" "1" } E.g. with path routing and base path config = { basePath = "/app/v1" , hash = False } ingest config "http://localhost:3000/app/v1/languages/1?k=1" --> { path = ["languages", "1" ], query = Dict.singleton "k" "1" } E.g. with hash routing config = { basePath = "" , hash = True } ingest config "http://localhost:3000/app#/languages/1?k=1" --> { path = ["languages", "1" ], query = Dict.singleton "k" "1" } -} ingest : Config -> String -> Address ingest = Hop.In.ingest {-| `makeResolver` normalizes the URL using your config and then gives that normalised URL to your parser. Use this for creating a function to give to `Navigation.makeParser`. See examples in `docs/matching-routes.md`. Hop.makeResolver hopConfig parse `makeResolver` takes 2 arguments. ### Config e.g. { basePath = "" , hash = False } ### Parse function A function that receives the normalised path and returns the result of parsing it. parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute You parse function will receive the path like this: `http://example.com/users/1` --> 'users/1/' So it won't have a leading /, but it will have a trailing /. This is because the way UrlParse works. ### Return value from resolver After being called with a URL the resolver will return a tuple with `(parse result, address)` e.g. resolver = Hop.makeResolver hopConfig parse resolver "http://example.com/index.html#/users/2" --> ( UserRoute 2, { path = ["users", "2"], query = ...} ) ### Example A complete example looks like: urlParser : Navigation.Parser ( Route, Address ) urlParser = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute resolver = Hop.makeResolver hopConfig parse in Navigation.makeParser (.href >> resolver) -} makeResolver : Config -> (String -> result) -> String -> (result, Address) makeResolver config parse rawInput = let address = rawInput |> ingest config parseResult = pathFromAddress address ++ "/" |> String.dropLeft 1 |> parse in (parseResult, address) --------------------------------------- -- CREATE OUTBOUND URLs --------------------------------------- {-| Convert an Address record to an URL to feed the browser. This will take in account your basePath and hash config. E.g. with path routing output config { path = ["languages", "1" ], query = Dict.singleton "k" "1" } --> "/languages/1?k=1" E.g. with hash routing output config { path = ["languages", "1" ], query = Dict.singleton "k" "1" } --> "#/languages/1?k=1" -} output : Config -> Address -> String output = Hop.Out.output {-| Convert a string to an URL to feed the browser. This will take in account your basePath and hash config. E.g. with path routing outputFromPath config "/languages/1?k=1" --> "/languages/1?k=1" E.g. with path routing + basePath outputFromPath config "/languages/1?k=1" --> "/app/languages/1?k=1" E.g. with hash routing output config "/languages/1?k=1" --> "#/languages/1?k=1" -} outputFromPath : Config -> String -> String outputFromPath = Hop.Out.outputFromPath --------------------------------------- -- WORK WITH ADDRESS --------------------------------------- {-| Get the path as a string from an Address record. address = { path = ["languages", "1" ], query = Dict.singleton "k" "1" } pathFromAddress address --> "/languages/1" -} pathFromAddress : Address -> String pathFromAddress = Hop.Address.getPath {-| Get the query as a string from an Address record. address = { path = ["app"], query = Dict.singleton "k" "1" } queryFromAddress address --> "?k=1" -} queryFromAddress : Address -> String queryFromAddress = Hop.Address.getQuery ------------------------------------------------------------------------------- -- QUERY MUTATION ------------------------------------------------------------------------------- {-| Add query string values (patches any existing values) to an Address record. addQuery query address addQuery (Dict.Singleton "b" "2") { path = [], query = Dict.fromList [("a", "1")] } == { path = [], query = Dict.fromList [("a", "1"), ("b", "2")] } - query is a dictionary with keys to add To remove a key / value pair set the value to "" -} addQuery : Query -> Address -> Address addQuery query location = let updatedQuery = Dict.union query location.query in { location | query = updatedQuery } {-| Set the whole query string (removes any existing values). setQuery query address -} setQuery : Query -> Address -> Address setQuery query location = { location | query = query } {-| Remove one key from the query string removeQuery key address -} removeQuery : String -> Address -> Address removeQuery key location = let updatedQuery = Dict.remove key location.query in { location | query = updatedQuery } {-| Clear all query string values clearQuery address -} clearQuery : Address -> Address clearQuery location = { location | query = Dict.empty } ================================================ FILE: src/HopTest.elm ================================================ module HopTest exposing (..) import Expect import Test exposing (..) import Hop.TestHelper exposing (configWithHash, configWithPath, configPathAndBasePath) import Hop makeResolverTest : Test makeResolverTest = let inputs = [ ( "path" , configWithPath , "http://example.com/users/1" , "users/1/" ) , ( "path with base" , configPathAndBasePath , "http://example.com/app/v1/users/1" , "users/1/" ) , ( "path" , configWithHash , "http://example.com/app#/users/1" , "users/1/" ) ] run ( testCase, config, href, expected ) = test testCase <| \() -> let resolver = Hop.makeResolver config identity ( actual, _ ) = resolver href in Expect.equal expected actual in describe "makeResolver" (List.map run inputs) all : Test all = describe "Hop" [ makeResolverTest ] ================================================ FILE: tests/IntegrationTest.elm ================================================ module IntegrationTest exposing (..) import UrlParser exposing ((), oneOf, int, s) import Navigation exposing (Location) import Expect import String import Test exposing (..) import Hop.TestHelper exposing (configWithHash, configWithPath, configPathAndBasePath) import Hop import Hop.Types exposing (Address, Config) type alias UserId = Int type UserRoute = UsersRoute | UserRoute UserId | UserEditRoute UserId type MainRoute = HomeRoute | AboutRoute | UsersRoutes UserRoute | NotFoundRoute usersMatchers = [ UrlParser.format UserEditRoute (int s "edit") , UrlParser.format UserRoute (int) , UrlParser.format UsersRoute (s "") ] mainMatchers = [ UrlParser.format HomeRoute (s "") , UrlParser.format AboutRoute (s "about") , UrlParser.format UsersRoutes (s "users" (oneOf usersMatchers)) ] routes = oneOf mainMatchers newLocation : Location newLocation = { hash = "" , host = "example.com" , hostname = "example.com" , href = "" , origin = "" , password = "" , pathname = "" , port_ = "" , protocol = "http" , search = "" , username = "" } parseWithUrlParser : Config -> Location -> ( MainRoute, Address ) parseWithUrlParser currentConfig = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute in .href >> Hop.makeResolver currentConfig parse ------------------------------ -- Example urlParsers ------------------------------ urlParserRouteAddress : Navigation.Parser ( MainRoute, Address ) urlParserRouteAddress = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver) urlParserOnlyRoute : Navigation.Parser MainRoute urlParserOnlyRoute = let parse path = path |> UrlParser.parse identity routes |> Result.withDefault NotFoundRoute solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver >> fst) urlParserResultAddress : Navigation.Parser (Result String MainRoute, Address) urlParserResultAddress = let parse path = path |> UrlParser.parse identity routes solver = Hop.makeResolver configWithHash parse in Navigation.makeParser (.href >> solver) urlParserIntegrationTest : Test urlParserIntegrationTest = let inputs = [ ( "Home page" , configWithPath , "http://example.com" , HomeRoute , "/" ) , ( "Base: Home page" , configPathAndBasePath , "http://example.com/app/v1" , HomeRoute , "/app/v1" ) , ( "Hash: Home page with /#" , configWithHash , "http://example.com/#" , HomeRoute , "#/" ) , ( "Hash: Home page with /#/" , configWithHash , "http://example.com/#/" , HomeRoute , "#/" ) , ( "Hash: Home page without hash" , configWithHash , "http://example.com" , HomeRoute , "#/" ) , ( "Hash: Home page" , configWithHash , "http://example.com/index.html" , HomeRoute , "#/" ) -- about , ( "AboutRoute" , configWithPath , "http://example.com/about" , AboutRoute , "/about" ) , ( "Base: AboutRoute" , configPathAndBasePath , "http://example.com/app/v1/about" , AboutRoute , "/app/v1/about" ) , ( "Hash: AboutRoute" , configWithHash , "http://example.com/#/about" , AboutRoute , "#/about" ) , ( "Hash: AboutRoute with slash" , configWithHash , "http://example.com/app#/about" , AboutRoute , "#/about" ) -- users , ( "UsersRoute" , configWithPath , "http://example.com/users" , UsersRoutes UsersRoute , "/users" ) , ( "Base: UsersRoute" , configPathAndBasePath , "http://example.com/app/v1/users" , UsersRoutes UsersRoute , "/app/v1/users" ) , ( "Hash: UsersRoute" , configWithHash , "http://example.com/#/users" , UsersRoutes UsersRoute , "#/users" ) -- users with query , ( "UsersRoute" , configWithPath , "http://example.com/users?k=1" , UsersRoutes UsersRoute , "/users?k=1" ) , ( "Base: UsersRoute" , configPathAndBasePath , "http://example.com/app/v1/users?k=1" , UsersRoutes UsersRoute , "/app/v1/users?k=1" ) , ( "Hash: UsersRoute" , configWithHash , "http://example.com/#/users?k=1" , UsersRoutes UsersRoute , "#/users?k=1" ) -- user , ( "UserRoute" , configWithPath , "http://example.com/users/2" , UsersRoutes (UserRoute 2) , "/users/2" ) , ( "Base: UserRoute" , configPathAndBasePath , "http://example.com/app/v1/users/2" , UsersRoutes (UserRoute 2) , "/app/v1/users/2" ) , ( "Hash: UserRoute" , configWithHash , "http://example.com/#/users/2" , UsersRoutes (UserRoute 2) , "#/users/2" ) -- user edit , ( "UserRoute" , configWithPath , "http://example.com/users/2/edit" , UsersRoutes (UserEditRoute 2) , "/users/2/edit" ) , ( "Base: UserRoute" , configPathAndBasePath , "http://example.com/app/v1/users/2/edit" , UsersRoutes (UserEditRoute 2) , "/app/v1/users/2/edit" ) , ( "Hash: UserRoute" , configWithHash , "http://example.com/#/users/2/edit" , UsersRoutes (UserEditRoute 2) , "#/users/2/edit" ) ] run ( testCase, currentConfig, href, expected, expectedRoundTrip ) = [ test testCase <| \() -> let location = { newLocation | href = href } ( actual, _ ) = parseWithUrlParser currentConfig location in Expect.equal expected actual , test (testCase ++ " - output") <| \() -> let location = { newLocation | href = href } ( _, address ) = parseWithUrlParser currentConfig location actual = Hop.output currentConfig address in Expect.equal expectedRoundTrip actual ] in describe "UrlParser integration" (List.concatMap run inputs) all : Test all = describe "Integration" [ urlParserIntegrationTest ] ================================================ FILE: tests/Main.elm ================================================ port module Main exposing (..) import Tests import Test.Runner.Node exposing (run) import Json.Encode exposing (Value) main : Program Value main = run emit Tests.all port emit : ( String, Value ) -> Cmd msg ================================================ FILE: tests/Tests.elm ================================================ module Tests exposing (..) import HopTest import Hop.AddressTest import Hop.InTest import Hop.OutTest import IntegrationTest import Test exposing (..) all : Test all = describe "Hop" [ HopTest.all , Hop.AddressTest.all , Hop.InTest.all , Hop.OutTest.all , IntegrationTest.all ] ================================================ FILE: tests/elm-package.json ================================================ { "version": "0.0.1", "summary": "A router for SPAs in Elm", "repository": "https://github.com/sporto/hop.git", "license": "MIT", "source-directories": [ ".", "../src" ], "exposed-modules": [], "dependencies": { "elm-community/elm-test": "2.1.0 <= v < 3.0.0", "elm-lang/core": "4.0.0 <= v < 5.0.0", "elm-lang/navigation": "1.0.0 <= v < 2.0.0", "evancz/elm-http": "3.0.1 <= v < 4.0.0", "evancz/url-parser": "1.0.0 <= v < 2.0.0", "rtfeldman/node-test-runner": "2.0.0 <= v < 3.0.0" }, "elm-version": "0.16.0 <= v < 0.18.0" } ================================================ FILE: tests/install-packages.sh ================================================ #!/bin/bash n=0 until [ $n -ge 5 ] do elm package install -y && break n=$[$n+1] sleep 15 done ================================================ FILE: tests/package.json ================================================ { "name": "test", "version": "1.0.0", "description": "", "main": "elm.js", "scripts": { "test": "elm-test" }, "author": "", "license": "ISC", "dependencies": { "elm-test": "^0.17.3" } }