Complete redesign to allow assigns style state

This commit is contained in:
Paul-Henri Froidmont 2025-08-17 21:55:13 +02:00
parent ae0dc04a9e
commit bb062b9679
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
18 changed files with 802 additions and 739 deletions

View file

@ -7,7 +7,7 @@ import mill.api.Task.Simple
trait Common extends ScalaModule: trait Common extends ScalaModule:
def scalaVersion = "3.7.2" def scalaVersion = "3.7.2"
def scalacOptions = Seq("-Wunused:all") def scalacOptions = Seq("-Wunused:all", "-preview", "-feature", "-language:implicitConversions")
object core extends Common: object core extends Common:
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44") def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")

View file

@ -1,50 +0,0 @@
package scalive
import zio.Chunk
import zio.json.JsonEncoder
import zio.json.ast.Json
enum Diff:
case Tag(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty)
case Split(
static: Seq[String] = Seq.empty,
entries: Seq[Diff.Dynamic] = Seq.empty)
case Static(value: String)
case Dynamic(index: Int, diff: Diff)
case Deleted
object Diff:
given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson)
private def toJson(diff: Diff): Json =
diff match
case Diff.Tag(static, dynamic) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
dynamic.map(d => d.index.toString -> toJson(d.diff))
)
)
case Diff.Split(static, entries) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
Option.when(entries.nonEmpty)(
"d" ->
Json.Obj(
entries.map(d => d.index.toString -> toJson(d.diff))*
)
)
)
)
case Diff.Static(value) => Json.Str(value)
case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> toJson(diff))
case Diff.Deleted => Json.Bool(false)
end Diff

View file

