diff --git a/examples/.tyrian-version b/examples/.tyrian-version index b6160487..7d06ba07 100644 --- a/examples/.tyrian-version +++ b/examples/.tyrian-version @@ -1 +1 @@ -0.6.2 +0.6.3-SNAPSHOT diff --git a/examples/build.sbt b/examples/build.sbt index df2bfd7f..ff0ae43c 100644 --- a/examples/build.sbt +++ b/examples/build.sbt @@ -123,6 +123,14 @@ lazy val indigo = ) ) +lazy val mainlauncher = + (project in file("main-launcher")) + .enablePlugins(ScalaJSPlugin) + .settings(commonSettings: _*) + .settings(name := "main-launcher", + Compile / mainClass := Some("example.Main"), + scalaJSUseMainModuleInitializer := true) + lazy val mario = (project in file("mario")) .enablePlugins(ScalaJSPlugin) @@ -190,6 +198,7 @@ lazy val exampleProjects: List[String] = "http", "http4sdom", "indigo", + "mainlauncher", "mario", "nonpm", "subcomponents", diff --git a/examples/main-launcher/README.md b/examples/main-launcher/README.md new file mode 100644 index 00000000..3520e722 --- /dev/null +++ b/examples/main-launcher/README.md @@ -0,0 +1,19 @@ +# Tyrian main launcher example + +This is a minimal working project setup to run the main launcher example. + +To run the program in a browser you will need to have yarn (or npm) installed. + +On first run: + +```sh +yarn install +``` + +and from then on + +```sh +yarn start +``` + +Then navigate to [http://localhost:1234/](http://localhost:1234/) diff --git a/examples/main-launcher/index.html b/examples/main-launcher/index.html new file mode 100644 index 00000000..f499f62c --- /dev/null +++ b/examples/main-launcher/index.html @@ -0,0 +1,16 @@ + + + + + + Main Launcher Example + + + + +

Main launcher example

