diff --git a/.gitignore b/.gitignore index c7681b7d..55bfc001 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ target/ .idea/ # Ignore [ce]tags files -tags +/tags # Metals .metals/ diff --git a/build.sbt b/build.sbt index 43e71b4a..c2dc350c 100644 --- a/build.sbt +++ b/build.sbt @@ -23,7 +23,7 @@ ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") val CatsVersion = "2.9.0" val CatsEffectVersion = "3.4.4" val Fs2Version = "3.4.0" -val Fs2DomVersion = "0.1-d92ea1c-SNAPSHOT" +val Fs2DomVersion = "0.2-20afaf8-SNAPSHOT" val MonocleVersion = "3.2.0" lazy val root = @@ -46,6 +46,8 @@ lazy val frp = crossProject(JVMPlatform, JSPlatform) ) ) +lazy val generateDomDefs = taskKey[Seq[File]]("Generate SDT sources") + lazy val calico = project .in(file("calico")) .enablePlugins(ScalaJSPlugin) @@ -55,9 +57,17 @@ lazy val calico = project "com.armanbilge" %%% "fs2-dom" % Fs2DomVersion, "org.typelevel" %%% "shapeless3-deriving" % "3.3.0", "dev.optics" %%% "monocle-core" % MonocleVersion, - "com.raquo" %%% "domtypes" % "0.16.0-RC3", "org.scala-js" %%% "scalajs-dom" % "2.3.0" - ) + ), + Compile / generateDomDefs := { + import _root_.calico.html.codegen.DomDefsGenerator + import cats.effect.unsafe.implicits.global + import sbt.util.CacheImplicits._ + (Compile / generateDomDefs).previous(sbt.fileJsonFormatter).getOrElse { + DomDefsGenerator.generate((Compile / sourceManaged).value / "domdefs").unsafeRunSync() + } + }, + Compile / sourceGenerators += (Compile / generateDomDefs) ) .dependsOn(frp.js) diff --git a/calico/src/main/scala/calico/html.scala b/calico/src/main/scala/calico/html.scala index 44f5b55b..559bbba8 100644 --- a/calico/src/main/scala/calico/html.scala +++ b/calico/src/main/scala/calico/html.scala @@ -17,6 +17,8 @@ package calico package html +import calico.html.codecs.AsIsCodec +import calico.html.codecs.Codec import calico.syntax.* import calico.util.DomHotswap import cats.Foldable @@ -30,18 +32,6 @@ import cats.effect.kernel.Sync import cats.effect.std.Dispatcher import cats.effect.syntax.all.* import cats.syntax.all.* -import com.raquo.domtypes.generic.builders.EventPropBuilder -import com.raquo.domtypes.generic.builders.HtmlAttrBuilder -import com.raquo.domtypes.generic.builders.HtmlTagBuilder -import com.raquo.domtypes.generic.builders.PropBuilder -import com.raquo.domtypes.generic.builders.ReflectedHtmlAttrBuilder -import com.raquo.domtypes.generic.codecs.Codec -import com.raquo.domtypes.generic.defs.attrs.* -import com.raquo.domtypes.generic.defs.complex.* -import com.raquo.domtypes.generic.defs.props.* -import com.raquo.domtypes.generic.defs.reflectedAttrs.* -import com.raquo.domtypes.generic.defs.tags.* -import com.raquo.domtypes.jsdom.defs.eventProps.* import fs2.Pipe import fs2.Stream import fs2.concurrent.Channel @@ -58,157 +48,50 @@ object io extends Html[IO] object Html: def apply[F[_]: Async]: Html[F] = new Html[F] {} -trait Html[F[_]] - extends HtmlBuilders[F], - DocumentTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlHtmlElement[F], - fs2.dom.HtmlHeadElement[F], - fs2.dom.HtmlBaseElement[F], - fs2.dom.HtmlLinkElement[F], - fs2.dom.HtmlMetaElement[F], - fs2.dom.HtmlScriptElement[F], - fs2.dom.HtmlElement[F], - ], - GroupingTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlParagraphElement[F], - fs2.dom.HtmlHrElement[F], - fs2.dom.HtmlPreElement[F], - fs2.dom.HtmlQuoteElement[F], - fs2.dom.HtmlOListElement[F], - fs2.dom.HtmlUListElement[F], - fs2.dom.HtmlLiElement[F], - fs2.dom.HtmlDListElement[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlDivElement[F], - ], - TextTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlAnchorElement[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlSpanElement[F], - fs2.dom.HtmlBrElement[F], - fs2.dom.HtmlModElement[F], - ], - FormTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlFormElement[F], - fs2.dom.HtmlFieldSetElement[F], - fs2.dom.HtmlLegendElement[F], - fs2.dom.HtmlLabelElement[F], - fs2.dom.HtmlInputElement[F], - fs2.dom.HtmlButtonElement[F], - fs2.dom.HtmlSelectElement[F], - fs2.dom.HtmlDataListElement[F], - fs2.dom.HtmlOptGroupElement[F], - fs2.dom.HtmlOptionElement[F], - fs2.dom.HtmlTextAreaElement[F], - ], - SectionTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlBodyElement[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlHeadingElement[F], - ], - EmbedTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlImageElement[F], - fs2.dom.HtmlIFrameElement[F], - fs2.dom.HtmlEmbedElement[F], - fs2.dom.HtmlObjectElement[F], - fs2.dom.HtmlParamElement[F], - fs2.dom.HtmlVideoElement[F], - fs2.dom.HtmlAudioElement[F], - fs2.dom.HtmlSourceElement[F], - fs2.dom.HtmlTrackElement[F], - fs2.dom.HtmlCanvasElement[F], - fs2.dom.HtmlMapElement[F], - fs2.dom.HtmlAreaElement[F], - ], - TableTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlTableElement[F], - fs2.dom.HtmlTableCaptionElement[F], - fs2.dom.HtmlTableColElement[F], - fs2.dom.HtmlTableSectionElement[F], - fs2.dom.HtmlTableRowElement[F], - fs2.dom.HtmlTableCellElement[F], - ], - MiscTags[ - HtmlTagT[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlTitleElement[F], - fs2.dom.HtmlStyleElement[F], - fs2.dom.HtmlElement[F], - fs2.dom.HtmlQuoteElement[F], - fs2.dom.HtmlProgressElement[F], - fs2.dom.HtmlMenuElement[F], - ], - HtmlAttrs[HtmlAttr[F, _]], - ReflectedHtmlAttrs[Prop[F, _, _]], - Props[Prop[F, _, _]], - ClipboardEventProps[EventProp[F, _]], - ErrorEventProps[EventProp[F, _]], - FormEventProps[EventProp[F, _]], - KeyboardEventProps[EventProp[F, _]], - MediaEventProps[EventProp[F, _]], - MiscellaneousEventProps[EventProp[F, _]], - MouseEventProps[EventProp[F, _]], - PointerEventProps[EventProp[F, _]] - -trait HtmlBuilders[F[_]](using F: Async[F]) - extends HtmlTagBuilder[HtmlTagT[F], fs2.dom.HtmlElement[F]], - HtmlAttrBuilder[HtmlAttr[F, _]], - ReflectedHtmlAttrBuilder[Prop[F, _, _]], - PropBuilder[Prop[F, _, _]], - EventPropBuilder[EventProp[F, _], dom.Event], - Modifiers[F], - HtmlAttrModifiers[F], +trait Html[F[_]](using F: Async[F]) + extends HtmlTags[F], + Props[F], + GlobalEventProps[F], + DocumentEventProps[F], + WindowEventProps[F], + HtmlAttrs[F], PropModifiers[F], - ClassPropModifiers[F], EventPropModifiers[F], + ClassPropModifiers[F], + Modifiers[F], ChildrenModifiers[F], - KeyedChildrenModifiers[F]: + KeyedChildrenModifiers[F], + HtmlAttrModifiers[F]: protected def htmlTag[E <: fs2.dom.HtmlElement[F]](tagName: String, void: Boolean) = HtmlTag(tagName, void) - protected def htmlAttr[V](key: String, codec: Codec[V, String]) = - HtmlAttr(key, codec) + def aria: Aria[F] = Aria[F] - protected def reflectedAttr[V, J]( - attrKey: String, - propKey: String, - attrCodec: Codec[V, String], - propCodec: Codec[V, J]) = - Prop(propKey, propCodec) + def cls: ClassProp[F] = ClassProp[F] - protected def prop[V, J](name: String, codec: Codec[V, J]) = - Prop(name, codec) + def role: HtmlAttr[F, List[String]] = HtmlAttr("role", Codec.whitespaceSeparatedStringsCodec) - def eventProp[V <: dom.Event](key: String): EventProp[F, V] = - EventProp(key) - - def cls: ClassProp[F] = ClassProp[F] + def dataAttr(suffix: String): HtmlAttr[F, String] = + HtmlAttr("data-" + suffix, AsIsCodec.StringAsIsCodec) def children: Children[F] = Children[F] def children[K](f: K => Resource[F, fs2.dom.Node[F]]): KeyedChildren[F, K] = KeyedChildren[F, K](f) -type HtmlTagT[F[_]] = [E <: fs2.dom.HtmlElement[F]] =>> HtmlTag[F, E] + def styleAttr: HtmlAttr[F, String] = + HtmlAttr("style", AsIsCodec.StringAsIsCodec) + +type HtmlTagT[F[_]] = [E] =>> HtmlTag[F, E] + +final class Aria[F[_]] private extends AriaAttrs[F] -final class HtmlTag[F[_], E <: fs2.dom.HtmlElement[F]] private[calico] ( - name: String, - void: Boolean)(using F: Async[F]): +private object Aria: + inline def apply[F[_]]: Aria[F] = instance.asInstanceOf[Aria[F]] + private val instance: Aria[cats.Id] = new Aria[cats.Id] + +final class HtmlTag[F[_], E] private[calico] (name: String, void: Boolean)(using F: Async[F]): def apply[M](modifier: M)(using M: Modifier[F, E, M]): Resource[F, E] = build.toResource.flatTap(M.modify(modifier, _)) @@ -240,6 +123,15 @@ trait Modifier[F[_], E, A]: inline final def contramap[B](inline f: B => A): Modifier[F, E, B] = (b: B, e: E) => outer.modify(f(b), e) +private object Modifier: + def forSignal[F[_]: Async, E, M, V](signal: M => Signal[F, V])( + mkModify: (M, E) => V => F[Unit]): Modifier[F, E, M] = (m, e) => + signal(m).getAndUpdates.flatMap { (head, tail) => + val modify = mkModify(m, e) + Resource.eval(modify(head)) *> + tail.foreach(modify(_)).compile.drain.cedeBackground.void + } + trait Modifiers[F[_]](using F: Async[F]): inline given forUnit[E]: Modifier[F, E, Unit] = _forUnit.asInstanceOf[Modifier[F, E, Unit]] @@ -320,7 +212,7 @@ trait Modifiers[F[_]](using F: Async[F]): sentinel => _forNodeSignal.modify(n2s.map(_.getOrElse(sentinel)), n) } -final class HtmlAttr[F[_], V] private[calico] (key: String, codec: Codec[V, String]): +sealed class HtmlAttr[F[_], V] private[calico] (key: String, codec: Codec[V, String]): import HtmlAttr.* inline def :=(v: V): ConstantModifier[V] = @@ -365,26 +257,23 @@ trait HtmlAttrModifiers[F[_]](using F: Async[F]): : Modifier[F, E, SignalModifier[F, V]] = _forSignalHtmlAttr.asInstanceOf[Modifier[F, E, SignalModifier[F, V]]] - private val _forSignalHtmlAttr: Modifier[F, dom.Element, SignalModifier[F, Any]] = (m, e) => - m.values.getAndUpdates.flatMap { (head, tail) => - def set(v: Any) = F.delay(e.setAttribute(m.key, m.codec.encode(v))) - Resource.eval(set(head)) *> - tail.foreach(set(_)).compile.drain.cedeBackground.void + private val _forSignalHtmlAttr = + Modifier.forSignal[F, dom.Element, SignalModifier[F, Any], Any](_.values) { (m, e) => v => + F.delay(e.setAttribute(m.key, m.codec.encode(v))) } inline given forOptionSignalHtmlAttr[E <: fs2.dom.Element[F], V] : Modifier[F, E, OptionSignalModifier[F, V]] = _forOptionSignalHtmlAttr.asInstanceOf[Modifier[F, E, OptionSignalModifier[F, V]]] - private val _forOptionSignalHtmlAttr: Modifier[F, dom.Element, OptionSignalModifier[F, Any]] = - (m, e) => - m.values.getAndUpdates.flatMap { (head, tail) => - def set(v: Option[Any]) = F.delay { - v.fold(e.removeAttribute(m.key))(v => e.setAttribute(m.key, m.codec.encode(v))) - } - Resource.eval(set(head)) *> - tail.foreach(set(_)).compile.drain.cedeBackground.void - } + private val _forOptionSignalHtmlAttr = + Modifier.forSignal[F, dom.Element, OptionSignalModifier[F, Any], Option[Any]](_.values) { + (m, e) => v => + F.delay(v.fold(e.removeAttribute(m.key))(v => e.setAttribute(m.key, m.codec.encode(v)))) + } + +final class AriaAttr[F[_], V] private[calico] (suffix: String, codec: Codec[V, String]) + extends HtmlAttr[F, V]("aria-" + suffix, codec) sealed class Prop[F[_], V, J] private[calico] (name: String, codec: Codec[V, J]): import Prop.* @@ -432,27 +321,22 @@ trait PropModifiers[F[_]](using F: Async[F]): inline given forSignalProp[N, V, J]: Modifier[F, N, SignalModifier[F, V, J]] = _forSignalProp.asInstanceOf[Modifier[F, N, SignalModifier[F, V, J]]] - private val _forSignalProp: Modifier[F, Any, SignalModifier[F, Any, Any]] = (m, n) => - m.values.getAndUpdates.flatMap { (head, tail) => - def set(v: Any) = setProp(n, v, m.name, m.codec) - Resource.eval(set(head)) *> - tail.foreach(set(_)).compile.drain.cedeBackground.void + private val _forSignalProp = + Modifier.forSignal[F, Any, SignalModifier[F, Any, Any], Any](_.values) { (m, n) => v => + setProp(n, v, m.name, m.codec) } inline given forOptionSignalProp[N, V, J]: Modifier[F, N, OptionSignalModifier[F, V, J]] = _forOptionSignalProp.asInstanceOf[Modifier[F, N, OptionSignalModifier[F, V, J]]] - private val _forOptionSignalProp: Modifier[F, Any, OptionSignalModifier[F, Any, Any]] = - (m, n) => - m.values.getAndUpdates.flatMap { (head, tail) => - def set(v: Option[Any]) = F.delay { + private val _forOptionSignalProp = + Modifier.forSignal[F, Any, OptionSignalModifier[F, Any, Any], Option[Any]](_.values) { + (m, n) => v => + F.delay { val dict = n.asInstanceOf[js.Dictionary[Any]] v.fold(dict -= m.name)(v => dict(m.name) = m.codec.encode(v)) - () } - Resource.eval(set(head)) *> - tail.foreach(set(_)).compile.drain.cedeBackground.void - } + } final class EventProp[F[_], E] private[calico] (key: String): import EventProp.* @@ -471,18 +355,7 @@ trait EventPropModifiers[F[_]](using F: Async[F]): final class ClassProp[F[_]] private[calico] extends Prop[F, List[String], String]( "className", - new: - def decode(domValue: String) = domValue.split(" ").toList - - def encode(scalaValue: List[String]) = - if scalaValue.isEmpty then "" - else - var acc = scalaValue.head - var tail = scalaValue.tail - while tail.nonEmpty do - acc += " " + tail.head - tail = tail.tail - acc + Codec.whitespaceSeparatedStringsCodec ): import ClassProp.* diff --git a/calico/src/main/scala/calico/html/codecs/AsIsCodec.scala b/calico/src/main/scala/calico/html/codecs/AsIsCodec.scala new file mode 100644 index 00000000..65234c56 --- /dev/null +++ b/calico/src/main/scala/calico/html/codecs/AsIsCodec.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Arman Bilge + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package calico.html.codecs + +/** + * Use this codec when you don't need any data transformation + */ + +trait AsIsCodec[T] extends Codec[T, T] { + override def decode(domValue: T): T = domValue + override def encode(scalaValue: T): T = scalaValue +} + +object AsIsCodec { + + def apply[T]: AsIsCodec[T] = new AsIsCodec[T] {} + + object BooleanAsIsCodec extends AsIsCodec[Boolean] + + object DoubleAsIsCodec extends AsIsCodec[Double] + + object StringAsIsCodec extends AsIsCodec[String] + + // Int Codecs + + object IntAsIsCodec extends AsIsCodec[Int] +} diff --git a/calico/src/main/scala/calico/html/codecs/Codec.scala b/calico/src/main/scala/calico/html/codecs/Codec.scala new file mode 100644 index 00000000..bb108ccb --- /dev/null +++ b/calico/src/main/scala/calico/html/codecs/Codec.scala @@ -0,0 +1,111 @@ +/* + * Copyright 2022 Arman Bilge + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package calico.html.codecs + +/** + * This trait represents a way to encode and decode HTML attribute or DOM property values. + * + * It is needed because attributes encode all values as strings regardless of their type, and + * then there are also multiple ways to encode e.g. boolean values. Some attributes encode those + * as "true" / "false" strings, others as presence or absence of the element, and yet others use + * "yes" / "no" or "on" / "off" strings, and properties encode booleans as actual booleans. + * + * Scala DOM Types hides all this mess from you using codecs. All those pseudo-boolean + * attributes would be simply `Attr[Boolean](name, codec)` in your code. + */ +trait Codec[ScalaType, DomType] { + + /** + * Convert the result of a `dom.Node.getAttribute` call to appropriate Scala type. + * + * Note: HTML Attributes are generally optional, and `dom.Node.getAttribute` will return + * `null` if an attribute is not defined on a given DOM node. However, this decoder is only + * intended for cases when the attribute is defined. + */ + def decode(domValue: DomType): ScalaType + + /** + * Convert desired attribute value to appropriate DOM type. The resulting value should be + * passed to `dom.Node.setAttribute` call, EXCEPT when resulting value is a `null`. In that + * case you should call `dom.Node.removeAttribute` instead. + * + * We use `null` instead of [[Option]] here to reduce overhead in JS land. This method should + * not be called by end users anyway, it's the consuming library's job to call this method + * under the hood. + */ + def encode(scalaValue: ScalaType): DomType +} + +object Codec { + private[calico] val whitespaceSeparatedStringsCodec: Codec[List[String], String] = new: + def decode(domValue: String) = domValue.split(" ").toList + + def encode(scalaValue: List[String]) = + if scalaValue.isEmpty then "" + else + var acc = scalaValue.head + var tail = scalaValue.tail + while tail.nonEmpty do + acc += " " + tail.head + tail = tail.tail + acc + + object BooleanAsAttrPresenceCodec extends Codec[Boolean, String] { + override def decode(domValue: String): Boolean = domValue != null + override def encode(scalaValue: Boolean): String = if scalaValue then "" else null + } + + object BooleanAsTrueFalseStringCodec extends Codec[Boolean, String] { + override def decode(domValue: String): Boolean = domValue == "true" + override def encode(scalaValue: Boolean): String = if scalaValue then "true" else "false" + } + + object BooleanAsYesNoStringCodec extends Codec[Boolean, String] { + override def decode(domValue: String): Boolean = domValue == "yes" + override def encode(scalaValue: Boolean): String = if scalaValue then "yes" else "no" + } + + object BooleanAsOnOffStringCodec extends Codec[Boolean, String] { + override def decode(domValue: String): Boolean = domValue == "on" + override def encode(scalaValue: Boolean): String = if scalaValue then "on" else "off" + } + + object IterableAsSpaceSeparatedStringCodec extends Codec[Iterable[String], String] { // use for e.g. className + override def decode(domValue: String): Iterable[String] = + if domValue == "" then Nil else domValue.split(' ') + override def encode(scalaValue: Iterable[String]): String = scalaValue.mkString(" ") + } + + object IterableAsCommaSeparatedStringCodec extends Codec[Iterable[String], String] { // use for lists of IDs + override def decode(domValue: String): Iterable[String] = + if domValue == "" then Nil else domValue.split(',') + override def encode(scalaValue: Iterable[String]): String = scalaValue.mkString(",") + } + + object DoubleAsStringCodec extends Codec[Double, String] { + override def decode(domValue: String): Double = + domValue.toDouble // @TODO this can throw exception. How do we handle this? + override def encode(scalaValue: Double): String = scalaValue.toString + } + + object IntAsStringCodec extends Codec[Int, String] { + override def decode(domValue: String): Int = + domValue.toInt // @TODO this can throw exception. How do we handle this? + override def encode(scalaValue: Int): String = scalaValue.toString + } + +} diff --git a/project/build.sbt b/project/build.sbt new file mode 100644 index 00000000..5f756888 --- /dev/null +++ b/project/build.sbt @@ -0,0 +1,4 @@ +libraryDependencies ++= Seq( + "com.raquo" %% "domtypes" % "17.0.0-M2", + "org.typelevel" %% "cats-effect" % "3.4.4" +) diff --git a/project/src/main/scala/calico/html/codegen/CalicoGenerator.scala b/project/src/main/scala/calico/html/codegen/CalicoGenerator.scala new file mode 100644 index 00000000..329d9332 --- /dev/null +++ b/project/src/main/scala/calico/html/codegen/CalicoGenerator.scala @@ -0,0 +1,275 @@ +/* + * Copyright 2022 Arman Bilge + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package calico.html.codegen + +import com.raquo.domtypes.codegen.CanonicalGenerator +import com.raquo.domtypes.codegen.CodeFormatting +import com.raquo.domtypes.codegen.DefType +import com.raquo.domtypes.codegen.generators.AttrsTraitGenerator +import com.raquo.domtypes.codegen.generators.EventPropsTraitGenerator +import com.raquo.domtypes.codegen.generators.PropsTraitGenerator +import com.raquo.domtypes.codegen.generators.TagsTraitGenerator +import com.raquo.domtypes.common.AttrDef +import com.raquo.domtypes.common.EventPropDef +import com.raquo.domtypes.common.HtmlTagType +import com.raquo.domtypes.common.PropDef +import com.raquo.domtypes.common.SvgTagType +import com.raquo.domtypes.common.TagDef +import com.raquo.domtypes.common.TagType +import java.io.File +import java.nio.file.Paths + +private[codegen] class CalicoGenerator(srcManaged: File) + extends CanonicalGenerator( + baseOutputDirectoryPath = srcManaged.getPath, + basePackagePath = "calico.html", + standardTraitCommentLines = List( + "#NOTE: GENERATED CODE", + " - This file is generated at compile time from the data in Scala DOM Types", + " - See `project/src/main/scala/calico/html/codegen/DomDefsGenerator.scala` for code generation params", + " - Contribute to https://github.com/raquo/scala-dom-types to add missing tags / attrs / props / etc." + ), + format = CodeFormatting() + ) { + + override val baseScalaJsHtmlElementType: String = "HtmlElement[F]" + + override def defsPackagePath: String = basePackagePath + + override def tagDefsPackagePath: String = defsPackagePath + + override def attrDefsPackagePath: String = defsPackagePath + + override def propDefsPackagePath: String = defsPackagePath + + override def eventPropDefsPackagePath: String = defsPackagePath + + override def stylePropDefsPackagePath: String = defsPackagePath + + override def keysPackagePath: String = basePackagePath + + override def tagKeysPackagePath: String = basePackagePath + + override val codecsImport: String = + List( + s"import ${basePackagePath}.codecs.Codec.*", + s"import ${basePackagePath}.codecs.AsIsCodec.*", + s"import ${basePackagePath}.codecs.*" + ).mkString("\n") + + override def generateTagsTrait( + tagType: TagType, + defGroups: List[(String, List[TagDef])], + printDefGroupComments: Boolean, + traitCommentLines: List[String], + traitName: String, + keyKind: String, + baseImplDefComments: List[String], + keyImplName: String, + defType: DefType): String = { + val (defs, defGroupComments) = defsAndGroupComments(defGroups, printDefGroupComments) + + val baseImplDef = if (tagType == HtmlTagType) { + List( + s"protected def ${keyImplName}[$scalaJsElementTypeParam <: $baseScalaJsHtmlElementType](key: String, void: Boolean = false): ${keyKind}[$scalaJsElementTypeParam]" + ) + } else { + List( + s"def ${keyImplName}[$scalaJsElementTypeParam <: $baseScalaJsSvgElementType](key: String): ${keyKind}[$scalaJsElementTypeParam] = ${keyKindConstructor(keyKind)}(key)" + ) + } + + val headerLines = List( + s"package $tagDefsPackagePath", + "", + "import calico.html.HtmlTagT", + "import fs2.dom.*", + "" + ) ++ standardTraitCommentLines.map("// " + _) + + new TagsTraitGenerator( + defs = defs, + defGroupComments = defGroupComments, + headerLines = headerLines, + traitCommentLines = traitCommentLines, + traitName = traitName, + traitExtends = Nil, + traitThisType = None, + defType = _ => defType, + keyType = tag => keyKind + "[" + TagDefMapper.extractFs2DomElementType(tag) + "]", + keyImplName = _ => keyImplName, + baseImplDefComments = baseImplDefComments, + baseImplDef = baseImplDef, + outputImplDefs = true, + format = format + ).printTrait().getOutput() + } + + override def generateAttrsTrait( + defGroups: List[(String, List[AttrDef])], + printDefGroupComments: Boolean, + traitCommentLines: List[String], + traitName: String, + keyKind: String, + implNameSuffix: String, + baseImplDefComments: List[String], + baseImplName: String, + namespaceImports: List[String], + namespaceImpl: String => String, + transformAttrDomName: String => String, + defType: DefType): String = { + val (defs, defGroupComments) = defsAndGroupComments(defGroups, printDefGroupComments) + + val tagTypes = defs.foldLeft(List[TagType]())((acc, k) => (acc :+ k.tagType).distinct) + if (tagTypes.size > 1) { + throw new Exception( + "Sorry, generateAttrsTrait does not support mixing attrs of different types in one call. You can contribute a PR (please contact us first), or bypass this limitation by calling AttrsTraitGenerator manually.") + } + val tagType = tagTypes.head + + val baseImplDef = if (tagType == SvgTagType) { + List( + s"def ${baseImplName}[V](key: String, codec: Codec[V, String], namespace: Option[String]): ${keyKind}[V] = ${keyKindConstructor(keyKind)}(key, codec, namespace)" + ) + } else { + List( + s"protected def ${baseImplName}[V](key: String, codec: Codec[V, String]): ${keyKind}[F, V] = ${keyKindConstructor(keyKind)}(key, codec)" + ) + } + + val headerLines = List( + s"package $attrDefsPackagePath", + "", + keyTypeImport(keyKind), + codecsImport + ) ++ namespaceImports ++ List("") ++ standardTraitCommentLines.map("// " + _) + + new AttrsTraitGenerator( + defs = defs.map(d => d.copy(domName = transformAttrDomName(d.domName))), + defGroupComments = defGroupComments, + headerLines = headerLines, + traitCommentLines = traitCommentLines, + traitName = traitName, + traitExtends = Nil, + traitThisType = None, + defType = _ => defType, + keyKind = keyKind, + keyImplName = attr => attrImplName(attr.codec, implNameSuffix), + baseImplDefComments = baseImplDefComments, + baseImplName = baseImplName, + baseImplDef = baseImplDef, + transformCodecName = _ + "Codec", + namespaceImpl = namespaceImpl, + outputImplDefs = true, + format = format + ).printTrait().getOutput() + } + + override def generatePropsTrait( + defGroups: List[(String, List[PropDef])], + printDefGroupComments: Boolean, + traitCommentLines: List[String], + traitName: String, + keyKind: String, + implNameSuffix: String, + baseImplDefComments: List[String], + baseImplName: String, + defType: DefType): String = { + + val (defs, defGroupComments) = defsAndGroupComments(defGroups, printDefGroupComments) + + val baseImplDef = List( + s"def ${baseImplName}[V, DomV](key: String, codec: Codec[V, DomV]): ${keyKind}[F, V, DomV] = ${keyKindConstructor(keyKind)}(key, codec)" + ) + + val headerLines = List( + s"package $propDefsPackagePath", + "", + keyTypeImport(keyKind), + codecsImport, + "" + ) ++ standardTraitCommentLines.map("// " + _) + + new PropsTraitGenerator( + defs = defs, + defGroupComments = defGroupComments, + headerLines = headerLines, + traitCommentLines = traitCommentLines, + traitName = traitName, + traitExtends = Nil, + traitThisType = None, + defType = _ => defType, + keyKind = keyKind, + keyImplName = prop => propImplName(prop.codec, implNameSuffix), + baseImplDefComments = baseImplDefComments, + baseImplName = baseImplName, + baseImplDef = baseImplDef, + transformCodecName = _ + "Codec", + outputImplDefs = true, + format = format + ).printTrait().getOutput() + } + + override def generateEventPropsTrait( + defSources: List[(String, List[EventPropDef])], + printDefGroupComments: Boolean, + traitCommentLines: List[String], + traitName: String, + traitExtends: List[String], + traitThisType: Option[String], + baseImplDefComments: List[String], + outputBaseImpl: Boolean, + keyKind: String, + keyImplName: String, + defType: DefType): String = { + val (defs, defGroupComments) = defsAndGroupComments(defSources, printDefGroupComments) + + val baseImplDef = if (outputBaseImpl) + List( + s"def ${keyImplName}[Ev <: ${baseScalaJsEventType}](key: String): ${keyKind}[F, Ev] = ${keyKindConstructor(keyKind)}(key)" + ) + else { + Nil + } + + val headerLines = List( + s"package $eventPropDefsPackagePath", + "", + keyTypeImport(keyKind), + scalaJsDomImport, + "" + ) ++ standardTraitCommentLines.map("// " + _) + + new EventPropsTraitGenerator( + defs = defs, + defGroupComments = defGroupComments, + headerLines = headerLines, + traitCommentLines = traitCommentLines, + traitName = traitName, + traitExtends = traitExtends, + traitThisType = traitThisType, + defType = _ => defType, + keyKind = keyKind, + keyImplName = _ => keyImplName, + baseImplDefComments = baseImplDefComments, + baseImplDef = baseImplDef, + outputImplDefs = true, + format = format + ).printTrait().getOutput() + } + +} diff --git a/project/src/main/scala/calico/html/codegen/DomDefsGenerator.scala b/project/src/main/scala/calico/html/codegen/DomDefsGenerator.scala new file mode 100644 index 00000000..c325462a --- /dev/null +++ b/project/src/main/scala/calico/html/codegen/DomDefsGenerator.scala @@ -0,0 +1,395 @@ +/* + * Copyright 2022 Arman Bilge + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package calico.html.codegen + +import com.raquo.domtypes.codegen.DefType.LazyVal +import com.raquo.domtypes.codegen.{ + CanonicalDefGroups, + CanonicalGenerator, + CodeFormatting, + SourceRepr +} +import cats.effect.IO +import cats.syntax.all._ +import com.raquo.domtypes.codegen.DefType +import com.raquo.domtypes.codegen.generators.AttrsTraitGenerator +import com.raquo.domtypes.codegen.generators.EventPropsTraitGenerator +import com.raquo.domtypes.codegen.generators.PropsTraitGenerator +import com.raquo.domtypes.codegen.generators.TagsTraitGenerator +import com.raquo.domtypes.common +import com.raquo.domtypes.common.TagType +import com.raquo.domtypes.common.{HtmlTagType, SvgTagType} +import com.raquo.domtypes.defs.styles.StyleTraitDefs +import java.io.File + +object DomDefsGenerator { + + def generate(srcManaged: File): IO[List[File]] = { + val defGroups = new CanonicalDefGroups() + val generator = new CalicoGenerator(srcManaged) + + def writeToFile(packagePath: String, fileName: String, fileContent: String): IO[File] = + IO { + generator.writeToFile( + packagePath = packagePath, + fileName = fileName, + fileContent = fileContent + ) + } + + // -- HTML tags -- + + val htmlTags = { + val traitName = "HtmlTags" + val traitNameWithParams = s"$traitName[F[_]]" + + val fileContent = generator.generateTagsTrait( + tagType = HtmlTagType, + defGroups = defGroups.htmlTagsDefGroups, + printDefGroupComments = true, + traitCommentLines = Nil, + traitName = traitNameWithParams, + keyKind = "HtmlTagT[F]", + baseImplDefComments = List( + "Create HTML tag", + "", + "Note: this simply creates an instance of HtmlTag.", + " - This does not create the element (to do that, call .apply() on the returned tag instance)", + " - This does not register this tag name as a custom element", + " - See https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements", + "", + "@param tagName - e.g. \"div\" or \"mwc-input\"", + "@tparam Ref - type of elements with this tag, e.g. dom.html.Input for \"input\" tag" + ), + keyImplName = "htmlTag", + defType = LazyVal + ) + + writeToFile(generator.tagDefsPackagePath, traitName, fileContent) + } + + // -- SVG tags -- + + // { + // val traitName = "SvgTags" + + // val fileContent = generator.generateTagsTrait( + // tagType = SvgTagType, + // defGroups = defGroups.svgTagsDefGroups, + // printDefGroupComments = false, + // traitCommentLines = Nil, + // traitName = traitName, + // keyKind = "SvgTag", + // baseImplDefComments = List( + // "Create SVG tag", + // "", + // "Note: this simply creates an instance of HtmlTag.", + // " - This does not create the element (to do that, call .apply() on the returned tag instance)", + // "", + // "@param tagName - e.g. \"circle\"", + // "", + // "@tparam Ref - type of elements with this tag, e.g. dom.svg.Circle for \"circle\" tag" + // ), + // keyImplName = "svgTag", + // defType = LazyVal + // ) + + // generator.writeToFile( + // packagePath = generator.tagDefsPackagePath, + // fileName = traitName, + // fileContent = fileContent + // ) + // } + + // -- HTML attributes -- + + val htmlAttrs = { + val traitName = "HtmlAttrs" + val traitNameWithParams = s"$traitName[F[_]]" + + val fileContent = generator.generateAttrsTrait( + defGroups = defGroups.htmlAttrDefGroups.map { + case (key, vals) => + (key, vals.map(attr => attr.copy(scalaValueType = "F, " + attr.scalaValueType))) + }, + printDefGroupComments = false, + traitCommentLines = Nil, + traitName = traitNameWithParams, + keyKind = "HtmlAttr", + implNameSuffix = "HtmlAttr", + baseImplDefComments = List( + "Create HTML attribute (Note: for SVG attrs, use L.svg.svgAttr)", + "", + "@param key - name of the attribute, e.g. \"value\"", + "@param codec - used to encode V into String, e.g. StringAsIsCodec", + "", + "@tparam V - value type for this attr in Scala" + ), + baseImplName = "htmlAttr", + namespaceImports = Nil, + namespaceImpl = _ => ???, + transformAttrDomName = identity, + defType = LazyVal + ) + + writeToFile(generator.attrDefsPackagePath, traitName, fileContent) + } + + // -- SVG attributes -- + + // { + // val traitName = "SvgAttrs" + + // val fileContent = generator.generateAttrsTrait( + // defGroups = defGroups.svgAttrDefGroups, + // printDefGroupComments = false, + // traitName = traitName, + // traitCommentLines = Nil, + // keyKind = "SvgAttr", + // baseImplDefComments = List( + // "Create SVG attribute (Note: for HTML attrs, use L.htmlAttr)", + // "", + // "@param key - name of the attribute, e.g. \"value\"", + // "@param codec - used to encode V into String, e.g. StringAsIsCodec", + // "", + // "@tparam V - value type for this attr in Scala" + // ), + // implNameSuffix = "SvgAttr", + // baseImplName = "svgAttr", + // namespaceImports = Nil, + // namespaceImpl = SourceRepr(_), + // transformAttrDomName = identity, + // defType = LazyVal + // ) + + // generator.writeToFile( + // packagePath = generator.attrDefsPackagePath, + // fileName = traitName, + // fileContent = fileContent + // ) + // } + + // -- ARIA attributes -- + + val ariaAttrs = { + val traitName = "AriaAttrs" + val traitNameWithParams = s"$traitName[F[_]]" + + def transformAttrDomName(ariaAttrName: String): String = { + if (ariaAttrName.startsWith("aria-")) { + ariaAttrName.substring(5) + } else { + throw new Exception(s"Aria attribute does not start with `aria-`: $ariaAttrName") + } + } + + val fileContent = generator.generateAttrsTrait( + defGroups = defGroups.ariaAttrDefGroups.map { + case (key, vals) => + (key, vals.map(attr => attr.copy(scalaValueType = "F, " + attr.scalaValueType))) + }, + printDefGroupComments = false, + traitName = traitNameWithParams, + traitCommentLines = Nil, + keyKind = "AriaAttr", + implNameSuffix = "AriaAttr", + baseImplDefComments = List( + "Create ARIA attribute (Note: for HTML attrs, use L.htmlAttr)", + "", + "@param key - suffix of the attribute, without \"aria-\" prefix, e.g. \"labelledby\"", + "@param codec - used to encode V into String, e.g. StringAsIsCodec", + "", + "@tparam V - value type for this attr in Scala" + ), + baseImplName = "ariaAttr", + namespaceImports = Nil, + namespaceImpl = _ => ???, + transformAttrDomName = transformAttrDomName, + defType = LazyVal + ) + + writeToFile(generator.attrDefsPackagePath, traitName, fileContent) + } + + // -- HTML props -- + + val htmlProps = { + val traitName = "Props" + val traitNameWithParams = s"$traitName[F[_]]" + + val fileContent = generator.generatePropsTrait( + defGroups = defGroups.propDefGroups.map { + case (key, vals) => + (key, vals.map(attr => attr.copy(scalaValueType = "F, " + attr.scalaValueType))) + }, + printDefGroupComments = true, + traitCommentLines = Nil, + traitName = traitNameWithParams, + keyKind = "Prop", + implNameSuffix = "Prop", + baseImplDefComments = List( + "Create custom HTML element property", + "", + "@param key - name of the prop in JS, e.g. \"value\"", + "@param codec - used to encode V into DomV, e.g. StringAsIsCodec,", + "", + "@tparam V - value type for this prop in Scala", + "@tparam DomV - value type for this prop in the underlying JS DOM." + ), + baseImplName = "prop", + defType = LazyVal + ) + + writeToFile(generator.propDefsPackagePath, traitName, fileContent) + } + + // -- Event props -- + + val eventProps = { + val baseTraitName = "GlobalEventProps" + val baseTraitNameWithParams = s"$baseTraitName[F[_]]" + + val subTraits = List( + ("WindowEventProps", "WindowEventProps[F[_]]", defGroups.windowEventPropDefGroups), + ("DocumentEventProps", "DocumentEventProps[F[_]]", defGroups.documentEventPropDefGroups) + ) + + val global = { + val fileContent = generator.generateEventPropsTrait( + defSources = defGroups.globalEventPropDefGroups.map { + case (key, vals) => + ( + key, + vals.map(attr => attr.copy(scalaJsEventType = "F, " + attr.scalaJsEventType))) + }, + printDefGroupComments = true, + traitCommentLines = Nil, + traitName = baseTraitNameWithParams, + traitExtends = Nil, + traitThisType = None, + baseImplDefComments = List( + "Create custom event property", + "", + "@param key - event type in JS, e.g. \"click\"", + "", + "@tparam Ev - event type in JS, e.g. dom.MouseEvent" + ), + outputBaseImpl = true, + keyKind = "EventProp", + keyImplName = "eventProp", + defType = LazyVal + ) + + writeToFile(generator.eventPropDefsPackagePath, baseTraitName, fileContent) + } + + List( + subTraits.traverse { + case (traitName, traitNameWithParams, eventPropsDefGroups) => + val fileContent = generator.generateEventPropsTrait( + defSources = eventPropsDefGroups.map { + case (key, vals) => + ( + key, + vals.map(attr => + attr.copy(scalaJsEventType = "F, " + attr.scalaJsEventType))) + }, + printDefGroupComments = true, + traitCommentLines = List(eventPropsDefGroups.head._1), + traitName = traitNameWithParams, + traitExtends = Nil, + traitThisType = Some(baseTraitName + "[F]"), + baseImplDefComments = Nil, + outputBaseImpl = false, + keyKind = "EventProp", + keyImplName = "eventProp", + defType = LazyVal + ) + writeToFile(generator.eventPropDefsPackagePath, traitName, fileContent) + }, + global.map(_.pure[List]) + ).parFlatSequence + } + + // -- Style props -- + + // { + // val traitName = "StyleProps" + + // val fileContent = generator.generateStylePropsTrait( + // defSources = defGroups.stylePropDefGroups, + // printDefGroupComments = true, + // traitCommentLines = Nil, + // traitName = traitName, + // keyKind = "StyleProp", + // keyKindAlias = "StyleProp", + // setterType = "StyleSetter", + // setterTypeAlias = "SS", + // derivedKeyKind = "DerivedStyleProp", + // derivedKeyKindAlias = "DSP", + // baseImplDefComments = List( + // "Create custom CSS property", + // "", + // "@param key - name of CSS property, e.g. \"font-weight\"", + // "", + // "@tparam V - type of values recognized by JS for this property, e.g. Int", + // " Note: String is always allowed regardless of the type you put here.", + // " If unsure, use String type as V." + // ), + // baseImplName = "styleProp", + // defType = LazyVal, + // lengthUnitsNumType = "Int", + // outputUnitTraits = true + // ) + + // generator.writeToFile( + // packagePath = generator.stylePropDefsPackagePath, + // fileName = traitName, + // fileContent = fileContent + // ) + // } + + // -- Style keyword traits + + // { + // StyleTraitDefs.defs.foreach { styleTrait => + // val fileContent = generator.generateStyleKeywordsTrait( + // defSources = styleTrait.keywordDefGroups, + // printDefGroupComments = styleTrait.keywordDefGroups.length > 1, + // traitCommentLines = Nil, + // traitName = styleTrait.scalaName.replace("[_]", ""), + // extendsTraits = styleTrait.extendsTraits.map(_.replace("[_]", "")), + // extendsUnitTraits = styleTrait.extendsUnits, + // propKind = "StyleProp", + // keywordType = "StyleSetter", + // derivedKeyKind = "DerivedStyleProp", + // lengthUnitsNumType = "Int", + // defType = LazyVal, + // outputUnitTypes = true, + // allowSuperCallInOverride = false // can't access lazy val from `super` + // ) + + // generator.writeToFile( + // packagePath = generator.styleTraitsPackagePath(), + // fileName = styleTrait.scalaName.replace("[_]", ""), + // fileContent = fileContent + // ) + // } + // } + List(List(htmlTags, htmlAttrs, ariaAttrs, htmlProps).sequence, eventProps).parFlatSequence + } +} diff --git a/project/src/main/scala/calico/html/codegen/TagDefMapper.scala b/project/src/main/scala/calico/html/codegen/TagDefMapper.scala new file mode 100644 index 00000000..149fb6b4 --- /dev/null +++ b/project/src/main/scala/calico/html/codegen/TagDefMapper.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2022 Arman Bilge + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package calico.html.codegen + +import com.raquo.domtypes.common.TagDef + +private[codegen] object TagDefMapper { + + def extractFs2DomElementType(tagDef: TagDef): String = + tagDef.javascriptElementType match { + case "HTMLHtmlElement" => + "HtmlElement[F]" + case "HTMLHRElement" => + "HtmlHrElement[F]" + case "HTMLLIElement" => + "HtmlLiElement[F]" + case "HTMLBRElement" => + "HtmlBrElement[F]" + case s => + s"${s.replaceFirst("HTML", "Html")}[F]" + } +} diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index 1090f7f7..ef715f14 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -135,7 +135,7 @@ object TodoMvc extends IOWebApp: } def StatusBar(activeCount: Signal[IO, Int], filter: Signal[IO, Filter], router: Router[IO]) = - footer( + footerTag( cls := "footer", span( cls := "todo-count",