@ -1,71 +1,53 @@
package scalive package scalive
import zio.Chunk
import zio.json.ast.Json
object DiffBuilder: object DiffBuilder:
def build( def build(rendered: Rendered, fingerprint: Fingerprint): Json =
static: Seq[String], val nestedFingerprintIter = fingerprint.nested.iterator
dynamic: Seq[LiveDyn[?]], Json.Obj(
includeUnchanged: Boolean = false Option
): Diff.Tag = .when(rendered.static.nonEmpty && fingerprint.value != rendered.fingerprint)(
Diff.Tag( "s" -> Json.Arr(rendered.static.map(Json.Str(_))*)
static = static,
dynamic = dynamic.zipWithIndex
.filter(includeUnchanged || _._1.wasUpdated)
.map {
case (v: LiveDyn.Value[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: LiveDyn.When[?], i) => build(v, i, includeUnchanged)
case (v: LiveDyn.Split[?, ?], i) =>
Diff.Dynamic(i, build(v, includeUnchanged))
}
)
private def build(
mod: LiveDyn.When[?],
index: Int,
includeUnchanged: Boolean
): Diff.Dynamic =
if mod.displayed then
if includeUnchanged || mod.cond.wasUpdated then
Diff.Dynamic(
index,
build(
mod.nested.static,
mod.nested.dynamic,
includeUnchanged = true
)
)
else
Diff.Dynamic(
index,
build(
static = Seq.empty,
mod.nested.dynamic,
includeUnchanged
)
)
else Diff.Dynamic(index, Diff.Deleted)
private def build(
mod: LiveDyn.Split[?, ?],
includeUnchanged: Boolean
): Diff.Split =
Diff.Split(
static = if includeUnchanged then mod.static else Seq.empty,
entries = mod.dynamic.toList.zipWithIndex
.filter(includeUnchanged || _._1.exists(_.wasUpdated))
.map[Diff.Dynamic]((mods, i) =>
Diff.Dynamic(
i,
build(
static = Seq.empty,
dynamic = mods,
includeUnchanged
)
)
) )
.to(Chunk)
.appendedAll( .appendedAll(
mod.removedIndexes rendered.dynamic.zipWithIndex
.map(i => Diff.Dynamic(i, Diff.Deleted)) .map((render, index) => index.toString -> build(render(true), nestedFingerprintIter))
).filterNot(_._2 == Json.Obj.empty)
)
private def build(comp: Comprehension, fingerprint: Fingerprint): Json =
val nestedFingerprintIter = fingerprint.nested.iterator
Json.Obj(
Option
.when(comp.static.nonEmpty && fingerprint.value != comp.fingerprint)(
"s" -> Json.Arr(comp.static.map(Json.Str(_))*)
)
.to(Chunk)
.appendedAll(
Option.when(comp.entries.nonEmpty)(
"d" ->
Json.Arr(
comp.entries.map(render =>
Json.Obj(
render(true).zipWithIndex
.map((dyn, index) =>
index.toString -> build(dyn, nestedFingerprintIter)
).filterNot(_._2 == Json.Obj.empty)*
)
)*
) )
) )
)
)
private def build(dyn: RenderedDyn, fingerprintIter: Iterator[Fingerprint]): Json =
dyn match
case Some(s: String) => Json.Str(s)
case Some(r: Rendered) => build(r, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
case Some(c: Comprehension) =>
build(c, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
case None => Json.Obj.empty
end DiffBuilder end DiffBuilder

View file

@ -0,0 +1,53 @@
package scalive
import java.nio.ByteBuffer
import java.security.MessageDigest
final case class Fingerprint(value: Long, nested: Seq[Fingerprint])
object Fingerprint:
val empty = Fingerprint(0, Seq.empty)
def apply(rendered: Rendered): Fingerprint =
Fingerprint(
rendered.fingerprint,
rendered.dynamic.flatMap(render => apply(render(false)))
)
def apply(comp: Comprehension): Fingerprint = Fingerprint(comp.fingerprint, Seq.empty)
def apply(dyn: RenderedDyn): Option[Fingerprint] =
dyn match
case Some(r: Rendered) => Some(apply(r))
case Some(c: Comprehension) => Some(apply(c))
case Some(_: String) => None
case None => None
val dynText = digest("text")
val dynAttr = digest("attr")
val dynAttrValueAsPresence = digest("attrValueAsPresence")
val dynWhen = digest("when")
val dynSplit = digest("split")
def digest(s: String): Array[Byte] =
MessageDigest.getInstance("MD5").digest(s.getBytes)
def digest(el: HtmlElement): Array[Byte] =
val md = MessageDigest.getInstance("MD5")
el.static.foreach(s => md.update(s.getBytes))
el.mods.foreach {
case Mod.Text(_) => ()
case Mod.StaticAttr(_, _) => ()
case Mod.StaticAttrValueAsPresence(_, _) => ()
case Mod.DynAttr(_, _) => md.update(Fingerprint.dynAttr)
case Mod.DynAttrValueAsPresence(_, _) => md.update(Fingerprint.dynAttrValueAsPresence)
case Mod.DynText(_) => md.update(Fingerprint.dynText)
case Mod.When(_, _) => md.update(Fingerprint.dynWhen)
case Mod.Split(_, project) => md.update(digest(project(Dyn.dummy)))
case Mod.Tag(el) => Right(digest(el))
}
md.digest()
def apply(el: HtmlElement): Long = digestToFingerprint(digest(el))
private def digestToFingerprint(digest: Array[Byte]): Long =
ByteBuffer.wrap(digest, 0, 8).getLong
end Fingerprint

View file

@ -4,27 +4,25 @@ import java.io.StringWriter
object HtmlBuilder: object HtmlBuilder:
def build(lv: LiveView[?]): String = def build(rendered: Rendered, isRoot: Boolean = false): String =
val strw = new StringWriter() val strw = new StringWriter()
build(lv.static, lv.dynamic, strw) if isRoot then strw.append("<!doctype html>")
build(rendered.static, rendered.dynamic, strw)
strw.toString() strw.toString()
private def build( private def build(
static: Seq[String], static: Seq[String],
dynamic: Seq[LiveDyn[?]], dynamic: Seq[Boolean => RenderedDyn],
strw: StringWriter strw: StringWriter
): Unit = ): Unit =
for i <- dynamic.indices do for i <- dynamic.indices do
strw.append(static(i)) strw.append(static(i))
dynamic(i) match dynamic(i)(false).foreach {
case mod: LiveDyn.Value[?, ?] => case s: String => strw.append(s)
strw.append(mod.currentValue.toString) case r: Rendered => build(r)
case mod: LiveDyn.When[?] => build(mod, strw) case c: Comprehension => build(c, strw)
case mod: LiveDyn.Split[?, ?] => build(mod, strw) }
strw.append(static.last) strw.append(static.last)
private def build(mod: LiveDyn.When[?], strw: StringWriter): Unit = private def build(comp: Comprehension, strw: StringWriter): Unit =
if mod.displayed then build(mod.nested.static, mod.nested.dynamic, strw) comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw))
private def build(mod: LiveDyn.Split[?, ?], strw: StringWriter): Unit =
mod.dynamic.foreach(entry => build(mod.static, entry, strw))

View file

@ -0,0 +1,68 @@
package scalive
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
lazy val static = Rendered.buildStatic(this)
class HtmlTag(val name: String, val void: Boolean = false):
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
def :=(value: V): Mod =
if isBooleanAsAttrPresence then
Mod.StaticAttrValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
value.asInstanceOf[Boolean]
)
else Mod.StaticAttr(this, codec.encode(value))
def :=(value: Dyn[V]): Mod =
if isBooleanAsAttrPresence then
Mod.DynAttrValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
value.asInstanceOf[Dyn[Boolean]]
)
else Mod.DynAttr(this, value)
enum Mod:
case Text(text: String)
case StaticAttr(attr: HtmlAttr[?], value: String)
case StaticAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean)
case DynAttr[T](attr: HtmlAttr[T], value: Dyn[T])
case DynAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Dyn[Boolean])
case Tag(el: HtmlElement)
case DynText(dyn: Dyn[String])
case When(dyn: Dyn[Boolean], el: HtmlElement)
case Split[T](
dynList: Dyn[List[T]],
project: Dyn[T] => HtmlElement)
given [T]: Conversion[HtmlElement, Mod] = Mod.Tag(_)
given [T]: Conversion[String, Mod] = Mod.Text(_)
given [T]: Conversion[Dyn[String], Mod] = Mod.DynText(_)
final case class Dyn[T](key: LiveState.Key, f: key.Type => T):
def render(state: LiveState, trackUpdates: Boolean): Option[T] =
val entry = state(key)
if !trackUpdates | entry.changed then Some(f(entry.value))
else None
def map[T2](f2: T => T2): Dyn[T2] = Dyn(key, f.andThen(f2))
inline def apply[T2](f2: T => T2): Dyn[T2] = map(f2)
def when(f2: T => Boolean)(el: HtmlElement): Mod.When = Mod.When(map(f2), el)
inline def whenNot(f2: T => Boolean)(el: HtmlElement): Mod.When =
when(f2.andThen(!_))(el)
extension [T](dyn: Dyn[List[T]])
def splitByIndex(project: Dyn[T] => HtmlElement): Mod.Split[T] =
Mod.Split(dyn, project)
object Dyn:
def dummy[T] = Dyn(LiveState.Key[T], identity)

