You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

107 lines
4.4 KiB

  1. package outwatch.router
  2. import cats.effect.Sync
  3. import colibri.Observable
  4. import org.scalajs.dom.window
  5. import outwatch._
  6. import outwatch.dsl._
  7. import outwatch.util.Reducer
  8. import outwatch.util.Store
  9. sealed trait Action
  10. final case class Replace(path: Path) extends Action
  11. final case class HistoryEvent(path: Path) extends Action
  12. final case class RouterState[P](page: P)
  13. /** An AppRouter handles parsing of URLs and mapping to pages of the given type.
  14. * @param siteRoot - The prefix part of a pathname, or the subpath at which your site is applied.
  15. * Usually this is just Root, but your site might need a prefix as in /my_site/[parsed pathname]
  16. * @param parent - The parent path at which this router is mounted. You can have routers contained in subroots of your site.
  17. * @param f - a mapping function from a Path to a page P.
  18. * @tparam F - the effect type
  19. * @tparam P - Your page type, such as a sealed trait root type.
  20. */
  21. class AppRouter[F[_]: Sync, P](siteRoot: Path, parent: Path, f: Path => P) {
  22. // Sync from the required page to the window.location
  23. def routerReducer(state: RouterState[P], action: Action): RouterState[P] =
  24. action match {
  25. case Replace(path) =>
  26. window.history.pushState("", "", Path(siteRoot, Path(parent, path)).toUrlString)
  27. state.copy(page = f(path))
  28. case HistoryEvent(path) =>
  29. state.copy(page = f(path))
  30. case _ => state
  31. }
  32. def store: F[RouterStore[P]] = {
  33. val startingPath = Path(window.location.pathname)
  34. Store.create[F, Action, RouterState[P]](
  35. Replace(startingPath),
  36. RouterState(f(startingPath)),
  37. Reducer(routerReducer _),
  38. )
  39. }
  40. def link(linkHref: String)(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
  41. a(href := linkHref)(
  42. onClick.preventDefault.useLazy(Replace(Path(linkHref))) --> store.sink,
  43. attrs,
  44. )
  45. def render(resolver: RouterResolve[P])(implicit store: RouterStore[P]): Observable[VDomModifier] =
  46. store.map { case (_, RouterState(p)) => resolver(p) }
  47. def watch()(implicit store: RouterStore[P]) =
  48. emitter(outwatch.dsl.events.window.onPopState)
  49. .useLazy(HistoryEvent(Path(org.scalajs.dom.window.location.pathname))) --> store.sink
  50. }
  51. object AppRouter {
  52. def render[P](resolver: RouterResolve[P])(implicit
  53. store: RouterStore[P],
  54. ): Observable[VDomModifier] = store.map { case (_, RouterState(p)) => resolver(p) }
  55. def watch[P]()(implicit store: RouterStore[P]) = emitter(
  56. outwatch.dsl.events.window.onPopState,
  57. ).useLazy(HistoryEvent(Path(org.scalajs.dom.window.location.pathname))) --> store.sink
  58. def create[F[_]: Sync, P](notFound: P)(f: PartialFunction[Path, P]): AppRouter[F, P] =
  59. create[F, P](Root, notFound)(f)
  60. def create[F[_]: Sync, P](parent: Path, notFound: P)(
  61. f: PartialFunction[Path, P],
  62. ): AppRouter[F, P] = new AppRouter[F, P](Root, parent, f.lift.andThen(_.getOrElse(notFound)))
  63. def createParseSiteRoot[F[_]: Sync, P](notFound: P)(
  64. f: PartialFunction[Path, P],
  65. ): AppRouter[F, P] = createParseSiteRoot[F, P](Root, notFound)(f)
  66. /** Automatically determine what siteroot we're using, based on the current URL and expected parent.
  67. * For example, your site could be deployed at /example/directory/, your router root path could be /names,
  68. * and the current url could be /example/directory/names/alice. So given the call:
  69. * createParseSubRoot[Page](Path("/names"), NotFound)(f), the router will work out that the window location
  70. * prefix must be /example/directory/names, and will handle actions such as Replace(Path("/names/bob"))
  71. * @param parent the parent for this router, another path perhaps managed by another router
  72. * @param notFound - the default case page assignment
  73. * @param f - a router function from Path to instances of your page type
  74. * @tparam P - your page type
  75. */
  76. def createParseSiteRoot[F[_]: Sync, P](parent: Path, notFound: P)(
  77. f: PartialFunction[Path, P],
  78. ): AppRouter[F, P] = {
  79. val initUrl = window.location.pathname
  80. // url is of form /sra/srb/src/pa/pb/pc...
  81. // so just drop the parent part from the right of the url if it exists.
  82. val siteRoot = initUrl.lastIndexOf(parent.toString) match {
  83. case x if x < 1 => Root
  84. case x => Path(initUrl.substring(0, x))
  85. }
  86. val routerFun: Path => P = f.lift.andThen(_.getOrElse(notFound))
  87. new AppRouter[F, P](siteRoot, parent, routerFun)
  88. }
  89. }