From a4e26caad9595d5242b8f7aff9a7b7b006ba8995 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 2 May 2023 07:34:38 +0100 Subject: [PATCH 01/24] Move ready() implementation up to IO/ZIO level --- tyrian-io/src/main/scala/tyrian/TyrianApp.scala | 13 +++++++++++++ tyrian-zio/src/main/scala/tyrian/TyrianApp.scala | 13 +++++++++++++ tyrian/js/src/main/scala/tyrian/TyrianAppF.scala | 12 +----------- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala index 835288f9..02e7bc5e 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -3,6 +3,7 @@ package tyrian import cats.effect.IO import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global +import org.scalajs.dom.Element import org.scalajs.dom.document import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime @@ -16,3 +17,15 @@ trait TyrianApp[Msg, Model] extends TyrianAppF[IO, Msg, Model]: val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = _.map(_.start()).useForever.unsafeRunAndForget() + + def ready(node: Element, flags: Map[String, String]): Unit = + run( + Tyrian.start( + node, + init(flags), + update, + view, + subscriptions, + MaxConcurrentTasks + ) + ) diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index d2791e98..e2918e5c 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -4,6 +4,7 @@ import cats.effect.Async import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global import org.scalajs.dom.document +import org.scalajs.dom.Element import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime import zio.Runtime @@ -24,3 +25,15 @@ trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Mod Unsafe.unsafe { implicit unsafe => runtime.unsafe.run(runnable).getOrThrowFiberFailure() } + + def ready(node: Element, flags: Map[String, String]): Unit = + run( + Tyrian.start( + node, + init(flags), + update, + view, + subscriptions, + MaxConcurrentTasks + ) + ) diff --git a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala index 760c7b18..4b86600a 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala @@ -76,17 +76,7 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: def launch(node: Element, flags: Map[String, String]): Unit = ready(node, flags) - def ready(node: Element, flags: Map[String, String]): Unit = - run( - Tyrian.start( - node, - init(flags), - update, - view, - subscriptions, - MaxConcurrentTasks - ) - ) + def ready(node: Element, flags: Map[String, String]): Unit @SuppressWarnings(Array("scalafix:DisableSyntax.throw")) private def runReadyOrError(containerId: String, flags: Map[String, String]): Unit = From f70031f4f59c5516dfb9b257eba4d7b12d665c11 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 2 May 2023 08:03:01 +0100 Subject: [PATCH 02/24] Extending MultiPage give hash based routing --- .../main/scala/example/IndigoSandbox.scala | 3 +- sandbox/src/main/scala/example/Sandbox.scala | 16 ++++--- .../{TyrianApp.scala => MultiPage.scala} | 16 +------ .../src/main/scala/tyrian/SinglePage.scala | 19 ++++++++ .../{TyrianApp.scala => MultiPage.scala} | 18 ++------ .../src/main/scala/tyrian/SinglePage.scala | 27 +++++++++++ .../js/src/main/scala/tyrian/TyrianAppF.scala | 12 ++++- .../main/scala/tyrian/TyrianRoutedAppF.scala | 45 +++++++++++++++++++ 8 files changed, 118 insertions(+), 38 deletions(-) rename tyrian-io/src/main/scala/tyrian/{TyrianApp.scala => MultiPage.scala} (59%) create mode 100644 tyrian-io/src/main/scala/tyrian/SinglePage.scala rename tyrian-zio/src/main/scala/tyrian/{TyrianApp.scala => MultiPage.scala} (66%) create mode 100644 tyrian-zio/src/main/scala/tyrian/SinglePage.scala create mode 100644 tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala diff --git a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala index e616f04e..4d691342 100644 --- a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala +++ b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala @@ -3,6 +3,7 @@ package example import example.game.MyAwesomeGame import org.scalajs.dom.document import tyrian.Html.* +import tyrian.SinglePage import tyrian.* import tyrian.cmds.Logger import tyrian.cmds.Random @@ -12,7 +13,7 @@ import zio.interop.catz.* import scala.scalajs.js.annotation.* @JSExportTopLevel("TyrianApp") -object IndigoSandbox extends TyrianApp[Msg, Model]: +object IndigoSandbox extends SinglePage[Msg, Model]: val gameDivId1: String = "my-game-1" val gameDivId2: String = "my-game-2" diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 054a619e..02004a7d 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -17,7 +17,14 @@ import scala.scalajs.js.annotation.* import scalajs.js @JSExportTopLevel("TyrianApp") -object Sandbox extends TyrianApp[Msg, Model]: +object Sandbox extends MultiPage[Msg, Model]: + + val hashResultToMessage: Navigation.Result => Msg = { + case Navigation.Result.CurrentHash(hash) => Msg.NavigateTo(Page.fromString(hash)) + case _ => Msg.NavigateTo(Page.Page1) + } + val hashChangeToMessage: Navigation.Result.HashChange => Msg = + hashChange => Msg.NavigateTo(Page.fromString(hashChange.newFragment)) val hotReloadKey: String = "hotreload" @@ -29,10 +36,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.") @@ -187,7 +190,7 @@ object Sandbox extends TyrianApp[Msg, Model]: (model.copy(tmpSaveData = content), Cmd.None) case Msg.JumpToHomePage => - (model, Navigation.setLocationHash(Page.Page1.toHash)) + (model, setLocationHash(Page.Page1.toHash)) case Msg.NavigateTo(page) => (model.copy(page = page), Cmd.None) @@ -569,7 +572,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 => diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/MultiPage.scala similarity index 59% rename from tyrian-io/src/main/scala/tyrian/TyrianApp.scala rename to tyrian-io/src/main/scala/tyrian/MultiPage.scala index 02e7bc5e..4edf1f84 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/MultiPage.scala @@ -10,22 +10,10 @@ 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 +/** The MultiPage 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. */ -trait TyrianApp[Msg, Model] extends TyrianAppF[IO, Msg, Model]: +trait MultiPage[Msg, Model] extends TyrianRoutedAppF[IO, Msg, Model]: val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = _.map(_.start()).useForever.unsafeRunAndForget() - - def ready(node: Element, flags: Map[String, String]): Unit = - run( - Tyrian.start( - node, - init(flags), - update, - view, - subscriptions, - MaxConcurrentTasks - ) - ) diff --git a/tyrian-io/src/main/scala/tyrian/SinglePage.scala b/tyrian-io/src/main/scala/tyrian/SinglePage.scala new file mode 100644 index 00000000..2d18de75 --- /dev/null +++ b/tyrian-io/src/main/scala/tyrian/SinglePage.scala @@ -0,0 +1,19 @@ +package tyrian + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.effect.unsafe.implicits.global +import org.scalajs.dom.Element +import org.scalajs.dom.document +import tyrian.TyrianAppF +import tyrian.runtime.TyrianRuntime + +import scala.scalajs.js.annotation._ + +/** The SinglePage 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. + */ +trait SinglePage[Msg, Model] extends TyrianAppF[IO, Msg, Model]: + + val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = + _.map(_.start()).useForever.unsafeRunAndForget() diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/MultiPage.scala similarity index 66% rename from tyrian-zio/src/main/scala/tyrian/TyrianApp.scala rename to tyrian-zio/src/main/scala/tyrian/MultiPage.scala index e2918e5c..791b8499 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/MultiPage.scala @@ -3,8 +3,8 @@ package tyrian import cats.effect.Async import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global -import org.scalajs.dom.document import org.scalajs.dom.Element +import org.scalajs.dom.document import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime import zio.Runtime @@ -13,10 +13,10 @@ 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 +/** The MultiPage 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. */ -trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: +trait MultiPage[Msg, Model](using Async[Task]) extends TyrianRoutedAppF[Task, Msg, Model]: val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => val runtime = Runtime.default @@ -25,15 +25,3 @@ trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Mod Unsafe.unsafe { implicit unsafe => runtime.unsafe.run(runnable).getOrThrowFiberFailure() } - - def ready(node: Element, flags: Map[String, String]): Unit = - run( - Tyrian.start( - node, - init(flags), - update, - view, - subscriptions, - MaxConcurrentTasks - ) - ) diff --git a/tyrian-zio/src/main/scala/tyrian/SinglePage.scala b/tyrian-zio/src/main/scala/tyrian/SinglePage.scala new file mode 100644 index 00000000..f4450f60 --- /dev/null +++ b/tyrian-zio/src/main/scala/tyrian/SinglePage.scala @@ -0,0 +1,27 @@ +package tyrian + +import cats.effect.Async +import cats.effect.kernel.Resource +import cats.effect.unsafe.implicits.global +import org.scalajs.dom.Element +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 SinglePage 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. + */ +trait SinglePage[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: + + val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => + val runtime = Runtime.default + val runnable = res.map(_.start()).useForever + + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(runnable).getOrThrowFiberFailure() + } diff --git a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala index 4b86600a..760c7b18 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala @@ -76,7 +76,17 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: def launch(node: Element, flags: Map[String, String]): Unit = ready(node, flags) - def ready(node: Element, flags: Map[String, String]): Unit + def ready(node: Element, flags: Map[String, String]): Unit = + run( + Tyrian.start( + node, + init(flags), + update, + view, + subscriptions, + MaxConcurrentTasks + ) + ) @SuppressWarnings(Array("scalafix:DisableSyntax.throw")) private def runReadyOrError(containerId: String, flags: Map[String, String]): Unit = diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala new file mode 100644 index 00000000..778cba4f --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -0,0 +1,45 @@ +package tyrian + +import cats.effect.kernel.Async +import cats.effect.kernel.Resource +import org.scalajs.dom.Element +import org.scalajs.dom.document +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. + */ +trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model]: + + def hashResultToMessage: Navigation.Result => Msg + def hashChangeToMessage: Navigation.Result.HashChange => Msg + + def setLocationHash(newHash: String): Cmd[F, Nothing] = + Navigation.setLocationHash(newHash) + + def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = + val (m, cmd) = init(flags) + (m, Navigation.getLocationHash[F, Msg](hashResultToMessage) |+| cmd) + + def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = + msg => update(model)(msg) + + def _view(model: Model): Html[Msg] = + view(model) + + def _subscriptions(model: Model): Sub[F, Msg] = + Navigation.onLocationHashChange[F, Msg](hashChangeToMessage) |+| subscriptions(model) + + override def ready(node: Element, flags: Map[String, String]): Unit = + run( + Tyrian.start( + node, + _init(flags), + _update, + _view, + _subscriptions, + MaxConcurrentTasks + ) + ) From 5aa91aa0259903aa74f140d673d41ce1d91e0072 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Wed, 3 May 2023 08:22:25 +0100 Subject: [PATCH 03/24] Make TyrianRoutedAppF methods private --- tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index 778cba4f..e57c2d8e 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -19,17 +19,17 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model def setLocationHash(newHash: String): Cmd[F, Nothing] = Navigation.setLocationHash(newHash) - def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = + private def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = val (m, cmd) = init(flags) (m, Navigation.getLocationHash[F, Msg](hashResultToMessage) |+| cmd) - def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = + private def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = msg => update(model)(msg) - def _view(model: Model): Html[Msg] = + private def _view(model: Model): Html[Msg] = view(model) - def _subscriptions(model: Model): Sub[F, Msg] = + private def _subscriptions(model: Model): Sub[F, Msg] = Navigation.onLocationHashChange[F, Msg](hashChangeToMessage) |+| subscriptions(model) override def ready(node: Element, flags: Map[String, String]): Unit = From aec93e76fc20fa22b6e1cbd0d24d6d7cd40bc536 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 5 May 2023 08:28:39 +0100 Subject: [PATCH 04/24] Popstate based routing --- sandbox/src/main/scala/example/Sandbox.scala | 30 +++++--------- .../main/scala/tyrian/TyrianRoutedAppF.scala | 40 ++++++++++++++++--- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 02004a7d..2842625e 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -19,12 +19,15 @@ import scalajs.js @JSExportTopLevel("TyrianApp") object Sandbox extends MultiPage[Msg, Model]: - val hashResultToMessage: Navigation.Result => Msg = { - case Navigation.Result.CurrentHash(hash) => Msg.NavigateTo(Page.fromString(hash)) - case _ => Msg.NavigateTo(Page.Page1) - } - val hashChangeToMessage: Navigation.Result.HashChange => Msg = - hashChange => Msg.NavigateTo(Page.fromString(hashChange.newFragment)) + // 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: String => Msg = + 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 _ => Msg.NavigateTo(Page.Page1) val hotReloadKey: String = "hotreload" @@ -696,21 +699,6 @@ enum Page: 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 - object Model: // val echoServer = "ws://ws.ifelse.io" // public echo server val echoServer = "ws://localhost:8080/wsecho" diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index e57c2d8e..2f427058 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -3,9 +3,12 @@ package tyrian import cats.effect.kernel.Async import cats.effect.kernel.Resource import org.scalajs.dom.Element +import org.scalajs.dom.Location import org.scalajs.dom.document +import org.scalajs.dom.window import tyrian.runtime.TyrianRuntime +import scala.scalajs.js 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 @@ -13,15 +16,14 @@ import scala.scalajs.js.annotation._ */ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model]: - def hashResultToMessage: Navigation.Result => Msg - def hashChangeToMessage: Navigation.Result.HashChange => Msg + def router: String => Msg def setLocationHash(newHash: String): Cmd[F, Nothing] = - Navigation.setLocationHash(newHash) + Routing.setLocation(newHash) private def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = val (m, cmd) = init(flags) - (m, Navigation.getLocationHash[F, Msg](hashResultToMessage) |+| cmd) + (m, Routing.getLocation[F, Msg](router) |+| cmd) private def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = msg => update(model)(msg) @@ -30,7 +32,7 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model view(model) private def _subscriptions(model: Model): Sub[F, Msg] = - Navigation.onLocationHashChange[F, Msg](hashChangeToMessage) |+| subscriptions(model) + Routing.onLocationChange[F, Msg](router) |+| subscriptions(model) override def ready(node: Element, flags: Map[String, String]): Unit = run( @@ -43,3 +45,31 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model MaxConcurrentTasks ) ) + +object Routing: + + private def locationToRoute(loc: Location): String = + val origin = loc.origin.getOrElse("") + loc.toString.replaceFirst(origin, "") + + def onLocationChange[F[_]: Async, Msg](router: String => Msg): Sub[F, Msg] = + Sub.Batch( + Sub.fromEvent("DOMContentLoaded", window) { _ => + Option((locationToRoute andThen router)(window.location)) + }, + Sub.fromEvent("popstate", window) { _ => + Option((locationToRoute andThen router)(window.location)) + } + ) + + def getLocation[F[_]: Async, Msg](router: String => Msg): Cmd[F, Msg] = + val task = + Async[F].delay { + locationToRoute(window.location) + } + Cmd.Run(task, router) + + def setLocation[F[_]: Async](newHash: String): Cmd[F, Nothing] = + Cmd.SideEffect { + window.location.hash = if newHash.startsWith("#") then newHash else "#" + newHash + } From 88e1de8e98b001d8b17d34d7c0ba29e57be19cd8 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 6 May 2023 14:38:25 +0100 Subject: [PATCH 05/24] Remove Navigation class --- .../js/src/main/scala/tyrian/Navigation.scala | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 tyrian/js/src/main/scala/tyrian/Navigation.scala 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 - } From 6f5e8cdbbef0b6b482ccd1bbdbf8dc7fec6874bd Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 6 May 2023 14:40:29 +0100 Subject: [PATCH 06/24] Introduce a location class --- sandbox/src/main/scala/example/Sandbox.scala | 41 ++++--- .../main/scala/tyrian/TyrianRoutedAppF.scala | 109 +++++++++++++++--- 2 files changed, 114 insertions(+), 36 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 2842625e..c85c7d31 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -21,13 +21,17 @@ object Sandbox extends MultiPage[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: String => Msg = - 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 _ => Msg.NavigateTo(Page.Page1) + def router: Location => Msg = loc => + loc.pathName match + 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 url => + println("Unknown route: " + url) + println(loc) + Msg.NavigateTo(Page.Page1) val hotReloadKey: String = "hotreload" @@ -193,10 +197,10 @@ object Sandbox extends MultiPage[Msg, Model]: (model.copy(tmpSaveData = content), Cmd.None) case Msg.JumpToHomePage => - (model, setLocationHash(Page.Page1.toHash)) + (model, Routing.setLocation(Page.Page1.toUrlPath)) case Msg.NavigateTo(page) => - (model.copy(page = page), Cmd.None) + (model.copy(page = page), Routing.setLocation(page.toUrlPath)) case Msg.TakeSnapshot => (model, HotReload.snapshot(hotReloadKey, model, Model.encode)) @@ -300,7 +304,10 @@ object Sandbox extends MultiPage[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 := "#", onClick(Msg.NavigateTo(pg)))(pg.toNavLabel) + } } val counters = model.components.zipWithIndex.map { case (c, i) => @@ -690,14 +697,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" + 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/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index 2f427058..f61265d9 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -3,7 +3,6 @@ package tyrian import cats.effect.kernel.Async import cats.effect.kernel.Resource import org.scalajs.dom.Element -import org.scalajs.dom.Location import org.scalajs.dom.document import org.scalajs.dom.window import tyrian.runtime.TyrianRuntime @@ -16,10 +15,7 @@ import scala.scalajs.js.annotation._ */ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model]: - def router: String => Msg - - def setLocationHash(newHash: String): Cmd[F, Nothing] = - Routing.setLocation(newHash) + def router: Location => Msg private def _init(flags: Map[String, String]): (Model, Cmd[F, Msg]) = val (m, cmd) = init(flags) @@ -48,28 +44,103 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model object Routing: - private def locationToRoute(loc: Location): String = - val origin = loc.origin.getOrElse("") - loc.toString.replaceFirst(origin, "") + private def jsLocationToLocation(loc: org.scalajs.dom.Location): Location = + Location( + _url = loc.toString, + _hash = loc.hash, + _protocol = loc.protocol, + _search = loc.search, + _href = loc.href, + _hostname = loc.hostname, + _port = loc.port, + _pathname = loc.pathname, + _host = loc.host, + _origin = loc.origin.toOption + ) + + def onLocationChange[F[_]: Async, Msg](router: Location => Msg): Sub[F, Msg] = + def makeMsg = Option(router(jsLocationToLocation(window.location))) - def onLocationChange[F[_]: Async, Msg](router: String => Msg): Sub[F, Msg] = Sub.Batch( - Sub.fromEvent("DOMContentLoaded", window) { _ => - Option((locationToRoute andThen router)(window.location)) - }, - Sub.fromEvent("popstate", window) { _ => - Option((locationToRoute andThen router)(window.location)) - } + Sub.fromEvent("DOMContentLoaded", window)(_ => makeMsg), + Sub.fromEvent("popstate", window)(_ => makeMsg) ) - def getLocation[F[_]: Async, Msg](router: String => Msg): Cmd[F, Msg] = + def getLocation[F[_]: Async, Msg](router: Location => Msg): Cmd[F, Msg] = val task = Async[F].delay { - locationToRoute(window.location) + jsLocationToLocation(window.location) } Cmd.Run(task, router) - def setLocation[F[_]: Async](newHash: String): Cmd[F, Nothing] = + private val emptyObj: js.Object = new js.Object + + def setLocation[F[_]: Async](newLocation: String): Cmd[F, Nothing] = Cmd.SideEffect { - window.location.hash = if newHash.startsWith("#") then newHash else "#" + newHash + window.history.pushState(emptyObj, "", newLocation) } + +/** The Location interface represents the location of the object it is linked to. Changes done on it are reflected on + * the object it relates to. Both the Document and Window interface have such a linked Location, accessible via + * Document.location and Window.location respectively. + * + * This `Location` type is mostly a paired back version of Scala.js's: org.scalajs.dom.Location, right down to the + * scaladocs! However, field names have been changed to camel case to be more Scala consitent, and additional fields + * `url` and `fullPath` have been added for convenience. + */ +final case class Location( + private val _url: String, + private val _hash: String, + private val _protocol: String, + private val _search: String, + private val _href: String, + private val _hostname: String, + private val _port: String, + private val _pathname: String, + private val _host: String, + private val _origin: Option[String] +): + /** Is a String of the full rendered url address. */ + val url: String = _url + + /** 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 => + _url + + case Some(o) => + _url.replaceFirst(o, "") + + /** Is a String containing a '#' followed by the fragment identifier of the URL. */ + val hash: String = _hash + + /** Is a String containing the protocol scheme of the URL, including the final ':'. */ + val protocol: String = _protocol + + /** Is a String containing a '?' followed by the parameters of the URL. */ + val search: String = _search + + /** Is a String containing the whole URL. */ + val href: String = _href + + /** Is a String containing the domain of the URL. */ + val hostName: String = _hostname + + /** Is a String containing the port number of the URL. */ + val port: String = _port + + /** Is a String containing an initial '/' followed by the path of the URL. */ + val pathName: String = _pathname + + /** Is a String containing the host, that is the hostname, a ':', and the port of the URL. */ + val host: String = _host + + /** The origin read-only property is a String containing the Unicode serialization of the origin of the represented + * URL, that is, for http and https, the scheme followed by '://', followed by the domain, followed by ':', followed + * by the port (the default port, 80 and 443 respectively, if explicitly specified). For URL using file: scheme, the + * value is browser dependant. + * + * This property also does not exist consistently on IE, even as new as IE11, hence it must be optional. + */ + def origin: Option[String] = _origin From 793397032702647235785816019a92b9397b3912 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 6 May 2023 14:57:05 +0100 Subject: [PATCH 07/24] Add deprecation warning for TyrianApp --- .../src/main/scala/tyrian/SinglePage.scala | 19 ------------- .../{MultiPage.scala => TyrianApp.scala} | 11 ++++++++ .../src/main/scala/tyrian/SinglePage.scala | 27 ------------------- .../{MultiPage.scala => TyrianApp.scala} | 16 +++++++++++ 4 files changed, 27 insertions(+), 46 deletions(-) delete mode 100644 tyrian-io/src/main/scala/tyrian/SinglePage.scala rename tyrian-io/src/main/scala/tyrian/{MultiPage.scala => TyrianApp.scala} (56%) delete mode 100644 tyrian-zio/src/main/scala/tyrian/SinglePage.scala rename tyrian-zio/src/main/scala/tyrian/{MultiPage.scala => TyrianApp.scala} (55%) diff --git a/tyrian-io/src/main/scala/tyrian/SinglePage.scala b/tyrian-io/src/main/scala/tyrian/SinglePage.scala deleted file mode 100644 index 2d18de75..00000000 --- a/tyrian-io/src/main/scala/tyrian/SinglePage.scala +++ /dev/null @@ -1,19 +0,0 @@ -package tyrian - -import cats.effect.IO -import cats.effect.kernel.Resource -import cats.effect.unsafe.implicits.global -import org.scalajs.dom.Element -import org.scalajs.dom.document -import tyrian.TyrianAppF -import tyrian.runtime.TyrianRuntime - -import scala.scalajs.js.annotation._ - -/** The SinglePage 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. - */ -trait SinglePage[Msg, Model] extends TyrianAppF[IO, Msg, Model]: - - val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = - _.map(_.start()).useForever.unsafeRunAndForget() diff --git a/tyrian-io/src/main/scala/tyrian/MultiPage.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala similarity index 56% rename from tyrian-io/src/main/scala/tyrian/MultiPage.scala rename to tyrian-io/src/main/scala/tyrian/TyrianApp.scala index 4edf1f84..07894ed5 100644 --- a/tyrian-io/src/main/scala/tyrian/MultiPage.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -10,6 +10,14 @@ import tyrian.runtime.TyrianRuntime import scala.scalajs.js.annotation._ +/** The SinglePage 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. + */ +trait SinglePage[Msg, Model] extends TyrianAppF[IO, Msg, Model]: + + val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = + _.map(_.start()).useForever.unsafeRunAndForget() + /** The MultiPage 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. */ @@ -17,3 +25,6 @@ trait MultiPage[Msg, Model] extends TyrianRoutedAppF[IO, Msg, Model]: val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = _.map(_.start()).useForever.unsafeRunAndForget() + +@deprecated("Please use SinglePage or MultiPage instead of TyrianApp.") +trait TyrianApp[Msg, Model] extends SinglePage[Msg, Model] diff --git a/tyrian-zio/src/main/scala/tyrian/SinglePage.scala b/tyrian-zio/src/main/scala/tyrian/SinglePage.scala deleted file mode 100644 index f4450f60..00000000 --- a/tyrian-zio/src/main/scala/tyrian/SinglePage.scala +++ /dev/null @@ -1,27 +0,0 @@ -package tyrian - -import cats.effect.Async -import cats.effect.kernel.Resource -import cats.effect.unsafe.implicits.global -import org.scalajs.dom.Element -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 SinglePage 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. - */ -trait SinglePage[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: - - val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => - val runtime = Runtime.default - val runnable = res.map(_.start()).useForever - - Unsafe.unsafe { implicit unsafe => - runtime.unsafe.run(runnable).getOrThrowFiberFailure() - } diff --git a/tyrian-zio/src/main/scala/tyrian/MultiPage.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala similarity index 55% rename from tyrian-zio/src/main/scala/tyrian/MultiPage.scala rename to tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index 791b8499..ba719335 100644 --- a/tyrian-zio/src/main/scala/tyrian/MultiPage.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -13,6 +13,19 @@ import zio.Unsafe import scala.scalajs.js.annotation._ +/** The SinglePage 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. + */ +trait SinglePage[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: + + val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => + val runtime = Runtime.default + val runnable = res.map(_.start()).useForever + + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(runnable).getOrThrowFiberFailure() + } + /** The MultiPage 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. */ @@ -25,3 +38,6 @@ trait MultiPage[Msg, Model](using Async[Task]) extends TyrianRoutedAppF[Task, Ms Unsafe.unsafe { implicit unsafe => runtime.unsafe.run(runnable).getOrThrowFiberFailure() } + +@deprecated("Please use SinglePage or MultiPage instead of TyrianApp.") +trait TyrianApp[Msg, Model](using Async[Task]) extends SinglePage[Msg, Model] From b11420ed627ace5040faa6731943af74b58d19d9 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 6 May 2023 15:06:39 +0100 Subject: [PATCH 08/24] Add a random hash link to the sandbox --- sandbox/src/main/scala/example/Sandbox.scala | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index c85c7d31..142ecc84 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -13,6 +13,7 @@ import tyrian.websocket.* import scala.concurrent.duration.* import scala.scalajs.js.annotation.* +import scala.util.Random import scalajs.js @@ -28,8 +29,8 @@ object Sandbox extends MultiPage[Msg, Model]: case "/page4" => Msg.NavigateTo(Page.Page4) case "/page5" => Msg.NavigateTo(Page.Page5) case "/page6" => Msg.NavigateTo(Page.Page6) - case url => - println("Unknown route: " + url) + case _ => + println("Unknown route: " + loc.fullPath) println(loc) Msg.NavigateTo(Page.Page1) @@ -308,7 +309,12 @@ object Sandbox extends MultiPage[Msg, Model]: li(style := CSS.`font-family`("sans-serif")) { a(href := "#", onClick(Msg.NavigateTo(pg)))(pg.toNavLabel) } - } + } ++ + List( + li(style := CSS.`font-family`("sans-serif")) { + a(href := "#foo" + Random.nextInt())("Random link") + } + ) val counters = model.components.zipWithIndex.map { case (c, i) => Counter.view(c).map(msg => Msg.Modify(i, msg)) From 7877cfc059f3939c1e15d847ee85edde9622ccb3 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 8 May 2023 17:04:18 +0100 Subject: [PATCH 09/24] Paranoid save - works, contains TODOs --- sandbox/src/main/scala/example/Sandbox.scala | 6 +- tyrian/js/src/main/scala/tyrian/Tyrian.scala | 2 + .../js/src/main/scala/tyrian/TyrianAppF.scala | 1 + .../main/scala/tyrian/TyrianRoutedAppF.scala | 71 ++++++++++------- .../main/scala/tyrian/runtime/Rendering.scala | 79 +++++++++++++++++-- .../scala/tyrian/runtime/TyrianRuntime.scala | 4 +- 6 files changed, 126 insertions(+), 37 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 142ecc84..70e591bc 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -198,10 +198,10 @@ object Sandbox extends MultiPage[Msg, Model]: (model.copy(tmpSaveData = content), Cmd.None) case Msg.JumpToHomePage => - (model, Routing.setLocation(Page.Page1.toUrlPath)) + (model.copy(page = Page.Page1), Routing.setLocation(Page.Page1.toUrlPath)) case Msg.NavigateTo(page) => - (model.copy(page = page), Routing.setLocation(page.toUrlPath)) + (model.copy(page = page), Cmd.None) case Msg.TakeSnapshot => (model, HotReload.snapshot(hotReloadKey, model, Model.encode)) @@ -307,7 +307,7 @@ object Sandbox extends MultiPage[Msg, Model]: if pg == model.page then li(style := CSS.`font-family`("sans-serif"))(pg.toNavLabel) else li(style := CSS.`font-family`("sans-serif")) { - a(href := "#", onClick(Msg.NavigateTo(pg)))(pg.toNavLabel) + a(href := pg.toUrlPath)(pg.toNavLabel) } } ++ List( 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..867d8d2a 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala @@ -80,6 +80,7 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: run( Tyrian.start( node, + ???, // TODO init(flags), update, view, diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index f61265d9..61489a0b 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -3,9 +3,9 @@ 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 tyrian.runtime.TyrianRuntime import scala.scalajs.js import scala.scalajs.js.annotation._ @@ -17,9 +17,16 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model def router: Location => Msg + 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, Routing.getLocation[F, Msg](router) |+| cmd) + (m, cmd |+| routeCurrentLocation[F, Msg](router)) private def _update(model: Model): Msg => (Model, Cmd[F, Msg]) = msg => update(model)(msg) @@ -34,6 +41,7 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model run( Tyrian.start( node, + router, _init(flags), _update, _view, @@ -44,40 +52,16 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model object Routing: - private def jsLocationToLocation(loc: org.scalajs.dom.Location): Location = - Location( - _url = loc.toString, - _hash = loc.hash, - _protocol = loc.protocol, - _search = loc.search, - _href = loc.href, - _hostname = loc.hostname, - _port = loc.port, - _pathname = loc.pathname, - _host = loc.host, - _origin = loc.origin.toOption - ) - def onLocationChange[F[_]: Async, Msg](router: Location => Msg): Sub[F, Msg] = - def makeMsg = Option(router(jsLocationToLocation(window.location))) - + def makeMsg = Option(router(Location.fromJsLocation(window.location))) Sub.Batch( Sub.fromEvent("DOMContentLoaded", window)(_ => makeMsg), Sub.fromEvent("popstate", window)(_ => makeMsg) ) - def getLocation[F[_]: Async, Msg](router: Location => Msg): Cmd[F, Msg] = - val task = - Async[F].delay { - jsLocationToLocation(window.location) - } - Cmd.Run(task, router) - - private val emptyObj: js.Object = new js.Object - def setLocation[F[_]: Async](newLocation: String): Cmd[F, Nothing] = Cmd.SideEffect { - window.history.pushState(emptyObj, "", newLocation) + window.history.pushState("", "", newLocation) } /** The Location interface represents the location of the object it is linked to. Changes done on it are reflected on @@ -144,3 +128,34 @@ final case class Location( * This property also does not exist consistently on IE, even as new as IE11, hence it must be optional. */ def origin: Option[String] = _origin + +object Location: + + // TODO! + def fromPath(path: String): Location = + Location( + _url = "", + _hash = "", + _protocol = "", + _search = "", + _href = "", + _hostname = "", + _port = "", + _pathname = path, + _host = "", + _origin = None + ) + + def fromJsLocation(loc: org.scalajs.dom.Location): Location = + Location( + _url = loc.toString, + _hash = loc.hash, + _protocol = loc.protocol, + _search = loc.search, + _href = loc.href, + _hostname = loc.hostname, + _port = loc.port, + _pathname = loc.pathname, + _host = loc.host, + _origin = loc.origin.toOption + ) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index bc1e57ec..28c01459 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,50 @@ 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) => + // Updates the address bar + window.history.pushState(new js.Object, "", loc) + + // Invoke the page change + onMsg(router(Location.fromPath(loc))) + + () + + } + + "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 +107,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 +145,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 From 9b11491caaed225970f328f7cb60292d16c087b9 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 8 May 2023 20:31:19 +0100 Subject: [PATCH 10/24] Added an external link to the sandbox, which errors. --- sandbox/src/main/scala/example/Sandbox.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 70e591bc..4e9a4dff 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -313,6 +313,9 @@ object Sandbox extends MultiPage[Msg, Model]: 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") } ) From 847678da426992f290f5f5ff5e05c083ad3acfd2 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 9 May 2023 22:03:50 +0100 Subject: [PATCH 11/24] Added broken Location tests, ready to fix! --- sandbox/src/main/scala/example/Sandbox.scala | 32 +-- .../main/scala/tyrian/TyrianRoutedAppF.scala | 185 ++++++++++-------- .../main/scala/tyrian/runtime/Rendering.scala | 2 +- .../src/test/scala/tyrian/LocationTests.scala | 105 ++++++++++ 4 files changed, 226 insertions(+), 98 deletions(-) create mode 100644 tyrian/js/src/test/scala/tyrian/LocationTests.scala diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 4e9a4dff..95acd9d5 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -22,17 +22,21 @@ object Sandbox extends MultiPage[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 = loc => - loc.pathName match - 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.fullPath) - println(loc) - Msg.NavigateTo(Page.Page1) + def router: Location => Msg = + case loc: Location.Internal => + loc.path match + 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.fullPath) + println(loc) + Msg.NavigateTo(Page.Page1) + + case loc: Location.External => + Msg.NavigateToUrl(loc.href) val hotReloadKey: String = "hotreload" @@ -198,11 +202,14 @@ object Sandbox extends MultiPage[Msg, Model]: (model.copy(tmpSaveData = content), Cmd.None) case Msg.JumpToHomePage => - (model.copy(page = Page.Page1), Routing.setLocation(Page.Page1.toUrlPath)) + (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)) @@ -615,6 +622,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 diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index 61489a0b..c47fd550 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -35,7 +35,7 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model view(model) private def _subscriptions(model: Model): Sub[F, Msg] = - Routing.onLocationChange[F, Msg](router) |+| subscriptions(model) + Routing.onUrlChange[F, Msg](router) |+| subscriptions(model) override def ready(node: Element, flags: Map[String, String]): Unit = run( @@ -52,110 +52,125 @@ trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model object Routing: - def onLocationChange[F[_]: Async, Msg](router: Location => Msg): Sub[F, Msg] = + 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) ) - def setLocation[F[_]: Async](newLocation: String): Cmd[F, Nothing] = +object Nav: + + def pushUrl[F[_]: Async](url: String): Cmd[F, Nothing] = Cmd.SideEffect { - window.history.pushState("", "", newLocation) + window.history.pushState("", "", url) } -/** The Location interface represents the location of the object it is linked to. Changes done on it are reflected on - * the object it relates to. Both the Document and Window interface have such a linked Location, accessible via - * Document.location and Window.location respectively. - * - * This `Location` type is mostly a paired back version of Scala.js's: org.scalajs.dom.Location, right down to the - * scaladocs! However, field names have been changed to camel case to be more Scala consitent, and additional fields - * `url` and `fullPath` have been added for convenience. - */ -final case class Location( - private val _url: String, - private val _hash: String, - private val _protocol: String, - private val _search: String, - private val _href: String, - private val _hostname: String, - private val _port: String, - private val _pathname: String, - private val _host: String, - private val _origin: Option[String] -): - /** Is a String of the full rendered url address. */ - val url: String = _url + def loadUrl[F[_]: Async](href: String): Cmd[F, Nothing] = + Cmd.SideEffect { + window.location.href = href + } + +trait LocationDetails: /** Is a String of the full rendered url address, minus the origin. e.g. /my-page?id=12#anchor */ - val fullPath: String = - _origin match + def fullPath: String = + origin match case None => - _url + href case Some(o) => - _url.replaceFirst(o, "") + href.replaceFirst(o, "") - /** Is a String containing a '#' followed by the fragment identifier of the URL. */ - val hash: String = _hash + /** The anchor in the url starting with '#' followed by the fragment of the URL. */ + def hash: Option[String] - /** Is a String containing the protocol scheme of the URL, including the final ':'. */ - val protocol: String = _protocol + /** The protocol e.g. https:// */ + def protocol: Option[String] /** Is a String containing a '?' followed by the parameters of the URL. */ - val search: String = _search - - /** Is a String containing the whole URL. */ - val href: String = _href - - /** Is a String containing the domain of the URL. */ - val hostName: String = _hostname - - /** Is a String containing the port number of the URL. */ - val port: String = _port - - /** Is a String containing an initial '/' followed by the path of the URL. */ - val pathName: String = _pathname - - /** Is a String containing the host, that is the hostname, a ':', and the port of the URL. */ - val host: String = _host - - /** The origin read-only property is a String containing the Unicode serialization of the origin of the represented - * URL, that is, for http and https, the scheme followed by '://', followed by the domain, followed by ':', followed - * by the port (the default port, 80 and 443 respectively, if explicitly specified). For URL using file: scheme, the - * value is browser dependant. - * - * This property also does not exist consistently on IE, even as new as IE11, hence it must be optional. - */ - def origin: Option[String] = _origin + def search: Option[String] + + /** The whole URL. */ + def href: String = + origin.getOrElse("") + path + hash.getOrElse("") + search.getOrElse("") + + /** The whole URL. */ + def url: String = href + + /** The name of host, e.g. localhost. */ + def hostName: Option[String] + + /** Is the port number of the URL, e.g. 80. */ + def port: Option[String] + + /** Is the path minus hash anchors and query params, e.g. "/page1". */ + def path: String + + /** The host, e.g. localhost:8080. */ + def host: Option[String] = + for { + h <- hostName + p <- port + } yield s"$h:$p" + + /** The origin, e.g. http://localhost:8080. */ + def origin: Option[String] = + for { + pr <- protocol + ht <- host + } yield pr + ht + +enum Location: + + case Internal( + hash: Option[String], + hostName: Option[String], + path: String, + port: Option[String], + protocol: Option[String], + search: Option[String] + ) extends Location with LocationDetails + + case External( + hash: Option[String], + hostName: Option[String], + path: String, + port: Option[String], + protocol: Option[String], + search: Option[String] + ) extends Location with LocationDetails object Location: - // TODO! - def fromPath(path: String): Location = - Location( - _url = "", - _hash = "", - _protocol = "", - _search = "", - _href = "", - _hostname = "", - _port = "", - _pathname = path, - _host = "", - _origin = None - ) + extension (s: String) def optional: Option[String] = if s.isEmpty then None else Option(s) - def fromJsLocation(loc: org.scalajs.dom.Location): Location = - Location( - _url = loc.toString, - _hash = loc.hash, - _protocol = loc.protocol, - _search = loc.search, - _href = loc.href, - _hostname = loc.hostname, - _port = loc.port, - _pathname = loc.pathname, - _host = loc.host, - _origin = loc.origin.toOption + // TODO! + def fromUrl(path: String): Location = + fromUnknownUrl(path, fromJsLocation(window.location)) + + def fromUnknownUrl(path: String, currentLocation: Location.Internal): Location = + ??? + // Location.Internal( + // hash = location.hash.optional, + // host = location.host.optional, + // hostName = location.hostname.optional, + // href = location.href, + // origin = location.origin.toOption, + // path = location.pathname, + // port = location.port.optional, + // protocol = location.protocol.optional, + // search = location.search.optional + // ) + + /** Location instances created from JS Location's are assumed to be internal links. + */ + def fromJsLocation(location: org.scalajs.dom.Location): Location.Internal = + Location.Internal( + hash = location.hash.optional, + hostName = location.hostname.optional, + path = location.pathname, + port = location.port.optional, + protocol = location.protocol.optional, + search = location.search.optional ) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index 28c01459..842e7077 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -89,7 +89,7 @@ object Rendering: window.history.pushState(new js.Object, "", loc) // Invoke the page change - onMsg(router(Location.fromPath(loc))) + onMsg(router(Location.fromUrl(loc))) () 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..68eb13e8 --- /dev/null +++ b/tyrian/js/src/test/scala/tyrian/LocationTests.scala @@ -0,0 +1,105 @@ +package tyrian + +class LocationTests extends munit.FunSuite: + + val internal: Location.Internal = + Location.Internal( + hash = None, + hostName = Some("localhost"), + path = "/", + port = Some("8080"), + protocol = Some("https:"), + search = None + ) + + test("Internal location renders correctly") { + assertEquals(internal.href, "https://localhost:8080/") + } + + test("A more complicated location renders correctly") { + + val actual = + Location.Internal( + hash = Some("#fragment"), + hostName = Some("localhost"), + path = "/blog/posts/1234", + port = Some("8080"), + protocol = Some("https:"), + search = Some("?id=12&q=fish") + ) + + assertEquals(actual.href, "https://localhost:8080/blog/posts/1234#fragment?id=12&q=fish") + } + + val examples: Map[String, Location] = + Map( + "/" -> + Location.Internal( + hash = None, + hostName = None, + path = "/", + port = None, + protocol = None, + search = None + ), + "/page-1" -> + Location.Internal( + hash = None, + hostName = None, + path = "/page-1", + port = None, + protocol = None, + search = None + ) + // "foo/bar#baz" -> Location(), + // "/static/images/photo.jpg?width=100&height=50" -> Location(), + // "http://localhost:8080/page2" -> Location(), + // "https://www.example.com" -> Location(), + // "http://example.com" -> Location(), + // "ftp://ftp.example.com" -> Location(), + // "ssh://example.com:22" -> Location(), + // "telnet://example.com:23" -> Location(), + // "mailto:user@example.com" -> Location(), + // "https://example.com/path/to/page.html" -> Location(), + // "http://example.com/path/to/page.php?param1=value1¶m2=value2" -> Location(), + // "https://example.com#section1" -> Location(), + // "http://example.com:8080/path/to/page.html" -> Location(), + // "ftp://example.com:21/path/to/file.txt" -> Location(), + // "http://subdomain.example.com" -> Location(), + // "https://www.example.co.uk" -> Location(), + // "http://example.net/path/to/page.html" -> Location(), + // "ftp://ftp.example.net:21/path/to/file.txt" -> Location(), + // "https://example.org/path/to/page.php?param=value#section1" -> Location(), + // "http://example.org:8080/path/to/page.html?param=value#section1" -> Location(), + // "ftp://example.org:21/path/to/file.txt?param=value#section1" -> Location(), + // "http://localhost:8080/path/to/page.php?param=value#section1" -> Location(), + // "https://192.168.1.100:8443/path/to/page.html?param=value#section1" -> Location(), + // "http://www.example.com" -> Location(), + // "https://www.example.com" -> Location(), + // "ftp://ftp.example.com" -> Location(), + // "sftp://ftp.example.com" -> Location(), + // "ssh://example.com" -> Location(), + // "telnet://example.com" -> Location(), + // "mailto:user@example.com" -> Location(), + // "news://example.com" -> Location(), + // "gopher://example.com" -> Location(), + // "ldap://ldap.example.com" -> Location(), + // "smb://example.com/share/file.txt" -> Location(), + // "nfs://example.com/share/file.txt" -> Location(), + // "file:///path/to/local/file.html" -> Location(), + // "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" -> Location(), + // "irc://irc.example.com/channel" -> Location(), + // "dns://example.com" -> Location(), + // "xmpp:user@example.com" -> Location(), + // "magnet:?xt=urn:btih:ABCD1234" -> Location(), + // "steam://run/440" -> Location(), + // "magnet:?xt=urn:btih:ABCD1234" -> Location() + ) + + test("check example locations parse correctly") { + + examples.toList.foreach { case (url, loc) => + assertEquals(Location.fromUnknownUrl(url, internal), loc) + } + + } From e327c466ee2ccd8687ee86fcfbbceb4188a83a19 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 12 May 2023 08:27:18 +0100 Subject: [PATCH 12/24] WIP: LocationDetails --- .../main/scala/tyrian/LocationDetails.scala | 133 ++++++ .../scala/tyrian/LocationDetailsTests.scala | 446 ++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 tyrian/js/src/main/scala/tyrian/LocationDetails.scala create mode 100644 tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala 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..cce26784 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala @@ -0,0 +1,133 @@ +package tyrian + +final case class LocationDetails( + hash: Option[String], + hostName: Option[String], + pathName: String, + port: Option[String], + protocol: Option[String], + search: Option[String], + url: String +) + +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 def parsePath(path: String): LocationPathDetails = + path match + case "" => + LocationPathDetails(path, None, None) + + // TODO + + case pathOnly => + LocationPathDetails(pathOnly, 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]) + + /** 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, "") + + // /** The anchor in the url starting with '#' followed by the fragment of the URL. */ + // val hash: Option[String] = + // ??? + + // /** The host, e.g. localhost:8080. */ + // val host: Option[String] = + // for { + // h <- hostName + // p <- port + // } yield s"$h:$p" + + // /** The name of host, e.g. localhost. */ + // val hostName: Option[String] + + // /** The whole URL. */ + // val href: String + + // /** The origin, e.g. http://localhost:8080. */ + // val origin: Option[String] = + // for { + // pr <- protocol + // ht <- host + // } yield pr + ht + + // /** Is the path minus hash anchors and query params, e.g. "/page1". */ + // val path: String + + // /** Is the port number of the URL, e.g. 80. */ + // val port: Option[String] + + // /** The protocol e.g. https:// */ + // val protocol: Option[String] + + // /** Is a String containing a '?' followed by the parameters of the URL. */ + // val search: Option[String] + + // /** The whole URL. */ + // val url: String = href 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..c0896ca9 --- /dev/null +++ b/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala @@ -0,0 +1,446 @@ +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("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" + ) + ) From eff8baf8e1293582705b29cbdad958f3be393120 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 12 May 2023 08:41:11 +0100 Subject: [PATCH 13/24] LocationDetails path matching --- .../src/main/scala/tyrian/LocationDetails.scala | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/LocationDetails.scala b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala index cce26784..08e30686 100644 --- a/tyrian/js/src/main/scala/tyrian/LocationDetails.scala +++ b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala @@ -16,17 +16,26 @@ object LocationDetails: 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) - // TODO + case pathMatchAll(path, search, hash) => + LocationPathDetails(path, Option(search), Option(hash)) - case pathOnly => - LocationPathDetails(pathOnly, None, None) + 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 From 7e87ca0d7a33dfe048d5024cbc926e24400bd762 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 10:53:19 +0100 Subject: [PATCH 14/24] LocationDetails works --- .../main/scala/tyrian/LocationDetails.scala | 101 +++++++++--------- .../scala/tyrian/LocationDetailsTests.scala | 24 +++++ 2 files changed, 75 insertions(+), 50 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/LocationDetails.scala b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala index 08e30686..4c24384f 100644 --- a/tyrian/js/src/main/scala/tyrian/LocationDetails.scala +++ b/tyrian/js/src/main/scala/tyrian/LocationDetails.scala @@ -1,5 +1,22 @@ 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], @@ -8,13 +25,45 @@ final case class LocationDetails( 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 urlMatch = """^([a-z]+\:)(\/+)?([a-zA-Z0-9-\.\@]+)(:)?([0-9]+)?(.*)?""".r private val pathMatchAll = """(.*)(\?.*)(#.*)""".r private val pathMatchHash = """(.*)(#.*)""".r @@ -92,51 +141,3 @@ object LocationDetails: ) final case class LocationPathDetails(path: String, search: Option[String], hash: Option[String]) - - /** 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, "") - - // /** The anchor in the url starting with '#' followed by the fragment of the URL. */ - // val hash: Option[String] = - // ??? - - // /** The host, e.g. localhost:8080. */ - // val host: Option[String] = - // for { - // h <- hostName - // p <- port - // } yield s"$h:$p" - - // /** The name of host, e.g. localhost. */ - // val hostName: Option[String] - - // /** The whole URL. */ - // val href: String - - // /** The origin, e.g. http://localhost:8080. */ - // val origin: Option[String] = - // for { - // pr <- protocol - // ht <- host - // } yield pr + ht - - // /** Is the path minus hash anchors and query params, e.g. "/page1". */ - // val path: String - - // /** Is the port number of the URL, e.g. 80. */ - // val port: Option[String] - - // /** The protocol e.g. https:// */ - // val protocol: Option[String] - - // /** Is a String containing a '?' followed by the parameters of the URL. */ - // val search: Option[String] - - // /** The whole URL. */ - // val url: String = href diff --git a/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala b/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala index c0896ca9..6d6ff197 100644 --- a/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala +++ b/tyrian/js/src/test/scala/tyrian/LocationDetailsTests.scala @@ -23,6 +23,30 @@ class LocationDetailsTests extends munit.FunSuite: 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) => From ce8c3bbc8124e1c0c00194076ff515b65136324c Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 11:45:36 +0100 Subject: [PATCH 15/24] Location type working --- .../js/src/main/scala/tyrian/Location.scala | 44 +++++ .../src/test/scala/tyrian/LocationTests.scala | 157 ++++++++---------- 2 files changed, 114 insertions(+), 87 deletions(-) create mode 100644 tyrian/js/src/main/scala/tyrian/Location.scala 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..e785eb1e --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Location.scala @@ -0,0 +1,44 @@ +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 + +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/test/scala/tyrian/LocationTests.scala b/tyrian/js/src/test/scala/tyrian/LocationTests.scala index 68eb13e8..a063d260 100644 --- a/tyrian/js/src/test/scala/tyrian/LocationTests.scala +++ b/tyrian/js/src/test/scala/tyrian/LocationTests.scala @@ -2,104 +2,87 @@ package tyrian class LocationTests extends munit.FunSuite: - val internal: Location.Internal = - Location.Internal( - hash = None, - hostName = Some("localhost"), - path = "/", - port = Some("8080"), - protocol = Some("https:"), - search = None - ) - - test("Internal location renders correctly") { - assertEquals(internal.href, "https://localhost:8080/") + 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("A more complicated location renders correctly") { + 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( - hash = Some("#fragment"), - hostName = Some("localhost"), - path = "/blog/posts/1234", - port = Some("8080"), - protocol = Some("https:"), - search = Some("?id=12&q=fish") + LocationDetails( + hash = None, + hostName = Some("localhost"), + pathName = "/page4", + port = Some("8080"), + protocol = Some("https:"), + search = None, + url = "https://localhost:8080/page4" + ) ) - assertEquals(actual.href, "https://localhost:8080/blog/posts/1234#fragment?id=12&q=fish") + assertEquals(actual, expected) + } - val examples: Map[String, Location] = - Map( - "/" -> - Location.Internal( - hash = None, - hostName = None, - path = "/", - port = None, - protocol = None, - search = None - ), - "/page-1" -> - Location.Internal( + 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 = None, - path = "/page-1", + hostName = Some("indigoengine.io"), + pathName = "/docs", port = None, - protocol = None, - search = None + protocol = Some("https:"), + search = None, + url = "https://indigoengine.io/docs" ) - // "foo/bar#baz" -> Location(), - // "/static/images/photo.jpg?width=100&height=50" -> Location(), - // "http://localhost:8080/page2" -> Location(), - // "https://www.example.com" -> Location(), - // "http://example.com" -> Location(), - // "ftp://ftp.example.com" -> Location(), - // "ssh://example.com:22" -> Location(), - // "telnet://example.com:23" -> Location(), - // "mailto:user@example.com" -> Location(), - // "https://example.com/path/to/page.html" -> Location(), - // "http://example.com/path/to/page.php?param1=value1¶m2=value2" -> Location(), - // "https://example.com#section1" -> Location(), - // "http://example.com:8080/path/to/page.html" -> Location(), - // "ftp://example.com:21/path/to/file.txt" -> Location(), - // "http://subdomain.example.com" -> Location(), - // "https://www.example.co.uk" -> Location(), - // "http://example.net/path/to/page.html" -> Location(), - // "ftp://ftp.example.net:21/path/to/file.txt" -> Location(), - // "https://example.org/path/to/page.php?param=value#section1" -> Location(), - // "http://example.org:8080/path/to/page.html?param=value#section1" -> Location(), - // "ftp://example.org:21/path/to/file.txt?param=value#section1" -> Location(), - // "http://localhost:8080/path/to/page.php?param=value#section1" -> Location(), - // "https://192.168.1.100:8443/path/to/page.html?param=value#section1" -> Location(), - // "http://www.example.com" -> Location(), - // "https://www.example.com" -> Location(), - // "ftp://ftp.example.com" -> Location(), - // "sftp://ftp.example.com" -> Location(), - // "ssh://example.com" -> Location(), - // "telnet://example.com" -> Location(), - // "mailto:user@example.com" -> Location(), - // "news://example.com" -> Location(), - // "gopher://example.com" -> Location(), - // "ldap://ldap.example.com" -> Location(), - // "smb://example.com/share/file.txt" -> Location(), - // "nfs://example.com/share/file.txt" -> Location(), - // "file:///path/to/local/file.html" -> Location(), - // "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" -> Location(), - // "irc://irc.example.com/channel" -> Location(), - // "dns://example.com" -> Location(), - // "xmpp:user@example.com" -> Location(), - // "magnet:?xt=urn:btih:ABCD1234" -> Location(), - // "steam://run/440" -> Location(), - // "magnet:?xt=urn:btih:ABCD1234" -> Location() - ) - - test("check example locations parse correctly") { - - examples.toList.foreach { case (url, loc) => - assertEquals(Location.fromUnknownUrl(url, internal), loc) - } + ) + + assertEquals(actual, expected) } From 760e50542fbc2cc85a99785c0b1be48baf96073b Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 11:45:45 +0100 Subject: [PATCH 16/24] Clean up --- sandbox/src/main/scala/example/Sandbox.scala | 6 +- .../main/scala/tyrian/TyrianRoutedAppF.scala | 104 ------------------ .../main/scala/tyrian/runtime/Rendering.scala | 5 +- 3 files changed, 9 insertions(+), 106 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index 95acd9d5..bf8a21e0 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -24,7 +24,7 @@ object Sandbox extends MultiPage[Msg, Model]: // lib like: https://github.com/sherpal/url-dsl def router: Location => Msg = case loc: Location.Internal => - loc.path match + loc.fullPath match case "/page2" => Msg.NavigateTo(Page.Page2) case "/page3" => Msg.NavigateTo(Page.Page3) case "/page4" => Msg.NavigateTo(Page.Page4) @@ -60,6 +60,9 @@ object Sandbox extends MultiPage[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) @@ -646,6 +649,7 @@ enum Msg: case AddFruit case UpdateFruitInput(input: String) case ToggleFruitAvailability(name: String) + case NoOp enum Status: case Connecting diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala index c47fd550..bbcbad36 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala @@ -70,107 +70,3 @@ object Nav: Cmd.SideEffect { window.location.href = href } - -trait LocationDetails: - - /** Is a String of the full rendered url address, minus the origin. e.g. /my-page?id=12#anchor */ - def fullPath: String = - origin match - case None => - href - - case Some(o) => - href.replaceFirst(o, "") - - /** The anchor in the url starting with '#' followed by the fragment of the URL. */ - def hash: Option[String] - - /** The protocol e.g. https:// */ - def protocol: Option[String] - - /** Is a String containing a '?' followed by the parameters of the URL. */ - def search: Option[String] - - /** The whole URL. */ - def href: String = - origin.getOrElse("") + path + hash.getOrElse("") + search.getOrElse("") - - /** The whole URL. */ - def url: String = href - - /** The name of host, e.g. localhost. */ - def hostName: Option[String] - - /** Is the port number of the URL, e.g. 80. */ - def port: Option[String] - - /** Is the path minus hash anchors and query params, e.g. "/page1". */ - def path: String - - /** The host, e.g. localhost:8080. */ - def host: Option[String] = - for { - h <- hostName - p <- port - } yield s"$h:$p" - - /** The origin, e.g. http://localhost:8080. */ - def origin: Option[String] = - for { - pr <- protocol - ht <- host - } yield pr + ht - -enum Location: - - case Internal( - hash: Option[String], - hostName: Option[String], - path: String, - port: Option[String], - protocol: Option[String], - search: Option[String] - ) extends Location with LocationDetails - - case External( - hash: Option[String], - hostName: Option[String], - path: String, - port: Option[String], - protocol: Option[String], - search: Option[String] - ) extends Location with LocationDetails - -object Location: - - extension (s: String) def optional: Option[String] = if s.isEmpty then None else Option(s) - - // TODO! - def fromUrl(path: String): Location = - fromUnknownUrl(path, fromJsLocation(window.location)) - - def fromUnknownUrl(path: String, currentLocation: Location.Internal): Location = - ??? - // Location.Internal( - // hash = location.hash.optional, - // host = location.host.optional, - // hostName = location.hostname.optional, - // href = location.href, - // origin = location.origin.toOption, - // path = location.pathname, - // port = location.port.optional, - // protocol = location.protocol.optional, - // search = location.search.optional - // ) - - /** Location instances created from JS Location's are assumed to be internal links. - */ - def fromJsLocation(location: org.scalajs.dom.Location): Location.Internal = - Location.Internal( - hash = location.hash.optional, - hostName = location.hostname.optional, - path = location.pathname, - port = location.port.optional, - protocol = location.protocol.optional, - search = location.search.optional - ) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index 842e7077..d87699de 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -88,8 +88,11 @@ object Rendering: // Updates the address bar window.history.pushState(new js.Object, "", loc) + val jsLoc = Location.fromJsLocation(window.location) + val locationToRoute = Location.fromUrl(loc, jsLoc) + // Invoke the page change - onMsg(router(Location.fromUrl(loc))) + onMsg(router(locationToRoute)) () From c180ac32465f2ff59d10a596708589521b8f7605 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 14:14:59 +0100 Subject: [PATCH 17/24] Improve sandbox example --- sandbox/src/main/scala/example/Sandbox.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sandbox/src/main/scala/example/Sandbox.scala b/sandbox/src/main/scala/example/Sandbox.scala index bf8a21e0..73c9f90f 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -24,16 +24,17 @@ object Sandbox extends MultiPage[Msg, Model]: // lib like: https://github.com/sherpal/url-dsl def router: Location => Msg = case loc: Location.Internal => - loc.fullPath match + 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.fullPath) - println(loc) - Msg.NavigateTo(Page.Page1) + println("Unknown route: " + loc.url) + Msg.NoOp case loc: Location.External => Msg.NavigateToUrl(loc.href) From 0e90fac9533283cdeef4fd67d00e5412a9c66e03 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 14:15:12 +0100 Subject: [PATCH 18/24] Only pushstate on internal links --- tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index d87699de..a5dd23ef 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -85,12 +85,14 @@ object Rendering: () case Some(loc) => - // Updates the address bar - window.history.pushState(new js.Object, "", 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)) From 790fe848e6e7a64d5dba45b11a57da62c3d652ff Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 21:38:31 +0100 Subject: [PATCH 19/24] Formatting --- tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index a5dd23ef..af7b2aa7 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -85,7 +85,6 @@ object Rendering: () case Some(loc) => - val jsLoc = Location.fromJsLocation(window.location) val locationToRoute = Location.fromUrl(loc, jsLoc) From 8e8953f74bb7b5798ac5214bf5fef726f4dd77c8 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 22:57:05 +0100 Subject: [PATCH 20/24] Restore TyrianApp now includes routing requirement --- .../main/scala/example/IndigoSandbox.scala | 32 ++++++--- sandbox/src/main/scala/example/Sandbox.scala | 2 +- .../src/main/scala/tyrian/TyrianApp.scala | 17 +---- .../src/main/scala/tyrian/TyrianApp.scala | 23 +----- .../js/src/main/scala/tyrian/Location.scala | 2 + tyrian/js/src/main/scala/tyrian/Nav.scala | 22 ++++++ tyrian/js/src/main/scala/tyrian/Routing.scala | 30 ++++++++ .../js/src/main/scala/tyrian/TyrianAppF.scala | 42 +++++++++-- .../main/scala/tyrian/TyrianRoutedAppF.scala | 72 ------------------- 9 files changed, 116 insertions(+), 126 deletions(-) create mode 100644 tyrian/js/src/main/scala/tyrian/Nav.scala create mode 100644 tyrian/js/src/main/scala/tyrian/Routing.scala delete mode 100644 tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala diff --git a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala index 4d691342..a39d8f3b 100644 --- a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala +++ b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala @@ -3,7 +3,6 @@ package example import example.game.MyAwesomeGame import org.scalajs.dom.document import tyrian.Html.* -import tyrian.SinglePage import tyrian.* import tyrian.cmds.Logger import tyrian.cmds.Random @@ -13,17 +12,25 @@ import zio.interop.catz.* import scala.scalajs.js.annotation.* @JSExportTopLevel("TyrianApp") -object IndigoSandbox extends SinglePage[Msg, Model]: +object IndigoSandbox extends TyrianApp[Msg, Model]: val gameDivId1: String = "my-game-1" val gameDivId2: String = "my-game-2" val gameId1: IndigoGameId = IndigoGameId("reverse") val gameId2: IndigoGameId = IndigoGameId("combine") + def router: Location => Msg = Routing.basic(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) @@ -91,6 +98,11 @@ object IndigoSandbox extends SinglePage[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( @@ -123,13 +135,15 @@ object IndigoSandbox extends SinglePage[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 73c9f90f..5625ce92 100644 --- a/sandbox/src/main/scala/example/Sandbox.scala +++ b/sandbox/src/main/scala/example/Sandbox.scala @@ -18,7 +18,7 @@ import scala.util.Random import scalajs.js @JSExportTopLevel("TyrianApp") -object Sandbox extends MultiPage[Msg, Model]: +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 diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala index 07894ed5..a84c2ed6 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -3,28 +3,13 @@ package tyrian import cats.effect.IO import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global -import org.scalajs.dom.Element -import org.scalajs.dom.document import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime -import scala.scalajs.js.annotation._ - -/** The SinglePage 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. - */ -trait SinglePage[Msg, Model] extends TyrianAppF[IO, Msg, Model]: - - val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = - _.map(_.start()).useForever.unsafeRunAndForget() - /** The MultiPage 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. */ -trait MultiPage[Msg, Model] extends TyrianRoutedAppF[IO, Msg, Model]: +trait TyrianApp[Msg, Model] extends TyrianAppF[IO, Msg, Model]: val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = _.map(_.start()).useForever.unsafeRunAndForget() - -@deprecated("Please use SinglePage or MultiPage instead of TyrianApp.") -trait TyrianApp[Msg, Model] extends SinglePage[Msg, Model] diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index ba719335..0ca25bc7 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -2,34 +2,16 @@ package tyrian import cats.effect.Async import cats.effect.kernel.Resource -import cats.effect.unsafe.implicits.global -import org.scalajs.dom.Element -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 SinglePage 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. - */ -trait SinglePage[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: - - val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => - val runtime = Runtime.default - val runnable = res.map(_.start()).useForever - - Unsafe.unsafe { implicit unsafe => - runtime.unsafe.run(runnable).getOrThrowFiberFailure() - } - /** The MultiPage 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. */ -trait MultiPage[Msg, Model](using Async[Task]) extends TyrianRoutedAppF[Task, Msg, Model]: +trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: val run: Resource[Task, TyrianRuntime[Task, Model, Msg]] => Unit = res => val runtime = Runtime.default @@ -38,6 +20,3 @@ trait MultiPage[Msg, Model](using Async[Task]) extends TyrianRoutedAppF[Task, Ms Unsafe.unsafe { implicit unsafe => runtime.unsafe.run(runnable).getOrThrowFiberFailure() } - -@deprecated("Please use SinglePage or MultiPage instead of TyrianApp.") -trait TyrianApp[Msg, Model](using Async[Task]) extends SinglePage[Msg, Model] diff --git a/tyrian/js/src/main/scala/tyrian/Location.scala b/tyrian/js/src/main/scala/tyrian/Location.scala index e785eb1e..8c6da701 100644 --- a/tyrian/js/src/main/scala/tyrian/Location.scala +++ b/tyrian/js/src/main/scala/tyrian/Location.scala @@ -14,6 +14,8 @@ sealed trait Location: def locationDetails: LocationDetails def isInternal: Boolean def isExternal: Boolean + def href: String + def url: String object Location: 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..32214340 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Nav.scala @@ -0,0 +1,22 @@ +package tyrian + +import cats.effect.kernel.Async +import org.scalajs.dom.window + +object Nav: + + /** 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) + } + + /** 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 + } 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..cbff826d --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/Routing.scala @@ -0,0 +1,30 @@ +package tyrian + +import cats.effect.kernel.Async +import org.scalajs.dom.window + +object Routing: + + /** Provides ultra simple frontend router for convience in minimal use cases. + * + * @param internal + * A function that converts an href (url) to a Msg. + * @param external + * A function that converts an href (url) to a Msg. + */ + def basic[Msg](internal: String => Msg, external: String => Msg): Location => Msg = { + case loc @ Location.Internal(_) => internal(loc.href) + case loc @ Location.External(_) => external(loc.href) + } + + /** Provides ultra simple frontend router for convience in minimal use cases. + * + * @param noop + * A user defined 'no-op' Msg that means "ignore this link". + * @param external + * A function that converts an href (url) to a Msg. + */ + def basic[Msg](ignore: Msg, external: String => Msg): Location => Msg = { + case loc @ Location.Internal(_) => ignore + case loc @ Location.External(_) => external(loc.href) + } diff --git a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala index 867d8d2a..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,15 +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, - ???, // TODO - init(flags), - update, - view, - subscriptions, + router, + _init(flags), + _update, + _view, + _subscriptions, MaxConcurrentTasks ) ) diff --git a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala deleted file mode 100644 index bbcbad36..00000000 --- a/tyrian/js/src/main/scala/tyrian/TyrianRoutedAppF.scala +++ /dev/null @@ -1,72 +0,0 @@ -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._ - -/** 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. - */ -trait TyrianRoutedAppF[F[_]: Async, Msg, Model] extends TyrianAppF[F, Msg, Model]: - - def router: Location => Msg - - 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 _subscriptions(model: Model): Sub[F, Msg] = - Routing.onUrlChange[F, Msg](router) |+| subscriptions(model) - - override def ready(node: Element, flags: Map[String, String]): Unit = - run( - Tyrian.start( - node, - router, - _init(flags), - _update, - _view, - _subscriptions, - MaxConcurrentTasks - ) - ) - -object Routing: - - 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) - ) - -object Nav: - - def pushUrl[F[_]: Async](url: String): Cmd[F, Nothing] = - Cmd.SideEffect { - window.history.pushState("", "", url) - } - - def loadUrl[F[_]: Async](href: String): Cmd[F, Nothing] = - Cmd.SideEffect { - window.location.href = href - } From fc86ac4fc8ebac9192799860f23ae141e802ccc5 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Sat, 13 May 2023 23:10:17 +0100 Subject: [PATCH 21/24] Updating Routing --- .../main/scala/example/IndigoSandbox.scala | 2 +- tyrian/js/src/main/scala/tyrian/Routing.scala | 40 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala index a39d8f3b..570208ab 100644 --- a/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala +++ b/indigo-sandbox/src/main/scala/example/IndigoSandbox.scala @@ -19,7 +19,7 @@ object IndigoSandbox extends TyrianApp[Msg, Model]: val gameId1: IndigoGameId = IndigoGameId("reverse") val gameId2: IndigoGameId = IndigoGameId("combine") - def router: Location => Msg = Routing.basic(Msg.NoOp, Msg.FollowLink(_)) + 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)) diff --git a/tyrian/js/src/main/scala/tyrian/Routing.scala b/tyrian/js/src/main/scala/tyrian/Routing.scala index cbff826d..a0642bfd 100644 --- a/tyrian/js/src/main/scala/tyrian/Routing.scala +++ b/tyrian/js/src/main/scala/tyrian/Routing.scala @@ -7,24 +7,36 @@ object Routing: /** Provides ultra simple frontend router for convience in minimal use cases. * - * @param internal - * A function that converts an href (url) to a Msg. - * @param external - * A function that converts an href (url) to a Msg. + * @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](internal: String => Msg, external: String => Msg): Location => Msg = { - case loc @ Location.Internal(_) => internal(loc.href) - case loc @ Location.External(_) => external(loc.href) + 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 ultra simple frontend router for convience in minimal use cases. + /** Provides ultra simple frontend router that ignores internal links to the app's own website, but allows you to + * follow 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. * - * @param noop - * A user defined 'no-op' Msg that means "ignore this link". - * @param external - * A function that converts an href (url) to a Msg. + * @param ignore + * A user defined 'no-op' Msg that means "ignore this link/href". */ - def basic[Msg](ignore: Msg, external: String => Msg): Location => Msg = { + def none[Msg](ignore: Msg): Location => Msg = { case loc @ Location.Internal(_) => ignore - case loc @ Location.External(_) => external(loc.href) + case loc @ Location.External(_) => ignore } From ca077c7c5d57a2ff7f88fb029e95a6b57fd1462a Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 15 May 2023 19:51:48 +0100 Subject: [PATCH 22/24] Fixed scaladocs --- tyrian-io/src/main/scala/tyrian/TyrianApp.scala | 2 +- tyrian-zio/src/main/scala/tyrian/TyrianApp.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala index a84c2ed6..89d7501f 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -6,7 +6,7 @@ import cats.effect.unsafe.implicits.global import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime -/** The MultiPage trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well +/** 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. */ trait TyrianApp[Msg, Model] extends TyrianAppF[IO, Msg, Model]: diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index 0ca25bc7..591bda77 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -8,7 +8,7 @@ import zio.Runtime import zio.Task import zio.Unsafe -/** The MultiPage trait can be extended to conveniently prompt you for all the methods needed for a Tyrian app, as well +/** 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. */ trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Model]: From ce046a571f7c2565968e74d6d45658474a15daa9 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 16 May 2023 07:43:40 +0100 Subject: [PATCH 23/24] Added an `all` Router + more scaladocs --- tyrian/js/src/main/scala/tyrian/Routing.scala | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/Routing.scala b/tyrian/js/src/main/scala/tyrian/Routing.scala index a0642bfd..f55f33a4 100644 --- a/tyrian/js/src/main/scala/tyrian/Routing.scala +++ b/tyrian/js/src/main/scala/tyrian/Routing.scala @@ -3,9 +3,25 @@ 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 ultra simple frontend router for convience in minimal use cases. + /** 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. @@ -17,8 +33,8 @@ object Routing: case loc @ Location.External(_) => externalHRef(loc.href) } - /** Provides ultra simple frontend router that ignores internal links to the app's own website, but allows you to - * follow links to other sites. + /** 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". @@ -31,7 +47,7 @@ object Routing: } /** Provides a frontend router that ignores and deactivates all links fired by the app. In other words, no `` - * style links will work. + * 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". From fa65c9619cdffe8b29fb502c85ef73879745c675 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 16 May 2023 07:52:08 +0100 Subject: [PATCH 24/24] Nav forward and back + scaladocs --- tyrian/js/src/main/scala/tyrian/Nav.scala | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tyrian/js/src/main/scala/tyrian/Nav.scala b/tyrian/js/src/main/scala/tyrian/Nav.scala index 32214340..5a814fa2 100644 --- a/tyrian/js/src/main/scala/tyrian/Nav.scala +++ b/tyrian/js/src/main/scala/tyrian/Nav.scala @@ -3,14 +3,23 @@ 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: - /** 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. + /** Move back one in the browser's history. */ - def pushUrl[F[_]: Async](url: String): Cmd[F, Nothing] = + def back[F[_]: Async]: Cmd[F, Nothing] = Cmd.SideEffect { - window.history.pushState("", "", url) + 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 @@ -20,3 +29,11 @@ object Nav: 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) + }