View file

@ -1,68 +0,0 @@
package scalive
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ArrayBuffer
sealed trait LiveDyn[Model]:
def update(model: Model): Unit
def wasUpdated: Boolean
object LiveDyn:
class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) extends LiveDyn[I]:
private var value: O = d.run(init)
private var updated: Boolean = startsUpdated
def wasUpdated: Boolean = updated
def currentValue: O = value
def update(v: I): Unit =
val newValue = d.run(v)
if value == newValue then updated = false
else
value = newValue
updated = true
class When[Model](
dynCond: Dyn[Model, Boolean],
el: HtmlElement[Model],
init: Model)
extends LiveDyn[Model]:
val cond = LiveDyn.Value(dynCond, init)
val nested = LiveView.render(el, init)
def displayed: Boolean = cond.currentValue
def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated
def update(model: Model): Unit =
cond.update(model)
nested.update(model)
class Split[Model, Item](
dynList: Dyn[Model, List[Item]],
project: Dyn[Item, Item] => HtmlElement[Item],
init: Model)
extends LiveDyn[Model]:
private val el = project(Dyn.id)
val static: ArraySeq[String] = LiveView.buildStatic(el)
val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] =
dynList
.run(init)
.map(LiveView.buildDynamic(el, _).to(ArraySeq))
.to(ArrayBuffer)
var removedIndexes: Seq[Int] = Seq.empty
def wasUpdated: Boolean =
removedIndexes.nonEmpty || dynamic.exists(_.exists(_.wasUpdated))
def update(model: Model): Unit =
val items = dynList.run(model)
removedIndexes =
if items.size < dynamic.size then items.size until dynamic.size
else Seq.empty
dynamic.takeInPlace(items.size)
items.zipWithIndex.map((item, i) =>
if i >= dynamic.size then
dynamic.append(
LiveView.buildDynamic(el, item, startsUpdated = true).to(ArraySeq)
)
else dynamic(i).foreach(_.update(item))
)
end Split
end LiveDyn

View file

@ -0,0 +1,31 @@
package scalive
final case class LiveState private (val data: Map[LiveState.Key, LiveState.Entry[Any]]):
def get(k: LiveState.Key): Option[LiveState.Entry[k.Type]] =
data.get(k).asInstanceOf[Option[LiveState.Entry[k.Type]]]
def set(k: LiveState.Key, v: k.Type): LiveState =
copy(data = data.updated(k, LiveState.Entry(true, v)))
def apply(k: LiveState.Key): LiveState.Entry[k.Type] = get(k).get
def update(k: LiveState.Key, update: k.Type => k.Type): LiveState =
copy(data =
data.updatedWith(k)(
_.asInstanceOf[Option[LiveState.Entry[k.Type]]]
.map(e => LiveState.Entry(true, update(e.value)))
)
)
def remove(k: LiveState.Key): LiveState = copy(data = data - k)
def setAllUnchanged: LiveState =
LiveState(data.view.mapValues(_.copy(changed = false)).toMap)
object LiveState:
final case class Entry[T](changed: Boolean, value: T)
val empty = LiveState(Map.empty)
class Key:
type Type
def id: Dyn[Type] = Dyn(this, identity)
def apply[T](f: Type => T): Dyn[T] = Dyn(this, f)
object Key:
def apply[T] = new Key:
type Type = T

View file

