diff --git a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala index e616f04e..570208ab 100644 --- a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala +++ b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala @@ -19,10 +19,18 @@ object IndigoSandbox extends TyrianApp[Msg, Model]: val gameId1: IndigoGameId = IndigoGameId("reverse") val gameId2: IndigoGameId = IndigoGameId("combine") + def router: Location => Msg = Routing.externalOnly(Msg.NoOp, Msg.FollowLink(_)) + def init(flags: Map[String, String]): (Model, Cmd[Task, Msg]) = (Model.init, Cmd.Emit(Msg.StartIndigo)) def update(model: Model): Msg => (Model, Cmd[Task, Msg]) = + case Msg.NoOp => + (model, Cmd.None) + + case Msg.FollowLink(href) => + (model, Nav.loadUrl(href)) + case Msg.NewRandomInt(i) => (model.copy(randomNumber = i), Cmd.None) @@ -90,6 +98,11 @@ object IndigoSandbox extends TyrianApp[Msg, Model]: div( div(hidden(false))("Random number: " + model.randomNumber.toString), + div( + a(href := "/another-page")("Internal link (will be ignored)"), + br, + a(href := "http://tyrian.indigoengine.io/")("Tyrian website") + ), div(id := gameDivId1)(), div(id := gameDivId2)(), div( @@ -122,13 +135,15 @@ object IndigoSandbox extends TyrianApp[Msg, Model]: ) enum Msg: - case NewContent(content: String) extends Msg - case Insert extends Msg - case Remove extends Msg - case Modify(i: Int, msg: Counter.Msg) extends Msg - case StartIndigo extends Msg - case IndigoReceive(msg: String) extends Msg - case NewRandomInt(i: Int) extends Msg + case NewContent(content: String) + case Insert + case Remove + case Modify(i: Int, msg: Counter.Msg) + case StartIndigo + case IndigoReceive(msg: String) + case NewRandomInt(i: Int) + case FollowLink(href: String) + case NoOp object Counter: diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 054a619e..5625ce92 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -13,12 +13,32 @@ import tyrian.websocket.* import scala.concurrent.duration.* import scala.scalajs.js.annotation.* +import scala.util.Random import scalajs.js @JSExportTopLevel("TyrianApp") object Sandbox extends TyrianApp[Msg, Model]: + // Here we just do a simple string match, but this could be a route matching + // lib like: https://github.com/sherpal/url-dsl + def router: Location => Msg = + case loc: Location.Internal => + loc.pathName match + case "/" => Msg.NavigateTo(Page.Page1) + case "/page1" => Msg.NavigateTo(Page.Page1) + case "/page2" => Msg.NavigateTo(Page.Page2) + case "/page3" => Msg.NavigateTo(Page.Page3) + case "/page4" => Msg.NavigateTo(Page.Page4) + case "/page5" => Msg.NavigateTo(Page.Page5) + case "/page6" => Msg.NavigateTo(Page.Page6) + case _ => + println("Unknown route: " + loc.url) + Msg.NoOp + + case loc: Location.External => + Msg.NavigateToUrl(loc.href) + val hotReloadKey: String = "hotreload" def init(flags: Map[String, String]): (Model, Cmd[IO, Msg]) = @@ -29,10 +49,6 @@ object Sandbox extends TyrianApp[Msg, Model]: case Right(model) => Msg.OverwriteModel(model) }, Logger.info(flags.toString), - Navigation.getLocationHash { - case Navigation.Result.CurrentHash(hash) => Msg.NavigateTo(Page.fromString(hash)) - case _ => Msg.NavigateTo(Page.Page1) - }, LocalStorage.key(0) { case LocalStorage.Result.Key(key) => Msg.Log("Found local storage key: " + key) case _ => Msg.Log("No local storage enties found.") @@ -45,6 +61,9 @@ object Sandbox extends TyrianApp[Msg, Model]: (Model.init, cmds) def update(model: Model): Msg => (Model, Cmd[IO, Msg]) = + case Msg.NoOp => + (model, Cmd.None) + case Msg.AddFruit => (model.copy(fruit = Fruit(model.fruitInput, false) :: model.fruit), Cmd.None) @@ -187,11 +206,14 @@ object Sandbox extends TyrianApp[Msg, Model]: (model.copy(tmpSaveData = content), Cmd.None) case Msg.JumpToHomePage => - (model, Navigation.setLocationHash(Page.Page1.toHash)) + (model.copy(page = Page.Page1), Nav.pushUrl(Page.Page1.toUrlPath)) case Msg.NavigateTo(page) => (model.copy(page = page), Cmd.None) + case Msg.NavigateToUrl(href) => + (model, Nav.loadUrl(href)) + case Msg.TakeSnapshot => (model, HotReload.snapshot(hotReloadKey, model, Model.encode)) @@ -294,8 +316,19 @@ object Sandbox extends TyrianApp[Msg, Model]: val navItems = Page.values.toList.map { pg => if pg == model.page then li(style := CSS.`font-family`("sans-serif"))(pg.toNavLabel) - else li(style := CSS.`font-family`("sans-serif"))(a(href := pg.toHash)(pg.toNavLabel)) - } + else + li(style := CSS.`font-family`("sans-serif")) { + a(href := pg.toUrlPath)(pg.toNavLabel) + } + } ++ + List( + li(style := CSS.`font-family`("sans-serif")) { + a(href := "#foo" + Random.nextInt())("Random link") + }, + li(style := CSS.`font-family`("sans-serif")) { + a(href := "https://tyrian.indigoengine.io/")("Tyrian's Website") + } + ) val counters = model.components.zipWithIndex.map { case (c, i) => Counter.view(c).map(msg => Msg.Modify(i, msg)) @@ -569,7 +602,6 @@ object Sandbox extends TyrianApp[Msg, Model]: Sub.Batch( webSocketSubs, - Navigation.onLocationHashChange(hashChange => Msg.NavigateTo(Page.fromString(hashChange.newFragment))), simpleSubs, clockSub, Sub.animationFrameTick("frametick") { t => @@ -594,6 +626,7 @@ enum Msg: case ClearStorage(key: String) case DataLoaded(data: String) case NavigateTo(page: Page) + case NavigateToUrl(href: String) case JumpToHomePage case OverwriteModel(model: Model) case TakeSnapshot @@ -617,6 +650,7 @@ enum Msg: case AddFruit case UpdateFruitInput(input: String) case ToggleFruitAvailability(name: String) + case NoOp enum Status: case Connecting @@ -685,29 +719,14 @@ enum Page: case Page5 => "Http" case Page6 => "Form" - def toHash: String = + def toUrlPath: String = this match - case Page1 => "#page1" - case Page2 => "#page2" - case Page3 => "#page3" - case Page4 => "#page4" - case Page5 => "#page5" - case Page6 => "#page6" - -object Page: - def fromString(pageString: String): Page = - pageString match - case "#page2" => Page2 - case "page2" => Page2 - case "#page3" => Page3 - case "page3" => Page3 - case "#page4" => Page4 - case "page4" => Page4 - case "#page5" => Page5 - case "page5" => Page5 - case "#page6" => Page6 - case "page6" => Page6 - case _ => Page1 + case Page1 => "/page1" + case Page2 => "/page2" + case Page3 => "/page3" + case Page4 => "/page4" + case Page5 => "/page5" + case Page6 => "/page6" object Model: // val echoServer = "ws://ws.ifelse.io" // public echo server diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala index 835288f9..89d7501f 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -3,12 +3,9 @@ package tyrian import cats.effect.IO import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global -import org.scalajs.dom.document import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime -import scala.scalajs.js.annotation._ - /** The TyrianApp trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well * as providing a number of standard app launching methods. */ diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index d2791e98..591bda77 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -2,16 +2,12 @@ package tyrian import cats.effect.Async import cats.effect.kernel.Resource -import cats.effect.unsafe.implicits.global -import org.scalajs.dom.document import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime import zio.Runtime import zio.Task import zio.Unsafe -import scala.scalajs.js.annotation._ - /** The TyrianApp trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well * as providing a number of standard app launching methods. */ diff --git a/tyrian/js/src/main/scala/tyrian/Location.scala b/tyrian/js/src/main/scala/tyrian/Location.scala new file mode 100644 index 00000000..8c6da701 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Location.scala @@ -0,0 +1,46 @@ +package tyrian + +import cats.effect.kernel.Async +import cats.effect.kernel.Resource +import org.scalajs.dom.Element +import org.scalajs.dom.PopStateEvent +import org.scalajs.dom.document +import org.scalajs.dom.window + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +sealed trait Location: + def locationDetails: LocationDetails + def isInternal: Boolean + def isExternal: Boolean + def href: String + def url: String + +object Location: + + final case class Internal(locationDetails: LocationDetails) extends Location: + export locationDetails.* + val isInternal: Boolean = true + val isExternal: Boolean = false + + final case class External(locationDetails: LocationDetails) extends Location: + export locationDetails.* + val isInternal: Boolean = false + val isExternal: Boolean = true + + /** Construct a Location from a given url, decides internal / external based on comparison with `currentLocation` + */ + def fromUrl(url: String, currentLocation: Location.Internal): Location = + val ld = LocationDetails.fromUrl(url) + + if ld.protocol.isEmpty then Location.Internal(ld) + else if ld.origin == currentLocation.origin then Location.Internal(ld) + else Location.External(ld) + + /** Location instances created from JS Location's are assumed to be internal links. + */ + def fromJsLocation(location: org.scalajs.dom.Location): Location.Internal = + Location.Internal( + LocationDetails.fromUrl(location.href) + ) diff --git a/tyrian/js/src/main/scala/tyrian/LocationDetails.scala b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala new file mode 100644 index 00000000..4c24384f --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala @@ -0,0 +1,143 @@ +package tyrian + +/** Represents the deconstructed parts of a url. + * + * @param hash + * The anchor in the url starting with '#' followed by the fragment of the URL. + * @param hostName + * The name of host, e.g. localhost. + * @param pathName + * Is the path minus hash anchors and query params, e.g. "/page1". + * @param port + * Is the port number of the URL, e.g. 80. + * @param protocol + * The protocol e.g. https: + * @param search + * Is a String containing a '?' followed by the parameters of the URL. + * @param url + * The whole URL. + */ +final case class LocationDetails( + hash: Option[String], + hostName: Option[String], + pathName: String, + port: Option[String], + protocol: Option[String], + search: Option[String], + url: String +): + private val noSep = List("xmpp:", "data:", "mailto:") + + private def whichSeparator(protocol: String): String = + if noSep.contains(protocol) then "" else "//" + + /** The host, e.g. localhost:8080. */ + val host: Option[String] = + port match + case None => + hostName + + case Some(p) => + hostName.map(h => s"$h:$p") + + /** The whole URL. */ + val href: String = url + + /** The origin, e.g. http://localhost:8080. */ + val origin: Option[String] = + for { + pr <- protocol + ht <- host + } yield pr + whichSeparator(pr) + ht + + /** Is a String of the full rendered url address, minus the origin. e.g. /my-page?id=12#anchor */ + val fullPath: String = + origin match + case None => + href + + case Some(o) => + href.replaceFirst(o, "") + +object LocationDetails: + + private val urlFileMatch = """^(file\:)\/\/(\/.*)?""".r + private val urlDataMatch = """^(data\:)(.*)?""".r + private val urlMatch = """^([a-z]+\:)(\/+)?([a-zA-Z0-9-\.\@]+)(:)?([0-9]+)?(.*)?""".r + + private val pathMatchAll = """(.*)(\?.*)(#.*)""".r + private val pathMatchHash = """(.*)(#.*)""".r + private val pathMatchSearch = """(.*)(\?.*)""".r + + private def parsePath(path: String): LocationPathDetails = + path match + case "" => + LocationPathDetails(path, None, None) + + case pathMatchAll(path, search, hash) => + LocationPathDetails(path, Option(search), Option(hash)) + + case pathMatchHash(path, hash) => + LocationPathDetails(path, None, Option(hash)) + + case pathMatchSearch(path, search) => + LocationPathDetails(path, Option(search), None) + + case _ => + LocationPathDetails(path, None, None) + + def fromUrl(url: String): LocationDetails = + url match + case urlFileMatch(protocol, path) => + val p = parsePath(Option(path).getOrElse("")) + + LocationDetails( + hash = p.hash, + hostName = None, + pathName = p.path, + port = None, + protocol = Option(protocol), + search = p.search, + url = url + ) + + case urlDataMatch(protocol, path) => + val p = parsePath(Option(path).getOrElse("")) + + LocationDetails( + hash = p.hash, + hostName = None, + pathName = p.path, + port = None, + protocol = Option(protocol), + search = p.search, + url = url + ) + + case urlMatch(protocol, _, hostname, _, port, path) => + val p = parsePath(Option(path).getOrElse("")) + + LocationDetails( + hash = p.hash, + hostName = Option(hostname), + pathName = p.path, + port = Option(port), + protocol = Option(protocol), + search = p.search, + url = url + ) + + case pathOnly => + val p = parsePath(Option(pathOnly).getOrElse("")) + + LocationDetails( + hash = p.hash, + hostName = None, + pathName = p.path, + port = None, + protocol = None, + search = p.search, + url = url + ) + + final case class LocationPathDetails(path: String, search: Option[String], hash: Option[String]) diff --git a/tyrian/js/src/main/scala/tyrian/Nav.scala b/tyrian/js/src/main/scala/tyrian/Nav.scala new file mode 100644 index 00000000..5a814fa2 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Nav.scala @@ -0,0 +1,39 @@ +package tyrian + +import cats.effect.kernel.Async +import org.scalajs.dom.window + +/** The `Nav` object provides `Cmd`s that are mainly expected to be used in conjunction with Tyrian's frontend routing. + * It exposes some of the functions from the JS `Location` and `History` apis. + */ +object Nav: + + /** Move back one in the browser's history. + */ + def back[F[_]: Async]: Cmd[F, Nothing] = + Cmd.SideEffect { + window.history.back() + } + + /** Move forward one in the browser's history. + */ + def forward[F[_]: Async]: Cmd[F, Nothing] = + Cmd.SideEffect { + window.history.forward() + } + + /** Tells the browser to navigate to a new url. Should be used in conjunction with Tyrian's frontend routing when you + * detect an external link and wish to follow it. + */ + def loadUrl[F[_]: Async](href: String): Cmd[F, Nothing] = + Cmd.SideEffect { + window.location.href = href + } + + /** Update the address bar location with a new url. Should be used in conjunction with Tyrian's frontend routing so + * that when your model decides to change pages, you can update the browser accordingly. + */ + def pushUrl[F[_]: Async](url: String): Cmd[F, Nothing] = + Cmd.SideEffect { + window.history.pushState("", "", url) + } diff --git a/tyrian/js/src/main/scala/tyrian/Navigation.scala b/tyrian/js/src/main/scala/tyrian/Navigation.scala deleted file mode 100644 index b25253dc..00000000 --- a/tyrian/js/src/main/scala/tyrian/Navigation.scala +++ /dev/null @@ -1,50 +0,0 @@ -package tyrian - -import cats.effect.kernel.Async -import org.scalajs.dom.HashChangeEvent -import org.scalajs.dom.window -import tyrian.Sub - -import scala.scalajs.js - -/** Provides simple routing based on url hash (anchor), such as: `http://mysite.com/#page1` */ -object Navigation: - - enum Result: - case HashChange(oldUrl: String, oldFragment: String, newUrl: String, newFragment: String) - case CurrentHash(hash: String) - case NoHash - - /** Subscribes to changes in the url hash and reports when they occur */ - def onLocationHashChange[F[_]: Async, Msg](resultToMessage: Result.HashChange => Msg): Sub[F, Msg] = - Sub.fromEvent("hashchange", window) { e => - def findFrag(frag: String): String = - if frag.contains('#') then frag.substring(frag.indexOf("#")) - else frag - - val evt = e.asInstanceOf[HashChangeEvent] // Never fails (JS Type), no exception to catch - val oldFrag = findFrag(evt.oldURL) - val newFrag = findFrag(evt.newURL) - - Option( - resultToMessage( - Result.HashChange(evt.oldURL, oldFrag, evt.newURL, newFrag) - ) - ) - } - - /** Fetch the current location hash */ - def getLocationHash[F[_]: Async, Msg](resultToMessage: Result => Msg): Cmd[F, Msg] = - val task = - Async[F].delay { - val hash = window.location.hash - if hash.nonEmpty then Result.CurrentHash(hash.substring(1)) - else Result.NoHash - } - Cmd.Run(task, resultToMessage) - - /** Set the location hash, the change can then be detected using the `onLocationHashChange` subscription */ - def setLocationHash[F[_]: Async](newHash: String): Cmd[F, Nothing] = - Cmd.SideEffect { - window.location.hash = if newHash.startsWith("#") then newHash else "#" + newHash - } diff --git a/tyrian/js/src/main/scala/tyrian/Routing.scala b/tyrian/js/src/main/scala/tyrian/Routing.scala new file mode 100644 index 00000000..f55f33a4 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Routing.scala @@ -0,0 +1,58 @@ +package tyrian + +import cats.effect.kernel.Async +import org.scalajs.dom.window + +/** Provides a number of convenience functions to help with routing in simple use-cases. Here the `Location` type is + * typically hidden away and the user is expected to match on the `String` href (if at all). + */ +object Routing: + + /** Provides a frontend router that treats all links the same way and simply forwards the href to the update function + * to be dealt with there. Does not differentiate between internal and external links. + * + * @param internalHRef + * A function that converts an internal href (url) to a Msg, i.e. a link to another page on this website. + * @param externalHRef + * A function that converts an external href (url) to a Msg, i.e. a link to a different website. + */ + def all[Msg](forward: String => Msg): Location => Msg = { + case loc @ Location.Internal(_) => forward(loc.href) + case loc @ Location.External(_) => forward(loc.href) + } + + /** Provides ultra simple frontend router based on string matching for convience in minimal use cases. + * + * @param internalHRef + * A function that converts an internal href (url) to a Msg, i.e. a link to another page on this website. + * @param externalHRef + * A function that converts an external href (url) to a Msg, i.e. a link to a different website. + */ + def basic[Msg](internalHRef: String => Msg, externalHRef: String => Msg): Location => Msg = { + case loc @ Location.Internal(_) => internalHRef(loc.href) + case loc @ Location.External(_) => externalHRef(loc.href) + } + + /** Provides a simple frontend router that ignores internal links to the app's own website (i.e. your app really is a + * single page app), but allows you to follow external links to other sites. + * + * @param ignore + * A user defined 'no-op' Msg that means "ignore this link/href". + * @param externalHRef + * A function that converts an external href (url) to a Msg, i.e. a link to a different website. + */ + def externalOnly[Msg](ignore: Msg, externalHRef: String => Msg): Location => Msg = { + case loc @ Location.Internal(_) => ignore + case loc @ Location.External(_) => externalHRef(loc.href) + } + + /** Provides a frontend router that ignores and deactivates all links fired by the app. In other words, no `` + * style links will work. Does not differentiate between internal and external links. + * + * @param ignore + * A user defined 'no-op' Msg that means "ignore this link/href". + */ + def none[Msg](ignore: Msg): Location => Msg = { + case loc @ Location.Internal(_) => ignore + case loc @ Location.External(_) => ignore + } diff --git a/tyrian/js/src/main/scala/tyrian/Tyrian.scala b/tyrian/js/src/main/scala/tyrian/Tyrian.scala index c55392c2..92aa10cd 100644 --- a/tyrian/js/src/main/scala/tyrian/Tyrian.scala +++ b/tyrian/js/src/main/scala/tyrian/Tyrian.scala @@ -45,6 +45,7 @@ object Tyrian: */ def start[F[_]: Async, Model, Msg]( node: Element, + router: Location => Msg, init: (Model, Cmd[F, Msg]), update: Model => Msg => (Model, Cmd[F, Msg]), view: Model => Html[Msg], @@ -62,6 +63,7 @@ object Tyrian: runtime <- Async[F].delay { new TyrianRuntime( + router, initialCmd, update, view, diff --git a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala index 760c7b18..497d816d 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala @@ -4,9 +4,10 @@ import cats.effect.kernel.Async import cats.effect.kernel.Resource import org.scalajs.dom.Element import org.scalajs.dom.document +import org.scalajs.dom.window import tyrian.runtime.TyrianRuntime -import scala.scalajs.js.annotation._ +import scala.scalajs.js.annotation.* /** The TyrianApp trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well * as providing a number of standard app launching methods. @@ -20,6 +21,8 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: val run: Resource[F, TyrianRuntime[F, Model, Msg]] => Unit + def router: Location => Msg + /** Used to initialise your app. Accepts simple flags and produces the initial model state, along with any commands to * run at start up, in order to trigger other processes. */ @@ -76,14 +79,42 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: def launch(node: Element, flags: Map[String, String]): Unit = ready(node, flags) + private def routeCurrentLocation[F[_]: Async, Msg](router: Location => Msg): Cmd[F, Msg] = + val task = + Async[F].delay { + Location.fromJsLocation(window.location) + } + Cmd.Run(task, router) + + private def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = + val (m, cmd) = init(flags) + (m, cmd |+| routeCurrentLocation[F, Msg](router)) + + private def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = + msg => update(model)(msg) + + private def _view(model: Model): Html[Msg] = + view(model) + + private def onUrlChange[F[_]: Async, Msg](router: Location => Msg): Sub[F, Msg] = + def makeMsg = Option(router(Location.fromJsLocation(window.location))) + Sub.Batch( + Sub.fromEvent("DOMContentLoaded", window)(_ => makeMsg), + Sub.fromEvent("popstate", window)(_ => makeMsg) + ) + + private def _subscriptions(model: Model): Sub[F, Msg] = + onUrlChange[F, Msg](router) |+| subscriptions(model) + def ready(node: Element, flags: Map[String, String]): Unit = run( Tyrian.start( node, - init(flags), - update, - view, - subscriptions, + router, + _init(flags), + _update, + _view, + _subscriptions, MaxConcurrentTasks ) ) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index bc1e57ec..af7b2aa7 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -2,12 +2,14 @@ package tyrian.runtime import org.scalajs.dom import org.scalajs.dom.Element +import org.scalajs.dom.window import snabbdom._ import snabbdom.modules._ import tyrian.Attr import tyrian.Attribute import tyrian.Event import tyrian.Html +import tyrian.Location import tyrian.NamedAttribute import tyrian.Property import tyrian.PropertyBoolean @@ -16,6 +18,8 @@ import tyrian.RawTag import tyrian.Tag import tyrian.Text +import scala.scalajs.js + object Rendering: private def buildNodeData[Msg](attrs: List[Attr[Msg]], onMsg: Msg => Unit): VNodeData = @@ -50,7 +54,54 @@ object Rendering: on = events.toMap ) - def toVNode[Msg](html: Html[Msg], onMsg: Msg => Unit): VNode = + private def interceptHref[Msg](attrs: List[Attr[Msg]]): Boolean = + val href = attrs.exists { + case Attribute("href", _) => true + case _ => false + } + + val onClick = attrs.exists { + case Event("click", _, _, _, _) => true + case _ => false + } + + href && !onClick + + private def onClickPreventDefault[Msg]( + attrs: List[Attr[Msg]], + onMsg: Msg => Unit, + router: Location => Msg + ): (String, EventHandler) = + val newLocation = attrs.collect { case Attribute("href", loc) => + loc + }.headOption + + val callback: dom.Event => Unit = { (e: dom.Event) => + + e.preventDefault() + + newLocation match + case None => + () + + case Some(loc) => + val jsLoc = Location.fromJsLocation(window.location) + val locationToRoute = Location.fromUrl(loc, jsLoc) + + if locationToRoute.isInternal then + // Updates the address bar + window.history.pushState(new js.Object, "", loc) + + // Invoke the page change + onMsg(router(locationToRoute)) + + () + + } + + "click" -> EventHandler(callback) + + def toVNode[Msg](html: Html[Msg], onMsg: Msg => Unit, router: Location => Msg): VNode = html match case RawTag(name, attrs, html) => val data = buildNodeData(attrs, onMsg) @@ -60,12 +111,28 @@ object Rendering: vNode.data = data vNode + // Intercept a tags with an href and no onClick attribute to stop the + // browser following links by default. + case Tag("a", attrs, children) if interceptHref(attrs) => + val data = buildNodeData(attrs, onMsg) + val childrenElem: Array[VNode] = + children.toArray.map { + case t: Text => VNode.text(t.value) + case subHtml: Html[Msg] => toVNode(subHtml, onMsg, router) + } + + h( + "a", + data.copy(on = data.on + onClickPreventDefault(attrs, onMsg, router)), + childrenElem + ) + case Tag(name, attrs, children) => val data = buildNodeData(attrs, onMsg) val childrenElem: Array[VNode] = children.toArray.map { case t: Text => VNode.text(t.value) - case subHtml: Html[Msg] => toVNode(subHtml, onMsg) + case subHtml: Html[Msg] => toVNode(subHtml, onMsg, router) } h(name, data, childrenElem) @@ -82,7 +149,13 @@ object Rendering: ) ) - def render[Model, Msg](oldNode: Element | VNode, model: Model, view: Model => Html[Msg], onMsg: Msg => Unit): VNode = + def render[Model, Msg]( + oldNode: Element | VNode, + model: Model, + view: Model => Html[Msg], + onMsg: Msg => Unit, + router: Location => Msg + ): VNode = oldNode match - case em: Element => patch(em, Rendering.toVNode(view(model), onMsg)) - case vn: VNode => patch(vn, Rendering.toVNode(view(model), onMsg)) + case em: Element => patch(em, Rendering.toVNode(view(model), onMsg, router)) + case vn: VNode => patch(vn, Rendering.toVNode(view(model), onMsg, router)) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala index 026eef12..3c061746 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala @@ -11,9 +11,11 @@ import org.scalajs.dom.Element import snabbdom.VNode import tyrian.Cmd import tyrian.Html +import tyrian.Location import tyrian.Sub final class TyrianRuntime[F[_]: Async, Model, Msg]( + router: Location => Msg, initCmd: Cmd[F, Msg], update: Model => Msg => (Model, Cmd[F, Msg]), view: Model => Html[Msg], @@ -109,7 +111,7 @@ final class TyrianRuntime[F[_]: Async, Model, Msg]( Async[F].flatMap(model.get) { m => if m.updated then for { - _ <- vnode.updateAndGet(n => Rendering.render(n, m.model, view, onMsg)) + _ <- vnode.updateAndGet(n => Rendering.render(n, m.model, view, onMsg, router)) _ <- model.set(ModelHolder(m.model, false)) } yield () else Async[F].unit diff --git a/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala b/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala new file mode 100644 index 00000000..6d6ff197 --- /dev/null +++ b/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala @@ -0,0 +1,470 @@ +package tyrian + +class LocationDetailsTests extends munit.FunSuite: + + test("Can be constructed from a URL") { + + val url = "http://localhost:8080/page2" + + val actual = + LocationDetails.fromUrl(url) + + val expected = + LocationDetails( + hash = None, + hostName = Some("localhost"), + pathName = "/page2", + port = Some("8080"), + protocol = Some("http:"), + search = None, + url = url + ) + + assertEquals(actual, expected) + } + + test("derived values: origin, host, and fullPath") { + + val url = "http://localhost:8080/page2?id=12#section" + + val actual = + LocationDetails.fromUrl(url) + + val expected = + LocationDetails( + hash = Some("#section"), + hostName = Some("localhost"), + pathName = "/page2", + port = Some("8080"), + protocol = Some("http:"), + search = Some("?id=12"), + url = url + ) + + assertEquals(actual, expected) + assertEquals(actual.host, Some("localhost:8080")) + assertEquals(actual.origin, Some("http://localhost:8080")) + assertEquals(actual.fullPath, "/page2?id=12#section") + } + + test("check example locations parse correctly") { + + examples.toList.foreach { case (url, loc) => + assertEquals(LocationDetails.fromUrl(url), loc) + } + + } + + val examples: Map[String, LocationDetails] = + Map( + "/" -> + LocationDetails( + hash = None, + hostName = None, + pathName = "/", + port = None, + protocol = None, + search = None, + url = "/" + ), + "/page-1" -> + LocationDetails( + hash = None, + hostName = None, + pathName = "/page-1", + port = None, + protocol = None, + search = None, + url = "/page-1" + ), + "foo/bar#baz" -> + LocationDetails( + hash = Option("#baz"), + hostName = None, + pathName = "foo/bar", + port = None, + protocol = None, + search = None, + url = "foo/bar#baz" + ), + "/static/images/photo.jpg?width=100&height=50" -> + LocationDetails( + hash = None, + hostName = None, + pathName = "/static/images/photo.jpg", + port = None, + protocol = None, + search = Option("?width=100&height=50"), + url = "/static/images/photo.jpg?width=100&height=50" + ), + "http://localhost:8080/page2" -> + LocationDetails( + hash = None, + hostName = Option("localhost"), + pathName = "/page2", + port = Option("8080"), + protocol = Option("http:"), + search = None, + url = "http://localhost:8080/page2" + ), + "https://www.example.com" -> + LocationDetails( + hash = None, + hostName = Option("www.example.com"), + pathName = "", + port = None, + protocol = Option("https:"), + search = None, + url = "https://www.example.com" + ), + "http://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("http:"), + search = None, + url = "http://example.com" + ), + "ftp://ftp.example.com" -> + LocationDetails( + hash = None, + hostName = Option("ftp.example.com"), + pathName = "", + port = None, + protocol = Option("ftp:"), + search = None, + url = "ftp://ftp.example.com" + ), + "ssh://example.com:22" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = Option("22"), + protocol = Option("ssh:"), + search = None, + url = "ssh://example.com:22" + ), + "telnet://example.com:23" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = Option("23"), + protocol = Option("telnet:"), + search = None, + url = "telnet://example.com:23" + ), + "mailto:user@example.com" -> + LocationDetails( + hash = None, + hostName = Option("user@example.com"), + pathName = "", + port = None, + protocol = Option("mailto:"), + search = None, + url = "mailto:user@example.com" + ), + "https://example.com/path/to/page.html" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/path/to/page.html", + port = None, + protocol = Option("https:"), + search = None, + url = "https://example.com/path/to/page.html" + ), + "http://example.com/path/to/page.php?param1=value1¶m2=value2" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/path/to/page.php", + port = None, + protocol = Option("http:"), + search = Option("?param1=value1¶m2=value2"), + url = "http://example.com/path/to/page.php?param1=value1¶m2=value2" + ), + "https://example.com#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("https:"), + search = None, + url = "https://example.com#section1" + ), + "http://example.com:8080/path/to/page.html" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/path/to/page.html", + port = Option("8080"), + protocol = Option("http:"), + search = None, + url = "http://example.com:8080/path/to/page.html" + ), + "ftp://example.com:21/path/to/file.txt" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/path/to/file.txt", + port = Option("21"), + protocol = Option("ftp:"), + search = None, + url = "ftp://example.com:21/path/to/file.txt" + ), + "http://subdomain.example.com" -> + LocationDetails( + hash = None, + hostName = Option("subdomain.example.com"), + pathName = "", + port = None, + protocol = Option("http:"), + search = None, + url = "http://subdomain.example.com" + ), + "https://www.example.co.uk" -> + LocationDetails( + hash = None, + hostName = Option("www.example.co.uk"), + pathName = "", + port = None, + protocol = Option("https:"), + search = None, + url = "https://www.example.co.uk" + ), + "http://example.net/path/to/page.html" -> + LocationDetails( + hash = None, + hostName = Option("example.net"), + pathName = "/path/to/page.html", + port = None, + protocol = Option("http:"), + search = None, + url = "http://example.net/path/to/page.html" + ), + "ftp://ftp.example.net:21/path/to/file.txt" -> + LocationDetails( + hash = None, + hostName = Option("ftp.example.net"), + pathName = "/path/to/file.txt", + port = Option("21"), + protocol = Option("ftp:"), + search = None, + url = "ftp://ftp.example.net:21/path/to/file.txt" + ), + "https://example.org/path/to/page.php?param=value#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("example.org"), + pathName = "/path/to/page.php", + port = None, + protocol = Option("https:"), + search = Option("?param=value"), + url = "https://example.org/path/to/page.php?param=value#section1" + ), + "http://example.org:8080/path/to/page.html?param=value#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("example.org"), + pathName = "/path/to/page.html", + port = Option("8080"), + protocol = Option("http:"), + search = Option("?param=value"), + url = "http://example.org:8080/path/to/page.html?param=value#section1" + ), + "ftp://example.org:21/path/to/file.txt?param=value#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("example.org"), + pathName = "/path/to/file.txt", + port = Option("21"), + protocol = Option("ftp:"), + search = Option("?param=value"), + url = "ftp://example.org:21/path/to/file.txt?param=value#section1" + ), + "http://localhost:8080/path/to/page.php?param=value#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("localhost"), + pathName = "/path/to/page.php", + port = Option("8080"), + protocol = Option("http:"), + search = Option("?param=value"), + url = "http://localhost:8080/path/to/page.php?param=value#section1" + ), + "https://192.168.1.100:8443/path/to/page.html?param=value#section1" -> + LocationDetails( + hash = Option("#section1"), + hostName = Option("192.168.1.100"), + pathName = "/path/to/page.html", + port = Option("8443"), + protocol = Option("https:"), + search = Option("?param=value"), + url = "https://192.168.1.100:8443/path/to/page.html?param=value#section1" + ), + "http://www.example.com" -> + LocationDetails( + hash = None, + hostName = Option("www.example.com"), + pathName = "", + port = None, + protocol = Option("http:"), + search = None, + url = "http://www.example.com" + ), + "https://www.example.com" -> + LocationDetails( + hash = None, + hostName = Option("www.example.com"), + pathName = "", + port = None, + protocol = Option("https:"), + search = None, + url = "https://www.example.com" + ), + "ftp://ftp.example.com" -> + LocationDetails( + hash = None, + hostName = Option("ftp.example.com"), + pathName = "", + port = None, + protocol = Option("ftp:"), + search = None, + url = "ftp://ftp.example.com" + ), + "sftp://ftp.example.com" -> + LocationDetails( + hash = None, + hostName = Option("ftp.example.com"), + pathName = "", + port = None, + protocol = Option("sftp:"), + search = None, + url = "sftp://ftp.example.com" + ), + "ssh://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("ssh:"), + search = None, + url = "ssh://example.com" + ), + "telnet://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("telnet:"), + search = None, + url = "telnet://example.com" + ), + "mailto:user@example.com" -> + LocationDetails( + hash = None, + hostName = Option("user@example.com"), + pathName = "", + port = None, + protocol = Option("mailto:"), + search = None, + url = "mailto:user@example.com" + ), + "news://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("news:"), + search = None, + url = "news://example.com" + ), + "gopher://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("gopher:"), + search = None, + url = "gopher://example.com" + ), + "ldap://ldap.example.com" -> + LocationDetails( + hash = None, + hostName = Option("ldap.example.com"), + pathName = "", + port = None, + protocol = Option("ldap:"), + search = None, + url = "ldap://ldap.example.com" + ), + "smb://example.com/share/file.txt" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/share/file.txt", + port = None, + protocol = Option("smb:"), + search = None, + url = "smb://example.com/share/file.txt" + ), + "nfs://example.com/share/file.txt" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "/share/file.txt", + port = None, + protocol = Option("nfs:"), + search = None, + url = "nfs://example.com/share/file.txt" + ), + "file:///path/to/local/file.html" -> + LocationDetails( + hash = None, + hostName = None, + pathName = "/path/to/local/file.html", + port = None, + protocol = Option("file:"), + search = None, + url = "file:///path/to/local/file.html" + ), + "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" -> + LocationDetails( + hash = None, + hostName = None, + pathName = "text/plain;base64,SGVsbG8sIFdvcmxkIQ==", + port = None, + protocol = Option("data:"), + search = None, + url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" + ), + "dns://example.com" -> + LocationDetails( + hash = None, + hostName = Option("example.com"), + pathName = "", + port = None, + protocol = Option("dns:"), + search = None, + url = "dns://example.com" + ), + "xmpp:user@example.com" -> + LocationDetails( + hash = None, + hostName = Option("user@example.com"), + pathName = "", + port = None, + protocol = Option("xmpp:"), + search = None, + url = "xmpp:user@example.com" + ) + ) diff --git a/tyrian/js/src/test/scala/tyrian/LocationTests.scala b/tyrian/js/src/test/scala/tyrian/LocationTests.scala new file mode 100644 index 00000000..a063d260 --- /dev/null +++ b/tyrian/js/src/test/scala/tyrian/LocationTests.scala @@ -0,0 +1,88 @@ +package tyrian + +class LocationTests extends munit.FunSuite: + + test("fromUrl: a path only location is Internal") { + + val actual = + Location.fromUrl( + "/page4", + Location.Internal( + LocationDetails( + hash = None, + hostName = None, + pathName = "/", + port = None, + protocol = None, + search = None, + url = "/" + ) + ) + ) + + val expected = + Location.Internal( + LocationDetails( + hash = None, + hostName = None, + pathName = "/page4", + port = None, + protocol = None, + search = None, + url = "/page4" + ) + ) + + assertEquals(actual, expected) + + } + + test("fromUrl: a path with an internal origin is Internal") { + + val actual = + Location.fromUrl( + "https://localhost:8080/page4", + Location.Internal(LocationDetails.fromUrl("https://localhost:8080/")) + ) + + val expected = + Location.Internal( + LocationDetails( + hash = None, + hostName = Some("localhost"), + pathName = "/page4", + port = Some("8080"), + protocol = Some("https:"), + search = None, + url = "https://localhost:8080/page4" + ) + ) + + assertEquals(actual, expected) + + } + + test("fromUrl: a path with an external origin is External") { + + val actual = + Location.fromUrl( + "https://indigoengine.io/docs", + Location.Internal(LocationDetails.fromUrl("https://localhost:8080/")) + ) + + val expected = + Location.External( + LocationDetails( + hash = None, + hostName = Some("indigoengine.io"), + pathName = "/docs", + port = None, + protocol = Some("https:"), + search = None, + url = "https://indigoengine.io/docs" + ) + ) + + assertEquals(actual, expected) + + }