├── .editorconfig
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── bower.json
├── package-lock.json
├── package.json
├── packages.dhall
├── spago.dhall
└── src
└── Web
├── Router.purs
└── Router
├── Hash.purs
├── Internal
├── Control.purs
└── Types.purs
└── PushState.purs
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 | end_of_line = lf
10 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: push
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 |
11 | - uses: actions/setup-node@v3
12 |
13 | - uses: thomashoneyman/setup-purescript@main
14 | with:
15 | purescript: "0.15.4"
16 | spago: "0.20.9"
17 |
18 | - name: Cache PureScript dependencies
19 | uses: actions/cache@v2
20 | with:
21 | key: ${{ runner.os }}-spago-${{ hashFiles('**/*.dhall') }}
22 | path: |
23 | .spago
24 | output
25 |
26 | - run: npm run -s build
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /bower_components/
2 | /node_modules/
3 | /.pulp-cache/
4 | /output/
5 | /generated-docs/
6 | /.psc-package/
7 | /.psc*
8 | /.purs*
9 | /.psa*
10 | /.spago
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "purescript.addNpmPath": true,
3 | "purescript.addPscPackageSources": true,
4 | "purescript.addSpagoSources": true,
5 | "purescript.buildCommand": "spago build -- --json-errors",
6 | "purescript.formatter": "purs-tidy",
7 | "[purescript]": {
8 | "editor.formatOnSave": true
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # purescript-web-router
2 |
3 | A router for browsers that supports asynchronous routing logic. Bring your own printing and parsing (check out [routing-duplex](https://github.com/natefaubion/purescript-routing-duplex)).
4 |
5 | For a basic React example see [here](https://github.com/robertdp/purescript-web-router-example/tree/master/src).
6 |
7 | ## How to use
8 |
9 | ### 1. Install with Spago
10 |
11 | `$ spago install web-router`
12 |
13 | ### 2. Define your routes
14 |
15 | ```purescript
16 | data Route
17 | = Page Page
18 | | NotFound
19 |
20 | data Page
21 | = Home
22 | | ProductList
23 | | ProductView ProductId
24 | | About
25 | | ContactUs
26 |
27 | type ProductId = Int
28 | ```
29 |
30 | ### 3. Implement parsing and printing
31 |
32 | This example uses [routing-duplex](https://github.com/natefaubion/purescript-routing-duplex).
33 |
34 |
35 | Imports
36 |
37 | ```purescript
38 | import Prelude hiding ((/))
39 | import Data.Either (Either)
40 | import Data.Generic.Rep (class Generic)
41 | import Routing.Duplex (RouteDuplex', default, end, int, parse, print, root, segment)
42 | import Routing.Duplex.Generic (noArgs, sum)
43 | import Routing.Duplex.Generic.Syntax ((/))
44 | import Routing.Duplex.Parser (RouteError)
45 | ```
46 |
47 |
48 |
49 | ```purescript
50 | derive instance Generic Route _
51 | derive instance Generic Page _
52 |
53 | productId :: RouteDuplex' ProductId
54 | productId = int segment
55 |
56 | routes :: RouteDuplex' Route
57 | routes =
58 | default NotFound $
59 | sum
60 | { "Page": pages
61 | , "NotFound": "404" / noArgs
62 | }
63 |
64 | pages :: RouteDuplex' Page
65 | pages =
66 | root $ end $
67 | sum
68 | { "Home": noArgs
69 | , "ProductList": "products" / noArgs
70 | , "ProductView": "products" / productId
71 | , "About": "about" / noArgs
72 | , "ContactUs": "about" / noArgs
73 | }
74 |
75 | -- | This is the route parser we need to pass to the driver.
76 | -- | It can produce any route which allows the parser to return a value of `NotFound` instead of failing.
77 | parseRoute :: forall String -> Either RouteError Route
78 | parseRoute = parse routes
79 |
80 | -- | This is the route printer we need to pass to the driver.
81 | -- | It can only print paths to valid pages, which means a path can't be produced for the `NotFound` route.
82 | -- | With this approach routes can be seperated based on whether they should be a navigation target and have a URL.
83 | -- | Note: assymetry is not required, and a symmetrical printer works as well.
84 | printRoute :: Page -> String
85 | printRoute = print pages
86 | ```
87 |
88 | ### 4. Define how your application reacts to navigation and routing events
89 |
90 |
91 | Imports
92 |
93 | ```purescript
94 | import Web.Router as Router
95 | ```
96 |
97 |
98 |
99 | ```purescript
100 | onNavigation :: Maybe Route -> Route -> Router.RouterM Route Page Router.Routing Router.Resolved Unit
101 | onNavigation previousRoute requestedRoute =
102 | case requestedRoute of
103 | NotFound ->
104 | case previousRoute of
105 | Just (Page page) -> Router.do
106 | liftEffect showBrokenNavigationMessage
107 | Router.redirect page -- redirect back to the previous page and show a message
108 | _ ->
109 | Router.continue -- no previous page, so just show the "not found" page
110 | _ -> Router.do
111 | access <- liftAff fetchUserAccess
112 | if userHasAccess requestedRoute access then
113 | Router.continue -- they have access, so resolve with the requested page
114 | else
115 | Router.override NotFound -- no access, so pretend the page doesn't exist
116 |
117 |
118 | onEvent :: Router.RoutingEvent Route -> Effect Unit
119 | onEvent newEvent =
120 | case newEvent of
121 | Router.Routing previousRoute requestedRoute ->
122 | showNavigationSpinner
123 | Router.Resolved previousRoute newRoute ->
124 | hideNavigationSpinner
125 | setCurrentRoute newRoute
126 | ```
127 |
128 | ### 5. Connect up the driver and router
129 |
130 |
131 | Imports
132 |
133 | ```purescript
134 | import Web.Router as Router
135 | import Web.Router.PushState as PushState
136 | ```
137 |
138 |
139 |
140 | ```purescript
141 | mkRouter :: Effect (Router.Router Route Page)
142 | mkRouter = do
143 | driver <- PushState.mkInterface parseRoute printRoute
144 | router <- Router.mkInterface onNavigation onEvent driver
145 | pure router
146 | ```
147 |
148 | Both pushstate and hash drivers are included, or a custom driver can be implemented. An example of a custom driver could be one that synchronises some navigation state over sockets, for an experience where one user's behaviour could be broadcast to multiple users to follow along.
149 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "purescript-web-router",
3 | "license": [
4 | "BSD-3-Clause"
5 | ],
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/robertdp/purescript-web-router.git"
9 | },
10 | "ignore": [
11 | "**/.*",
12 | "node_modules",
13 | "bower_components",
14 | "output"
15 | ],
16 | "dependencies": {
17 | "purescript-aff": "^v7.0.0",
18 | "purescript-effect": "^v4.0.0",
19 | "purescript-foldable-traversable": "^v6.0.0",
20 | "purescript-foreign": "^v7.0.0",
21 | "purescript-freet": "^v7.0.0",
22 | "purescript-indexed-monad": "^v2.1.0",
23 | "purescript-maybe": "^v6.0.0",
24 | "purescript-prelude": "^v6.0.0",
25 | "purescript-profunctor-lenses": "^v8.0.0",
26 | "purescript-refs": "^v6.0.0",
27 | "purescript-routing": "^v11.0.0",
28 | "purescript-type-equality": "^v4.0.1"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "purescript-web-router",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "spago build",
8 | "postinstall": "spago install"
9 | },
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "bower": "^1.8.14",
14 | "pulp": "^16.0.2",
15 | "purescript": "^0.15.4",
16 | "purescript-psa": "^0.8.2",
17 | "purs-tidy": "^0.9.0",
18 | "spago": "^0.20.9"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages.dhall:
--------------------------------------------------------------------------------
1 | let upstream =
2 | https://github.com/purescript/package-sets/releases/download/psc-0.15.4-20220718/packages.dhall
3 | sha256:a6d66723b6109f1e3eaf6575910f1c51aa545965ce313024ba329360e2f009ac
4 |
5 | in upstream
6 |
--------------------------------------------------------------------------------
/spago.dhall:
--------------------------------------------------------------------------------
1 | { name = "web-router"
2 | , license = "BSD-3-Clause"
3 | , repository = "https://github.com/robertdp/purescript-web-router.git"
4 | , dependencies =
5 | [ "aff"
6 | , "effect"
7 | , "foldable-traversable"
8 | , "foreign"
9 | , "freet"
10 | , "indexed-monad"
11 | , "maybe"
12 | , "prelude"
13 | , "profunctor-lenses"
14 | , "refs"
15 | , "routing"
16 | , "type-equality"
17 | ]
18 | , packages = ./packages.dhall
19 | , sources = [ "src/**/*.purs" ]
20 | }
21 |
--------------------------------------------------------------------------------
/src/Web/Router.purs:
--------------------------------------------------------------------------------
1 | module Web.Router
2 | ( module Control
3 | , module Ix
4 | , module Types
5 | , mkInterface
6 | ) where
7 |
8 | import Prelude
9 | import Control.Monad.Indexed.Qualified (apply, bind, discard, map, pure) as Ix
10 | import Data.Foldable (for_)
11 | import Data.Maybe (Maybe(..))
12 | import Effect (Effect)
13 | import Effect.Aff (Aff, error, killFiber, launchAff, launchAff_)
14 | import Effect.Class (liftEffect)
15 | import Effect.Ref as Ref
16 | import Web.Router.Internal.Control (Resolved, RouterIndex, RouterM, Routing, continue, override, redirect) as Control
17 | import Web.Router.Internal.Control (RouterCommand(..), Resolved, RouterM, Routing, runRouter)
18 | import Web.Router.Internal.Types (DriverInterface, DriverInterface', RouterEvent(..), RouterInterface, _Resolved, _RouterEvent, _Routing, isResolved, isRouting) as Types
19 | import Web.Router.Internal.Types (DriverInterface, RouterEvent(..), RouterInterface)
20 |
21 | mkInterface
22 | :: forall i o
23 | . (Maybe i -> i -> RouterM i o Routing Resolved Unit)
24 | -> (RouterEvent i -> Effect Unit)
25 | -> DriverInterface i o
26 | -> Effect (RouterInterface o)
27 | mkInterface onNavigation onEvent driver = do
28 | lastFiberRef <- Ref.new Nothing
29 | lastEventRef <- Ref.new Nothing
30 | let
31 | readPreviousRoute :: Effect (Maybe i)
32 | readPreviousRoute =
33 | Ref.read lastEventRef <#>
34 | case _ of
35 | Just (Resolved _ route) -> Just route
36 | Just (Routing (Just route) _) -> Just route
37 | _ -> Nothing
38 |
39 | handleEvent :: RouterEvent i -> Effect Unit
40 | handleEvent event = do
41 | Ref.write (Just event) lastEventRef
42 | onEvent event
43 |
44 | onCommand :: forall a. i -> RouterCommand i o a -> Aff Unit
45 | onCommand newRoute cmd =
46 | liftEffect do
47 | previousRoute <- readPreviousRoute
48 | case cmd of
49 | Continue -> handleEvent $ Resolved previousRoute newRoute
50 | Override route -> handleEvent $ Resolved previousRoute route
51 | Redirect route -> driver.redirect route
52 |
53 | runRouter' :: i -> Effect Unit
54 | runRouter' newRoute = do
55 | lastFiber <- Ref.read lastFiberRef
56 | for_ lastFiber $ killFiber (error "Killing previous routing fiber") >>> launchAff_
57 | previousRoute <- readPreviousRoute
58 | handleEvent $ Routing previousRoute newRoute
59 | newFiber <-
60 | onNavigation previousRoute newRoute
61 | # runRouter (onCommand newRoute)
62 | # launchAff
63 | Ref.write (Just newFiber) lastFiberRef
64 | pure
65 | { initialize: driver.initialize runRouter'
66 | , navigate: driver.navigate
67 | , redirect: driver.redirect
68 | }
69 |
--------------------------------------------------------------------------------
/src/Web/Router/Hash.purs:
--------------------------------------------------------------------------------
1 | module Web.Router.Hash where
2 |
3 | import Prelude
4 | import Data.Foldable (class Foldable)
5 | import Routing.Hash as Hash
6 | import Web.Router.Internal.Types (DriverInterface)
7 |
8 | mkInterface :: forall f i o. Foldable f => (String -> f i) -> (o -> String) -> DriverInterface i o
9 | mkInterface parser printer =
10 | { initialize: Hash.matchesWith parser <<< const
11 | , navigate: Hash.setHash <<< printer
12 | , redirect: Hash.setHash <<< printer
13 | }
14 |
--------------------------------------------------------------------------------
/src/Web/Router/Internal/Control.purs:
--------------------------------------------------------------------------------
1 | module Web.Router.Internal.Control where
2 |
3 | import Prelude
4 |
5 | import Control.Applicative.Indexed (class IxApplicative, iapply, imap, ipure)
6 | import Control.Apply.Indexed (class IxApply)
7 | import Control.Bind.Indexed (class IxBind, ibind)
8 | import Control.Monad.Free.Trans (FreeT, liftFreeT, runFreeT)
9 | import Control.Monad.Indexed (class IxMonad, iap)
10 | import Data.Functor.Indexed (class IxFunctor)
11 | import Effect.Aff (Aff)
12 | import Effect.Aff.Class (class MonadAff, liftAff)
13 | import Effect.Class (class MonadEffect, liftEffect)
14 | import Type.Equality (class TypeEquals)
15 |
16 | data RouterCommand :: Type -> Type -> Type -> Type
17 | data RouterCommand i o a
18 | = Continue
19 | | Override i
20 | | Redirect o
21 |
22 | derive instance Functor (RouterCommand i o)
23 |
24 | data RouterIndex
25 |
26 | foreign import data Routing :: RouterIndex
27 | foreign import data Resolved :: RouterIndex
28 |
29 | newtype RouterM :: Type -> Type -> RouterIndex -> RouterIndex -> Type -> Type
30 | newtype RouterM i o x y a = RouterM (FreeT (RouterCommand i o) Aff a)
31 |
32 | instance IxFunctor (RouterM i o) where
33 | imap f (RouterM r) = RouterM (map f r)
34 |
35 | instance IxApply (RouterM i o) where
36 | iapply = iap
37 |
38 | instance IxApplicative (RouterM i o) where
39 | ipure a = RouterM (pure a)
40 |
41 | instance IxBind (RouterM i o) where
42 | ibind (RouterM r) f = RouterM (r >>= \a -> case f a of RouterM r' -> r')
43 |
44 | instance IxMonad (RouterM i o)
45 |
46 | instance TypeEquals Routing x => Functor (RouterM i o x x) where
47 | map = imap
48 |
49 | instance TypeEquals Routing x => Apply (RouterM i o x x) where
50 | apply = iapply
51 |
52 | instance TypeEquals Routing x => Applicative (RouterM i o x x) where
53 | pure = ipure
54 |
55 | instance TypeEquals Routing x => Bind (RouterM i o x x) where
56 | bind = ibind
57 |
58 | instance TypeEquals Routing x => Monad (RouterM i o x x)
59 |
60 | instance TypeEquals Routing x => MonadEffect (RouterM i o x x) where
61 | liftEffect eff = RouterM (liftEffect eff)
62 |
63 | instance TypeEquals Routing x => MonadAff (RouterM i o x x) where
64 | liftAff aff = RouterM (liftAff aff)
65 |
66 | runRouter :: forall i o. (forall a. RouterCommand i o a -> Aff Unit) -> RouterM i o Routing Resolved Unit -> Aff Unit
67 | runRouter handleCmd (RouterM router) = runFreeT (\cmd -> handleCmd cmd *> mempty) router
68 |
69 | continue :: forall i o. RouterM i o Routing Resolved Unit
70 | continue = RouterM (liftFreeT Continue)
71 |
72 | override :: forall i o. i -> RouterM i o Routing Resolved Unit
73 | override route = RouterM (liftFreeT (Override route))
74 |
75 | redirect :: forall i o. o -> RouterM i o Routing Resolved Unit
76 | redirect route = RouterM (liftFreeT (Redirect route))
77 |
--------------------------------------------------------------------------------
/src/Web/Router/Internal/Types.purs:
--------------------------------------------------------------------------------
1 | module Web.Router.Internal.Types where
2 |
3 | import Prelude
4 | import Data.Lens (Lens', Prism', is, lens, prism')
5 | import Data.Maybe (Maybe(..))
6 | import Effect (Effect)
7 |
8 | type RouterInterface route =
9 | { initialize :: Effect (Effect Unit)
10 | , navigate :: route -> Effect Unit
11 | , redirect :: route -> Effect Unit
12 | }
13 |
14 | type DriverInterface i o =
15 | { initialize :: (i -> Effect Unit) -> Effect (Effect Unit)
16 | , navigate :: o -> Effect Unit
17 | , redirect :: o -> Effect Unit
18 | }
19 |
20 | type DriverInterface' route = DriverInterface route route
21 |
22 | data RouterEvent route
23 | = Routing (Maybe route) route
24 | | Resolved (Maybe route) route
25 |
26 | derive instance Eq route => Eq (RouterEvent route)
27 |
28 | _RouterEvent :: forall route. Lens' (RouterEvent route) route
29 | _RouterEvent = lens getter setter
30 | where
31 | getter = case _ of
32 | Routing _ route -> route
33 | Resolved _ route -> route
34 |
35 | setter = case _ of
36 | Routing route _ -> Routing route
37 | Resolved route _ -> Resolved route
38 |
39 | _Routing :: forall route. Prism' (RouterEvent route) route
40 | _Routing =
41 | prism' (Routing Nothing) case _ of
42 | Routing _ route -> Just route
43 | _ -> Nothing
44 |
45 | _Resolved :: forall route. Prism' (RouterEvent route) route
46 | _Resolved =
47 | prism' (Resolved Nothing) case _ of
48 | Resolved _ route -> Just route
49 | _ -> Nothing
50 |
51 | isRouting :: forall route. RouterEvent route -> Boolean
52 | isRouting = is _Routing
53 |
54 | isResolved :: forall route. RouterEvent route -> Boolean
55 | isResolved = is _Resolved
56 |
--------------------------------------------------------------------------------
/src/Web/Router/PushState.purs:
--------------------------------------------------------------------------------
1 | module Web.Router.PushState where
2 |
3 | import Prelude
4 | import Data.Foldable (class Foldable)
5 | import Effect (Effect)
6 | import Foreign (unsafeToForeign)
7 | import Routing.PushState (PushStateInterface)
8 | import Routing.PushState as PushState
9 | import Web.Router.Internal.Types (DriverInterface)
10 |
11 | mkInterface :: forall f i o. Foldable f => (String -> f i) -> (o -> String) -> Effect (DriverInterface i o)
12 | mkInterface parser printer = mkInterface_ parser printer <$> PushState.makeInterface
13 |
14 | mkInterface_ :: forall f i o. Foldable f => (String -> f i) -> (o -> String) -> PushStateInterface -> DriverInterface i o
15 | mkInterface_ parser printer interface =
16 | { initialize: \k -> PushState.matchesWith parser (\_ -> k) interface
17 | , navigate: interface.pushState (unsafeToForeign {}) <<< printer
18 | , redirect: interface.replaceState (unsafeToForeign {}) <<< printer
19 | }
20 |
--------------------------------------------------------------------------------