@ -1,114 +1,4 @@
package scalive package scalive
import scala.annotation.nowarn trait LiveView:
import scala.collection.immutable.ArraySeq def render: HtmlElement
import scala.collection.mutable.ListBuffer
import scalive.codecs.BooleanAsAttrPresenceCodec
class LiveView[Model] private (
val static: ArraySeq[String],
val dynamic: ArraySeq[LiveDyn[Model]]):
assert(
static.size == dynamic.size + 1,
s"Static size : ${static.size}, Dynamic size : ${dynamic.size}"
)
def update(model: Model): Unit =
dynamic.foreach(_.update(model))
def wasUpdated: Boolean = dynamic.exists(_.wasUpdated)
def fullDiff: Diff =
DiffBuilder.build(static, dynamic, includeUnchanged = true)
def diff: Diff =
DiffBuilder.build(static = Seq.empty, dynamic)
object LiveView:
inline def apply[Model](
lv: View[Model],
model: Model
): LiveView[Model] =
render(lv.root, model)
def render[Model](
tag: HtmlElement[Model],
model: Model
): LiveView[Model] =
new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq))
def buildStatic[Model](el: HtmlElement[Model]): ArraySeq[String] =
buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments[Model](
el: HtmlElement[Model]
): Seq[Option[String]] =
val (attrs, children) = buildStaticFragmentsByType(el)
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${el.tag.name}"
for attr <- attrs do
attr match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "/>" else ">")
for child <- children do
child match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
static.append(Some(staticFragment))
static.toSeq
@nowarn("cat=unchecked")
private def buildStaticFragmentsByType[Model](
el: HtmlElement[Model]
): (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
val (attrs, children) = el.mods.partitionMap {
case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value"""")))
case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}")))
case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty)
case Mod.DynAttr(attr, _) =>
Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)))
case Mod.DynAttrValueAsPresence(attr, _) =>
Left(List(Some(""), None, Some("")))
case Mod.Tag(el) => Right(buildStaticFragments(el))
case Mod.Text(text) => Right(List(Some(text)))
case Mod.DynText[Model](_) => Right(List(None))
case Mod.When[Model](_, _) => Right(List(None))
case Mod.Split[Model, Any](_, _) => Right(List(None))
}
(attrs.flatten, children.flatten)
@nowarn("cat=unchecked")
def buildDynamic[Model](
el: HtmlElement[Model],
model: Model,
startsUpdated: Boolean = false
): Seq[LiveDyn[Model]] =
val (attrs, children) = el.mods.partitionMap {
case Mod.Text(_) => Right(List.empty)
case Mod.StaticAttr(_, _) => Left(List.empty)
case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty)
case Mod.DynAttr(attr, value) =>
Right(List(LiveDyn.Value(value(attr.codec.encode), model, startsUpdated)))
case Mod.DynAttrValueAsPresence(attr, value) =>
Right(List(LiveDyn.Value(value(if _ then s" ${attr.name}" else ""), model, startsUpdated)))
case Mod.Tag(el) =>
Right(buildDynamic(el, model, startsUpdated))
case Mod.DynText[Model](dynText) =>
Right(List(LiveDyn.Value(dynText, model, startsUpdated)))
case Mod.When[Model](dynCond, el) =>
Right(List(LiveDyn.When(dynCond, el, model)))
case Mod.Split[Model, Any](dynList, project) =>
Right(List(LiveDyn.Split(dynList, project, model)))
}
attrs.flatten ++ children.flatten
end LiveView

View file

@ -0,0 +1,122 @@
package scalive
import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ListBuffer
type RenderedDyn = Option[String | Rendered | Comprehension]
final case class Rendered(
// val root: Boolean,
val fingerprint: Long,
val static: Seq[String],
val dynamic: Seq[Boolean => RenderedDyn])
final case class Comprehension(
// val hasKey: Boolean,
val fingerprint: Long,
val static: Seq[String],
val entries: Seq[Boolean => Seq[RenderedDyn]])
object Rendered:
def render(el: HtmlElement, state: LiveState): Rendered =
Rendered(Fingerprint.apply(el), el.static, buildDynamicRendered(el, state))
def render[T](mod: Mod.DynAttr[T], state: LiveState): Boolean => RenderedDyn =
trackUpdates => mod.value.render(state, trackUpdates).map(mod.attr.codec.encode)
def render(mod: Mod.DynAttrValueAsPresence, state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.value.render(state, trackUpdates).map(if _ then s" ${mod.attr.name}" else "")
def render(mod: Mod.DynText, state: LiveState): Boolean => RenderedDyn =
trackUpdates => mod.dyn.render(state, trackUpdates)
def render(mod: Mod.When, state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.dyn
.render(state, trackUpdates)
.collect { case true => render(mod.el, state) }
def render(mod: Mod.Split[Any], state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.dynList
.render(state, trackUpdates)
.collect {
case items if items.nonEmpty =>
val el = mod.project(Dyn.dummy)
Comprehension(
Fingerprint.apply(el),
el.static,
items.map(item =>
val localKey = LiveState.Key[Any]
val localState = LiveState.empty.set(localKey, item)
val localElem = mod.project(localKey.id)
trackElemUpdates =>
buildDynamicRendered(localElem, localState).map(_(trackElemUpdates))
)
)
}
def buildStatic(el: HtmlElement): ArraySeq[String] =
buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
val (attrs, children) = buildStaticFragmentsByType(el)
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${el.tag.name}"
for attr <- attrs do
attr match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "/>" else ">")
for child <- children do
child match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
static.append(Some(staticFragment))
static.toSeq
@nowarn("cat=unchecked")
private def buildStaticFragmentsByType(el: HtmlElement)
: (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
val (attrs, children) = el.mods.partitionMap {
case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value"""")))
case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}")))
case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty)
case Mod.DynAttr(attr, _) =>
Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)))
case Mod.DynAttrValueAsPresence(attr, _) =>
Left(List(Some(""), None, Some("")))
case Mod.Tag(el) => Right(buildStaticFragments(el))
case Mod.Text(text) => Right(List(Some(text)))
case Mod.DynText(_) => Right(List(None))
case Mod.When(_, _) => Right(List(None))
case Mod.Split[Any](_, _) => Right(List(None))
}
(attrs.flatten, children.flatten)
@nowarn("cat=unchecked")
def buildDynamicRendered(
el: HtmlElement,
state: LiveState
): Seq[Boolean => RenderedDyn] =
val (attrs, children) = el.mods.partitionMap {
case Mod.Text(_) => Right(List.empty)
case Mod.StaticAttr(_, _) => Left(List.empty)
case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty)
case mod: Mod.DynAttr[?] => Right(List(Rendered.render(mod, state)))
case mod: Mod.DynAttrValueAsPresence => Right(List(Rendered.render(mod, state)))
case Mod.Tag(el) => Right(buildDynamicRendered(el, state))
case mod: Mod.DynText => Right(List(Rendered.render(mod, state)))
case mod: Mod.When => Right(List(Rendered.render(mod, state)))
case mod: Mod.Split[Any] => Right(List(Rendered.render(mod, state)))
}
attrs.flatten ++ children.flatten
end Rendered

View file

@ -1,8 +1,8 @@
package scalive package scalive
import scalive.defs.tags.HtmlTags
import scalive.defs.attrs.HtmlAttrs import scalive.defs.attrs.HtmlAttrs
import scalive.defs.complex.ComplexHtmlKeys import scalive.defs.complex.ComplexHtmlKeys
import scalive.defs.tags.HtmlTags
object Scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys object Scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys

View file