+
+
+ + + diff --git a/examples/main-launcher/package.json b/examples/main-launcher/package.json new file mode 100644 index 00000000..e0bd4ba5 --- /dev/null +++ b/examples/main-launcher/package.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "start": "parcel index.html --no-cache --dist-dir dist --log-level info", + "build": "parcel build index.html --dist-dir dist --log-level info" + }, + "devDependencies": { + "parcel": "^2.1.0", + "process": "^0.11.10" + } +} diff --git a/examples/main-launcher/src/main/scala/example/ChatApp.scala b/examples/main-launcher/src/main/scala/example/ChatApp.scala new file mode 100644 index 00000000..f209b2b6 --- /dev/null +++ b/examples/main-launcher/src/main/scala/example/ChatApp.scala @@ -0,0 +1,43 @@ +package example + +import cats.effect.IO +import tyrian.Html.* +import tyrian.* + +import scala.scalajs.js +import scala.scalajs.js.annotation.* + +object ChatApp extends TyrianApp[ChatAppMsg, ChatAppModel]: + + def init(flags: Map[String, String]): (ChatAppModel, Cmd[IO, ChatAppMsg]) = + val initialChat = flags.get("InitialMessage").getOrElse("") + (ChatAppModel(chatInput = initialChat, messages = Seq()), Cmd.None) + + def update(model: ChatAppModel): ChatAppMsg => (ChatAppModel, Cmd[IO, ChatAppMsg]) = + case ChatInput(input) => (model.copy(chatInput = input), Cmd.None) + case SendChat() => (model.copy(chatInput = "", messages = model.messages :+ model.chatInput), Cmd.None) + case NoOp() => (model, Cmd.None) + + def view(model: ChatAppModel): Html[ChatAppMsg] = + div( + ul()( + for { message <- model.messages.toList } + yield li()(message) + ), + input(onInput(ChatInput.apply), value := model.chatInput), + button(onClick(SendChat()))("Send Chat") + ) + + def router: Location => ChatAppMsg = + _ => NoOp() + + def subscriptions(model: ChatAppModel): Sub[IO, ChatAppMsg] = + Sub.None + +case class ChatAppModel(chatInput: String, messages: Seq[String]) + +sealed abstract class ChatAppMsg +case class NoOp() extends ChatAppMsg +case class ChatInput(input: String) extends ChatAppMsg +case class SendChat() extends ChatAppMsg + diff --git a/examples/main-launcher/src/main/scala/example/CounterApp.scala b/examples/main-launcher/src/main/scala/example/CounterApp.scala new file mode 100644 index 00000000..603933bc --- /dev/null +++ b/examples/main-launcher/src/main/scala/example/CounterApp.scala @@ -0,0 +1,46 @@ +package example + +import cats.effect.IO +import tyrian.Html.* +import tyrian.* + +import scala.scalajs.js.annotation.* +import scala.util.Try + +object CounterApp extends TyrianApp[Msg, Model]: + + def init(flags: Map[String, String]): (Model, Cmd[IO, Msg]) = + val initialValue: Option[Int] = for { + initialCounter <- flags.get("InitialCounter") + initialCounterInt <- Try(initialCounter.toInt).toOption + } yield initialCounterInt + (Model(initialValue.getOrElse(0)), Cmd.None) + + def update(model: Model): Msg => (Model, Cmd[IO, Msg]) = + case Msg.Increment => (model + 1, Cmd.None) + case Msg.Decrement => (model - 1, Cmd.None) + case Msg.NoOp => (model, Cmd.None) + + def view(model: Model): Html[Msg] = + div( + button(onClick(Msg.Decrement))("-"), + div(model.toString), + button(onClick(Msg.Increment))("+") + ) + + def router: Location => Msg = + _ => Msg.NoOp + + def subscriptions(model: Model): Sub[IO, Msg] = + Sub.None + +opaque type Model = Int +object Model: + def apply(value: Int): Model = value + + extension (i: Model) + def +(other: Int): Model = i + other + def -(other: Int): Model = i - other + +enum Msg: + case Increment, Decrement, NoOp diff --git a/examples/main-launcher/src/main/scala/example/Main.scala b/examples/main-launcher/src/main/scala/example/Main.scala new file mode 100644 index 00000000..a726eb14 --- /dev/null +++ b/examples/main-launcher/src/main/scala/example/Main.scala @@ -0,0 +1,13 @@ +package example + +import cats.effect.IO +import tyrian.Html.* +import tyrian.* + +object Main { + def main(args: Array[String]): Unit = + TyrianApp.onLoad( + "CounterApp" -> CounterApp, + "ChatApp" -> ChatApp + ) +} \ No newline at end of file diff --git a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala index 89d7501f..4b69ff27 100644 --- a/tyrian-io/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-io/src/main/scala/tyrian/TyrianApp.scala @@ -1,6 +1,7 @@ package tyrian import cats.effect.IO +import cats.effect.kernel.Async import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global import tyrian.TyrianAppF @@ -13,3 +14,10 @@ trait TyrianApp[Msg, Model] extends TyrianAppF[IO, Msg, Model]: val run: Resource[IO, TyrianRuntime[IO, Model, Msg]] => Unit = _.map(_.start()).useForever.unsafeRunAndForget() + +object TyrianApp: + def onLoad(appDirectory: (String, TyrianAppF[IO, _, _])*): Unit = + TyrianAppF.onLoad(appDirectory: _*) + + def launch(appDirectory: Map[String, TyrianAppF[IO, _, _]]): Unit = + TyrianAppF.launch(appDirectory) diff --git a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala index 591bda77..c2fcfe57 100644 --- a/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala +++ b/tyrian-zio/src/main/scala/tyrian/TyrianApp.scala @@ -1,6 +1,7 @@ package tyrian import cats.effect.Async +import cats.effect.kernel.Async import cats.effect.kernel.Resource import tyrian.TyrianAppF import tyrian.runtime.TyrianRuntime @@ -20,3 +21,10 @@ trait TyrianApp[Msg, Model](using Async[Task]) extends TyrianAppF[Task, Msg, Mod Unsafe.unsafe { implicit unsafe => runtime.unsafe.run(runnable).getOrThrowFiberFailure() } + +object TyrianApp: + def onLoad(appDirectory: (String, TyrianAppF[Task, _, _])*)(using Async[Task]): Unit = + TyrianAppF.onLoad(appDirectory: _*) + + def launch(appDirectory: Map[String, TyrianAppF[Task, _, _]])(using Async[Task]): Unit = + TyrianAppF.launch(appDirectory) diff --git a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala index 497d816d..e32f1aee 100644 --- a/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala +++ b/tyrian/js/src/main/scala/tyrian/TyrianAppF.scala @@ -2,11 +2,14 @@ package tyrian import cats.effect.kernel.Async import cats.effect.kernel.Resource +import org.scalajs.dom.DocumentReadyState import org.scalajs.dom.Element +import org.scalajs.dom.HTMLElement import org.scalajs.dom.document import org.scalajs.dom.window import tyrian.runtime.TyrianRuntime +import scala.scalajs.js.Promise 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 @@ -127,3 +130,49 @@ trait TyrianAppF[F[_]: Async, Msg, Model]: case None => throw new Exception(s"Missing Element! Could not find an element with id '$containerId' on the page.") + +object TyrianAppF: + /** Launch app instances after DOMContentLoaded. + */ + def onLoad[F[_] : Async](appDirectory: Map[String, TyrianAppF[F, _, _]]): Unit = + val documentReady = new Promise((resolve, _reject) => { + document.addEventListener("DOMContentLoaded", _ => { + resolve(()) + }) + if (document.readyState != DocumentReadyState.loading) { + resolve(()) + } + }) + documentReady.`then`(_ => { + launch[F](appDirectory) + }) + + def onLoad[F[_] : Async](appDirectory: (String, TyrianAppF[F, _, _])*): Unit = + onLoad(appDirectory.toMap) + + /** Find data-tyrian-app HTMLElements and launch corresponding TyrianAppF instances + */ + def launch[F[_] : Async](appDirectory: Map[String, TyrianAppF[F, _, _]]): Unit = + for { + element <- document.querySelectorAll("[data-tyrian-app]") + } yield { + val tyrianAppElement = element.asInstanceOf[HTMLElement] + val tyrianAppName = tyrianAppElement.dataset.get("tyrianApp") + val appSupplierOption = for { + appName <- tyrianAppName + appSupplier <- appDirectory.get(appName) + } yield appSupplier + appSupplierOption match + case Some(appSupplier) => + appSupplier.launch(tyrianAppElement, appElementFlags(tyrianAppElement)) + case _ => + println(s"Could not find an app entry for ${tyrianAppName.getOrElse("")}") + } + + private def appElementFlags(tyrianAppElement: HTMLElement): Map[String,String] = + val appFlags = for { + (dataAttr, attrValue) <- tyrianAppElement.dataset + if dataAttr.startsWith("tyrianFlag") + flagName = dataAttr.replaceFirst("^tyrianFlag", "") + } yield (flagName, attrValue) + appFlags.toMap \ No newline at end of file