@ -0,0 +1,19 @@
package scalive
import zio.json.*
final case class Socket(view: LiveView, initState: LiveState):
private var state: LiveState = initState
private var fingerprint: Fingerprint = Fingerprint.empty
val id: String = "scl-123"
def setState(newState: LiveState): Unit = state = newState
def updateState(f: LiveState => LiveState): Unit = state = f(state)
def renderHtml: String =
HtmlBuilder.build(Rendered.render(view.render, state), isRoot = true)
def syncClient: Unit =
val r = Rendered.render(view.render, state)
println(DiffBuilder.build(r, fingerprint).toJsonPretty)
fingerprint = Fingerprint(r)
println(fingerprint)
state = state.setAllUnchanged

View file

@ -1,66 +0,0 @@
package scalive
import scalive.codecs.Codec
import scalive.codecs.BooleanAsAttrPresenceCodec
trait View[Model]:
val model: Dyn[Model, Model] = Dyn.id
def root: HtmlElement[Model]
opaque type Dyn[I, O] = I => O
extension [I, O](d: Dyn[I, O])
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
def when(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] =
Mod.When(d.andThen(f), el)
inline def whenNot(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] =
when(f.andThen(!_))(el)
def splitByIndex[O2](
f: O => List[O2]
)(
project: Dyn[O2, O2] => HtmlElement[O2]
): Mod.Split[I, O2] =
Mod.Split(d.andThen(f), project)
def run(v: I): O = d(v)
object Dyn:
def id[T]: Dyn[T, T] = identity
enum Mod[T]:
case StaticAttr(attr: HtmlAttr[?], value: String)
case StaticAttrValueAsPresence(attr: HtmlAttr[?], value: Boolean)
case DynAttr[T, V](attr: HtmlAttr[V], value: Dyn[T, V]) extends Mod[T]
case DynAttrValueAsPresence[T, V](attr: HtmlAttr[V], value: Dyn[T, Boolean]) extends Mod[T]
case Tag(el: HtmlElement[T])
case Text(text: String)
case DynText(dynText: Dyn[T, String])
case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T])
case Split[T, O](
dynList: Dyn[T, List[O]],
project: Dyn[O, O] => HtmlElement[O]) extends Mod[T]
given [T]: Conversion[HtmlElement[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
class HtmlTag(val name: String, val void: Boolean = false):
def apply[Model](mods: Mod[Model]*): HtmlElement[Model] =
HtmlElement(this, mods.toVector)
class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]])
class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
val isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
def :=[T](value: V): Mod[T] =
val stringValue = codec.encode(value)
if isBooleanAsAttrPresence then
Mod.StaticAttrValueAsPresence(this, BooleanAsAttrPresenceCodec.decode(stringValue))
else Mod.StaticAttr(this, stringValue)
def :=[T](value: Dyn[T, V]): Mod[T] =
val stringDyn = value(codec.encode)
if isBooleanAsAttrPresence then
Mod.DynAttrValueAsPresence(this, stringDyn(BooleanAsAttrPresenceCodec.decode))
else Mod.DynAttr(this, value)

View file

@ -1,12 +1,11 @@
package scalive package scalive
import zio.json.*
@main @main
def main = def main =
val lv = val s = Socket(
LiveView(
TestView, TestView,
LiveState.empty.set(
TestView.model,
MyModel( MyModel(
List( List(
NestedModel("a", 10), NestedModel("a", 10),
@ -15,11 +14,16 @@ def main =
) )
) )
) )
println(lv.fullDiff.toJsonPretty) )
println(HtmlBuilder.build(lv)) println("Init")
println(s.renderHtml)
s.syncClient
s.syncClient
println("Edit first and last") println("Edit first and last")
lv.update( s.updateState(
_.set(
TestView.model,
MyModel( MyModel(
List( List(
NestedModel("x", 10), NestedModel("x", 10),
@ -28,56 +32,86 @@ def main =
) )
) )
) )
println(lv.diff.toJsonPretty)
println(HtmlBuilder.build(lv))
println("Add one")
lv.update(
MyModel(
List(
NestedModel("x", 10),
NestedModel("b", 15),
NestedModel("c", 99),
NestedModel("d", 35)
) )
) s.syncClient
) // val lv =
println(lv.diff.toJsonPretty) // LiveView(
println(HtmlBuilder.build(lv)) // TestView,
// LiveState.empty.set(
println("Remove first") // TestView.model,
lv.update( // MyModel(
MyModel( // List(
List( // NestedModel("a", 10),
NestedModel("b", 15), // NestedModel("b", 15),
NestedModel("c", 99), // NestedModel("c", 20)
NestedModel("d", 35) // )
) // )
) // )
) // )
println(lv.diff.toJsonPretty) // println(lv.fullDiff.toJsonPretty)
println(HtmlBuilder.build(lv)) // println(HtmlBuilder.build(lv))
//
println("Remove all") // println("Edit first and last")
lv.update( // lv.update(
MyModel(List.empty, "text-lg", bool = false) // MyModel(
) // List(
println(lv.diff.toJsonPretty) // NestedModel("x", 10),
println(HtmlBuilder.build(lv)) // NestedModel("b", 15),
// NestedModel("c", 99)
// )
// )
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
//
// println("Add one")
// lv.update(
// MyModel(
// List(
// NestedModel("x", 10),
// NestedModel("b", 15),
// NestedModel("c", 99),
// NestedModel("d", 35)
// )
// )
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
//
// println("Remove first")
// lv.update(
// MyModel(
// List(
// NestedModel("b", 15),
// NestedModel("c", 99),
// NestedModel("d", 35)
// )
// )
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
//
// println("Remove all")
// lv.update(
// MyModel(List.empty, "text-lg", bool = false)
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
end main end main
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs", bool: Boolean = true) final case class MyModel(elems: List[NestedModel], cls: String = "text-xs", bool: Boolean = true)
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
object TestView extends View[MyModel]: object TestView extends LiveView:
val root: HtmlElement[MyModel] = val model = LiveState.Key[MyModel]
val render =
div( div(
idAttr := "42", idAttr := "42",
cls := model(_.cls), cls := model(_.cls),
draggable := model(_.bool), draggable := model(_.bool),
disabled := model(_.bool), disabled := model(_.bool),
ul( ul(
model.splitByIndex(_.elems)(elem => model(_.elems).splitByIndex(elem =>
li( li(
"Nom: ", "Nom: ",
elem(_.name), elem(_.name),

View file

@ -14,316 +14,320 @@ object LiveViewSpec extends TestSuite:
items: List[NestedModel] = List.empty) items: List[NestedModel] = List.empty)
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
def assertEqualsJson(actual: Diff, expected: Json) = // def assertEqualsJson(actual: Diff, expected: Json) =
assert(actual.toJsonPretty == expected.toJsonPretty) // assert(actual.toJsonPretty == expected.toJsonPretty)
val emptyDiff = Json.Obj.empty val emptyDiff = Json.Obj.empty
val tests = Tests { val tests = Tests {
test("Static only") { // test("Static only") {
val lv = // val lv =
LiveView( // LiveView(
new View[Unit]: // new View:
val root: HtmlElement[Unit] = // type Model = Unit
div("Static string") // val render = div("Static string")
, // ,
() // ()
) // )
test("init") { // test("init") {
assertEqualsJson( // assertEqualsJson(
lv.fullDiff, // lv.fullDiff,
Json.Obj( // Json.Obj(
"s" -> Json.Arr(Json.Str("<div>Static string</div>")) // "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
) // )
) // )
} // }
test("diff") { // test("diff") {
assertEqualsJson(lv.diff, emptyDiff) // assertEqualsJson(lv.diff, emptyDiff)
} // }
} // }
//
test("Dynamic string") { // test("Dynamic string") {
val lv = // val lv =
LiveView( // LiveView(
new View[TestModel]: // new View:
val root: HtmlElement[TestModel] = // type Model = TestModel
div(model(_.title)) // val render =
, // div(model(_.title))
TestModel() // ,
) // TestModel()
test("init") { // )
assertEqualsJson( // test("init") {
lv.fullDiff, // assertEqualsJson(
Json // lv.fullDiff,
.Obj( // Json
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")), // .Obj(
"0" -> Json.Str("title value") // "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
) // "0" -> Json.Str("title value")
) // )
} // )
test("diff no update") { // }
assertEqualsJson(lv.diff, emptyDiff) // test("diff no update") {
} // assertEqualsJson(lv.diff, emptyDiff)
test("diff with update") { // }
lv.update(TestModel(title = "title updated")) // test("diff with update") {
assertEqualsJson( // lv.update(TestModel(title = "title updated"))
lv.diff, // assertEqualsJson(
Json.Obj("0" -> Json.Str("title updated")) // lv.diff,
) // Json.Obj("0" -> Json.Str("title updated"))
} // )
test("diff with update and no change") { // }
lv.update(TestModel(title = "title updated")) // test("diff with update and no change") {
lv.update(TestModel(title = "title updated")) // lv.update(TestModel(title = "title updated"))
assertEqualsJson(lv.diff, emptyDiff) // lv.update(TestModel(title = "title updated"))
} // assertEqualsJson(lv.diff, emptyDiff)
} // }
// }
test("Dynamic attribute") { //
val lv = // test("Dynamic attribute") {
LiveView( // val lv =
new View[TestModel]: // LiveView(
val root: HtmlElement[TestModel] = // new View:
div(cls := model(_.cls)) // type Model = TestModel
, // val render =
TestModel() // div(cls := model(_.cls))
) // ,
test("init") { // TestModel()
assertEqualsJson( // )
lv.fullDiff, // test("init") {
Json // assertEqualsJson(
.Obj( // lv.fullDiff,
"s" -> Json // Json
.Arr(Json.Str("<div class=\""), Json.Str("\"></div>")), // .Obj(
"0" -> Json.Str("text-sm") // "s" -> Json
) // .Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
) // "0" -> Json.Str("text-sm")
} // )
test("diff no update") { // )
assertEqualsJson(lv.diff, emptyDiff) // }
} // test("diff no update") {
test("diff with update") { // assertEqualsJson(lv.diff, emptyDiff)
lv.update(TestModel(cls = "text-md")) // }
assertEqualsJson( // test("diff with update") {
lv.diff, // lv.update(TestModel(cls = "text-md"))
Json.Obj("0" -> Json.Str("text-md")) // assertEqualsJson(
) // lv.diff,
} // Json.Obj("0" -> Json.Str("text-md"))
test("diff with update and no change") { // )
lv.update(TestModel(cls = "text-md")) // }
lv.update(TestModel(cls = "text-md")) // test("diff with update and no change") {
assertEqualsJson(lv.diff, emptyDiff) // lv.update(TestModel(cls = "text-md"))
} // lv.update(TestModel(cls = "text-md"))
} // assertEqualsJson(lv.diff, emptyDiff)
// }
test("when mod") { // }
val lv = //
LiveView( // test("when mod") {
new View[TestModel]: // val lv =
val root: HtmlElement[TestModel] = // LiveView(
div( // new View:
model.when(_.bool)( // type Model = TestModel
div("static string", model(_.nestedTitle)) // val render =
) // div(
) // model.when(_.bool)(
, // div("static string", model(_.nestedTitle))
TestModel() // )
) // )
test("init") { // ,
assertEqualsJson( // TestModel()
lv.fullDiff, // )
Json // test("init") {
.Obj( // assertEqualsJson(
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")), // lv.fullDiff,
"0" -> Json.Bool(false) // Json
) // .Obj(
) // "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
} // "0" -> Json.Bool(false)
test("diff no update") { // )
assertEqualsJson(lv.diff, emptyDiff) // )
} // }
test("diff with unrelated update") { // test("diff no update") {
lv.update(TestModel(title = "title updated")) // assertEqualsJson(lv.diff, emptyDiff)
assertEqualsJson(lv.diff, emptyDiff) // }
} // test("diff with unrelated update") {
test("diff when true") { // lv.update(TestModel(title = "title updated"))
lv.update(TestModel(bool = true)) // assertEqualsJson(lv.diff, emptyDiff)
assertEqualsJson( // }
lv.diff, // test("diff when true") {
Json.Obj( // lv.update(TestModel(bool = true))
"0" -> // assertEqualsJson(
Json // lv.diff,
.Obj( // Json.Obj(
"s" -> Json // "0" ->
.Arr(Json.Str("<div>static string"), Json.Str("</div>")), // Json
"0" -> Json.Str("nested title value") // .Obj(
) // "s" -> Json
) // .Arr(Json.Str("<div>static string"), Json.Str("</div>")),
) // "0" -> Json.Str("nested title value")
} // )
test("diff when nested change") { // )
lv.update(TestModel(bool = true)) // )
lv.update(TestModel(bool = true, nestedTitle = "nested title updated")) // }
assertEqualsJson( // test("diff when nested change") {
lv.diff, // lv.update(TestModel(bool = true))
Json.Obj( // lv.update(TestModel(bool = true, nestedTitle = "nested title updated"))
"0" -> // assertEqualsJson(
Json // lv.diff,
.Obj( // Json.Obj(
"0" -> Json.Str("nested title updated") // "0" ->
) // Json
) // .Obj(
) // "0" -> Json.Str("nested title updated")
} // )
} // )
// )
test("splitByIndex mod") { // }
val initModel = // }
TestModel( //
items = List( // test("splitByIndex mod") {
NestedModel("a", 10), // val initModel =
NestedModel("b", 15), // TestModel(
NestedModel("c", 20) // items = List(
) // NestedModel("a", 10),
) // NestedModel("b", 15),
val lv = // NestedModel("c", 20)
LiveView( // )
new View[TestModel]: // )
val root: HtmlElement[TestModel] = // val lv =
div( // LiveView(
ul( // new View:
model.splitByIndex(_.items)(elem => // type Model = TestModel
li( // val render =
"Nom: ", // div(
elem(_.name), // ul(
" Age: ", // model.splitByIndex(_.items)(elem =>
elem(_.age.toString) // li(
) // "Nom: ",
) // elem(_.name),
) // " Age: ",
) // elem(_.age.toString)
, // )
initModel // )
) // )
test("init") { // )
assertEqualsJson( // ,
lv.fullDiff, // initModel
Json // )
.Obj( // test("init") {
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")), // assertEqualsJson(
"0" -> Json.Obj( // lv.fullDiff,
"s" -> Json.Arr( // Json
Json.Str("<li>Nom: "), // .Obj(
Json.Str(" Age: "), // "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
Json.Str("</li>") // "0" -> Json.Obj(
), // "s" -> Json.Arr(
"d" -> Json.Obj( // Json.Str("<li>Nom: "),
"0" -> Json.Obj( // Json.Str(" Age: "),
"0" -> Json.Str("a"), // Json.Str("</li>")
"1" -> Json.Str("10") // ),
), // "d" -> Json.Obj(
"1" -> Json.Obj( // "0" -> Json.Obj(
"0" -> Json.Str("b"), // "0" -> Json.Str("a"),
"1" -> Json.Str("15") // "1" -> Json.Str("10")
), // ),
"2" -> Json.Obj( // "1" -> Json.Obj(
"0" -> Json.Str("c"), // "0" -> Json.Str("b"),
"1" -> Json.Str("20") // "1" -> Json.Str("15")
) // ),
) // "2" -> Json.Obj(
) // "0" -> Json.Str("c"),
) // "1" -> Json.Str("20")
) // )
} // )
test("diff no update") { // )
assertEqualsJson(lv.diff, emptyDiff) // )
} // )
test("diff with unrelated update") { // }
lv.update(initModel.copy(title = "title updated")) // test("diff no update") {
assertEqualsJson(lv.diff, emptyDiff) // assertEqualsJson(lv.diff, emptyDiff)
} // }
test("diff with item changed") { // test("diff with unrelated update") {
lv.update( // lv.update(initModel.copy(title = "title updated"))
initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99))) // assertEqualsJson(lv.diff, emptyDiff)
) // }
assertEqualsJson( // test("diff with item changed") {
lv.diff, // lv.update(
Json.Obj( // initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99)))
"0" -> // )
Json // assertEqualsJson(
.Obj( // lv.diff,
"d" -> Json.Obj( // Json.Obj(
"2" -> Json.Obj( // "0" ->
"1" -> Json.Str("99") // Json
) // .Obj(
) // "d" -> Json.Obj(
) // "2" -> Json.Obj(
) // "1" -> Json.Str("99")
) // )
} // )
test("diff with item added") { // )
lv.update( // )
initModel.copy(items = initModel.items.appended(NestedModel("d", 35))) // )
) // }
assertEqualsJson( // test("diff with item added") {
lv.diff, // lv.update(
Json.Obj( // initModel.copy(items = initModel.items.appended(NestedModel("d", 35)))
"0" -> // )
Json // assertEqualsJson(
.Obj( // lv.diff,
"d" -> Json.Obj( // Json.Obj(
"3" -> Json.Obj( // "0" ->
"0" -> Json.Str("d"), // Json
"1" -> Json.Str("35") // .Obj(
) // "d" -> Json.Obj(
) // "3" -> Json.Obj(
) // "0" -> Json.Str("d"),
) // "1" -> Json.Str("35")
) // )
} // )
test("diff with first item removed") { // )
lv.update( // )
initModel.copy(items = initModel.items.tail) // )
) // }
assertEqualsJson( // test("diff with first item removed") {
lv.diff, // lv.update(
Json.Obj( // initModel.copy(items = initModel.items.tail)
"0" -> // )
Json // assertEqualsJson(
.Obj( // lv.diff,
"d" -> Json.Obj( // Json.Obj(
"0" -> Json.Obj( // "0" ->
"0" -> Json.Str("b"), // Json
"1" -> Json.Str("15") // .Obj(
), // "d" -> Json.Obj(
"1" -> Json.Obj( // "0" -> Json.Obj(
"0" -> Json.Str("c"), // "0" -> Json.Str("b"),
"1" -> Json.Str("20") // "1" -> Json.Str("15")
), // ),
"2" -> Json.Bool(false) // "1" -> Json.Obj(
) // "0" -> Json.Str("c"),
) // "1" -> Json.Str("20")
) // ),
) // "2" -> Json.Bool(false)
} // )
test("diff all removed") { // )
lv.update(initModel.copy(items = List.empty)) // )
assertEqualsJson( // )
lv.diff, // }
Json.Obj( // test("diff all removed") {
"0" -> // lv.update(initModel.copy(items = List.empty))
Json // assertEqualsJson(
.Obj( // lv.diff,
"d" -> Json.Obj( // Json.Obj(
"0" -> Json.Bool(false), // "0" ->
"1" -> Json.Bool(false), // Json
"2" -> Json.Bool(false) // .Obj(
) // "d" -> Json.Obj(
) // "0" -> Json.Bool(false),
) // "1" -> Json.Bool(false),
) // "2" -> Json.Bool(false)
// )
} // )
} // )
// )
//
// }
// }
} }
end LiveViewSpec end LiveViewSpec

View file

@ -9,9 +9,10 @@ import zio.http.template.Html
object Example extends ZIOAppDefault: object Example extends ZIOAppDefault:
val lv = val s = Socket(
LiveView(
TestView, TestView,
LiveState.empty.set(
TestView.model,
MyModel( MyModel(
List( List(
NestedModel("a", 10), NestedModel("a", 10),
@ -20,6 +21,7 @@ object Example extends ZIOAppDefault:
) )
) )
) )
)
val socketApp: WebSocketApp[Any] = val socketApp: WebSocketApp[Any] =
Handler.webSocket { channel => Handler.webSocket { channel =>
@ -66,7 +68,7 @@ object Example extends ZIOAppDefault:
val routes: Routes[Any, Response] = val routes: Routes[Any, Response] =
Routes( Routes(
Method.GET / "" -> handler { (_: Request) => Method.GET / "" -> handler { (_: Request) =>
Response.html(Html.raw(HtmlBuilder.build(lv))) Response.html(Html.raw(s.renderHtml))
}, },
Method.GET / "live" / "ws" -> handler(socketApp.toResponse) Method.GET / "live" / "ws" -> handler(socketApp.toResponse)
) )
@ -77,13 +79,14 @@ end Example
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
object TestView extends View[MyModel]: object TestView extends LiveView:
val root: HtmlElement[MyModel] = val model = LiveState.Key[MyModel]
val render =
div( div(
idAttr := "42", idAttr := "42",
cls := model(_.cls), cls := model(_.cls),
ul( ul(
model.splitByIndex(_.elems)(elem => model(_.elems).splitByIndex(elem =>
li( li(
"Nom: ", "Nom: ",
elem(_.name), elem(_.name),

View file

@ -0,0 +1,13 @@
package scalive
import scalive.HtmlElement
object RootLayout:
def apply[RootModel](content: HtmlElement): HtmlElement =
htmlRootTag(
lang := "en",
metaTag(charset := "utf-8"),
bodyTag(
// content
)
)

View file

@ -0,0 +1,30 @@
package scalive
import zio.http.Response
import zio.http.template.Html
// trait LiveRouter:
// type RootModel
// private lazy val viewsMap: Map[String, View] = views.map(r => (r.name, r.view)).toMap
// def rootLayout: HtmlElement[RootModel]
// def views: Seq[LiveRoute]
//
// final case class LiveRoute(name: String, view: View)
object ZioLiveApp:
// 1 Request to live route
// 2 Create live view with stateless token containing user id if connected, http params, live view id
// 3 Response with HTML and token
// 4 Websocket connection with token
// 5 Recreate exact same liveview as before using token data
// val testRoute = LiveRoute("test", TestView)
// val router = new LiveRouter:
// val rootLayout = htmlRootTag()
// val views = Seq(testRoute)
// def htmlRender(v: View, model: v.Model) =
// val lv = LiveView(v, model)
// Response.html(Html.raw(HtmlBuilder.build(lv, isRoot = true)))
private val socketApp = ???