Skip to content

feat(template): zio-blocks-template — type-safe HTML templating with compile-time optimizations#1164

Open
987Nabil wants to merge 30 commits intomainfrom
feat/template
Open

feat(template): zio-blocks-template — type-safe HTML templating with compile-time optimizations#1164
987Nabil wants to merge 30 commits intomainfrom
feat/template

Conversation

@987Nabil
Copy link
Contributor

@987Nabil 987Nabil commented Mar 3, 2026

Summary

Comprehensive implementation of zio-blocks-template — a zero-dependency, type-safe HTML templating micro library with compile-time optimizations.

Features

  • HTML DSL: div(id := "main", className := "container", p("Hello!"))
  • String Interpolators: html"", css"", js"", selector"" with position-aware escaping
  • CssSelector ADT: Full CSS selector DSL with combinators (>, >>, +, ~, &, |) and pseudo-classes
  • Css ADT: Structured Css.Rule, Css.Declaration, Css.Sheet with pretty-printing
  • Position-Aware html"": Summons ToAttrValue for attribute positions, ToElements for content
  • ToJs Schema Derivation: Auto-derive ToJs instances from Schema via JSON
  • CssSelectable: Elements are valid CSS selectors (div.hover, div > span)

Compile-Time Optimizations (Scala 3)

  • Constant Folding: Zero-arg css"", js"", selector"" produce compile-time constants
  • Tag Macro: optimizedApply classifies modifiers at compile time, generates direct Dom.Element.Generic construction (eliminates N intermediate copies)
  • PreRendered: Dom variant for macro-generated static subtrees

Review Comments Addressed

All 17 jdegoes comments from previous review:

  • ✅ Css → structured ADT with CssRule/CssDeclaration
  • ✅ Vector → Chunk everywhere
  • ✅ RawHtml removed, Fragment removed
  • ✅ Element.apply(modifier, modifiers*) — non-empty guarantee
  • ✅ Escape, InterpolatorRuntime made private[template]
  • ✅ Js sealed abstract case class
  • ✅ ToJs Schema derivation
  • ✅ Position-aware html interpolator
  • ✅ Configurable indentation render(indent: Int)

Tests

  • 425 tests on Scala 3.7.4 (incl. 25 optimization equivalence tests)
  • 400 tests on Scala 2.13.18
  • All pass on both JVM and Scala.js

Copilot AI review requested due to automatic review settings March 3, 2026 21:54
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new zio-blocks-template crossProject module to the codebase, providing a small HTML templating / rendering library (DOM ADT, escaping, element/attribute DSL, and css/js/html interpolators) along with a substantial test suite and build integration.

Changes:

  • Introduces the Dom ADT with rendering (minified/indented), traversal utilities, and attribute modeling.
  • Adds HTML element/attribute constructors (HtmlElements), escaping utilities (Escape), and typeclass-based interpolator plumbing (To* typeclasses + Scala 2 / Scala 3 macros).
  • Wires the new module into build.sbt (root aggregation + test/doc aliases) and adds comprehensive ZIO Test specs.

Reviewed changes

Copilot reviewed 28 out of 31 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
build.sbt Adds the new template crossProject, aggregates it in root, and includes it in test/doc aliases + coverage settings.
template/shared/src/main/scala/zio/blocks/template/package.scala Exposes the module API via zio.blocks.template package object (element DSL + interpolators).
template/shared/src/main/scala/zio/blocks/template/Css.scala Introduces Css wrapper type used by the CSS interpolator/typeclass.
template/shared/src/main/scala/zio/blocks/template/Js.scala Introduces Js wrapper type used by the JS interpolator/typeclass.
template/shared/src/main/scala/zio/blocks/template/Dom.scala Implements the DOM ADT, rendering (including indented/minified), traversal, and attribute rendering.
template/shared/src/main/scala/zio/blocks/template/Escape.scala Adds HTML/JS/CSS escaping routines used throughout rendering and interpolators.
template/shared/src/main/scala/zio/blocks/template/HtmlElements.scala Adds ~HTML5 element constructors, attribute vals/helpers, and DOM helper functions.
template/shared/src/main/scala/zio/blocks/template/Modifier.scala Adds modifier abstraction + implicit conversions for children/attributes/options/iterables.
template/shared/src/main/scala/zio/blocks/template/PartialAttribute.scala Defines attribute builder/operator surface (e.g. :=) and “bare attribute as modifier” behavior.
template/shared/src/main/scala/zio/blocks/template/PartialMultiAttribute.scala Defines multi-value attribute helper (varargs / iterable / vector).
template/shared/src/main/scala/zio/blocks/template/InterpolatorRuntime.scala Runtime builder used by both Scala 2 and Scala 3 interpolator macros.
template/shared/src/main/scala/zio/blocks/template/ToText.scala Adds ToText typeclass for converting values to text.
template/shared/src/main/scala/zio/blocks/template/ToElements.scala Adds ToElements typeclass controlling HTML interpolation behavior.
template/shared/src/main/scala/zio/blocks/template/ToJs.scala Adds ToJs typeclass for JS interpolation with escaping/quoting rules.
template/shared/src/main/scala/zio/blocks/template/ToCss.scala Adds ToCss typeclass and CSS value ADTs (CssLength, CssColor).
template/shared/src/main/scala/zio/blocks/template/ToTagName.scala Adds ToTagName typeclass (currently not used by the html interpolator macro).
template/shared/src/main/scala/zio/blocks/template/ToAttrName.scala Adds ToAttrName typeclass (currently not used by the html interpolator macro).
template/shared/src/main/scala/zio/blocks/template/ToAttrValue.scala Adds ToAttrValue typeclass for attribute value escaping/encoding.
template/shared/src/main/scala/zio/blocks/template/SafeTagName.scala Adds whitelist-based safe tag name wrapper.
template/shared/src/main/scala/zio/blocks/template/SafeAttrName.scala Adds whitelist-based safe attribute name wrapper with aria/data support.
template/shared/src/main/scala/zio/blocks/template/EventAttrName.scala Adds whitelist-based event attribute name wrapper.
template/shared/src/main/scala-3/zio/blocks/template/TemplateInterpolators.scala Scala 3 macro implementation for css/js/html interpolators using To* typeclasses.
template/shared/src/main/scala-2/zio/blocks/template/TemplateInterpolators.scala Scala 2 macro implementation for css/js/html interpolators using To* typeclasses.
template/shared/src/test/scala/zio/blocks/template/DomSpec.scala Validates DOM rendering, traversal, void elements, indentation, and helpers.
template/shared/src/test/scala/zio/blocks/template/EscapeSpec.scala Validates HTML/JS/CSS escaping behavior.
template/shared/src/test/scala/zio/blocks/template/HtmlElementsSpec.scala Validates element constructors, attributes, modifiers, helpers, and script/style behavior.
template/shared/src/test/scala/zio/blocks/template/InterpolatorSpec.scala Validates css/js/html interpolators at a basic level.
template/shared/src/test/scala/zio/blocks/template/SafeNameSpec.scala Validates SafeTagName/SafeAttrName/EventAttrName whitelist behavior.
template/shared/src/test/scala/zio/blocks/template/TypeclassSpec.scala Validates ToJs/ToCss/ToText/ToElements/name/value typeclasses.
template/jvm/.gitkeep Placeholder for JVM module directory.
template/js/.gitkeep Placeholder for JS module directory.

Comment on lines +17 to +35
private def elScript(modifiers: Seq[Modifier]): Dom.Element.Script = {
var elem: Dom.Element = Dom.Element.Script(Vector.empty, Vector.empty)
var i = 0
while (i < modifiers.length) {
elem = modifiers(i).applyTo(elem)
i += 1
}
elem.asInstanceOf[Dom.Element.Script]
}

private def elStyle(modifiers: Seq[Modifier]): Dom.Element.Style = {
var elem: Dom.Element = Dom.Element.Style(Vector.empty, Vector.empty)
var i = 0
while (i < modifiers.length) {
elem = modifiers(i).applyTo(elem)
i += 1
}
elem.asInstanceOf[Dom.Element.Style]
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elScript/elStyle build a Script/Style element but then return it via asInstanceOf. A user-defined Modifier can legally return a different Dom.Element subtype, which would cause a ClassCastException at runtime. Consider keeping the builder typed as Dom.Element.Script/Style throughout (or validating the final type and failing with a clearer error).

Copilot uses AI. Check for mistakes.
sb.append(' ')
sb.append(name)
sb.append("=\"")
sb.append(js.value)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsValue attribute values are written into a quoted HTML attribute without any escaping. If js.value contains a double quote (or </&), it can break out of the attribute value and inject additional attributes/markup, which is an XSS vector. Consider HTML-escaping at least ", <, >, and & here (or rendering event handler attributes differently), and/or tightening the Js type so it cannot contain unsafe characters in attribute context.

Suggested change
sb.append(js.value)
sb.append(Escape.html(js.value))

Copilot uses AI. Check for mistakes.
}

implicit val jsToAttrValue: ToAttrValue[Js] = new ToAttrValue[Js] {
def toAttrValue(a: Js): String = a.value
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToAttrValue[Js] returns the raw JS string, but attribute values are ultimately emitted inside double quotes. Without escaping, a Js value containing " can break out of the attribute and inject markup. If Js is intended for event-handler attribute values, it still needs HTML attribute escaping (or a separate safe wrapper type that guarantees it contains no " / < / &).

Suggested change
def toAttrValue(a: Js): String = a.value
def toAttrValue(a: Js): String = Escape.html(a.value)

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +96
val processedArgs: Seq[Expr[Vector[Dom]]] = argExprs.map { argExpr =>
val argType = argExpr.asTerm.tpe.widen
val toElementsTc = TypeRepr.of[ToElements]
val toElementsType = toElementsTc.appliedTo(argType)
Implicits.search(toElementsType) match {
case success: ImplicitSearchSuccess =>
argType.asType match {
case '[t] =>
val instanceExpr = success.tree.asExprOf[ToElements[t]]
val typedArgExpr = argExpr.asExprOf[t]
'{ $instanceExpr.toElements($typedArgExpr) }
}
case _: ImplicitSearchFailure =>
report.errorAndAbort(s"No ToElements instance found for type ${argType.show}")
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The html interpolator macro currently converts all interpolated args using ToElements (content context) and then concatenates them with raw string parts. This doesn’t implement the PR description’s “position-based typeclasses” (tag/attr name/value contexts), and it makes examples like html"<div class=$cls>" unsafe because the interpolation is not treated as an attribute value (spaces can terminate the unquoted value and inject new attributes). Either implement real HTML parsing/position tracking to use ToTagName/ToAttrName/ToAttrValue, or update the PR description and docs to reflect the current (content-only) behavior.

Copilot uses AI. Check for mistakes.
Dom.Attribute.KeyValue(attrName, Dom.AttributeValue.MultiValue(values, separator))

def applyTo(element: Dom.Element): Dom.Element =
element.withAttributes(element.attributes :+ Dom.Attribute.BooleanAttribute(attrName))
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PartialAttribute extends Modifier, and applyTo always adds a boolean attribute (no value). This produces invalid HTML for non-boolean attributes (e.g. div(id) would render <div id></div>). Consider splitting boolean attributes into a separate type (e.g. PartialBooleanAttribute) or making PartialAttribute not a Modifier and providing explicit boolean-attribute vals only for known boolean attrs.

Suggested change
element.withAttributes(element.attributes :+ Dom.Attribute.BooleanAttribute(attrName))
element.withAttributes(
element.attributes :+ Dom.Attribute.KeyValue(attrName, Dom.AttributeValue.BooleanValue(true))
)

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +66
def jsString(s: String): String = {
val len = s.length
if (len == 0) return s

val sb = new java.lang.StringBuilder(len + 16)
var i = 0
while (i < len) {
val c = s.charAt(i)
if (c == '"') sb.append("\\\"")
else if (c == '\'') sb.append("\\'")
else if (c == '\\') sb.append("\\\\")
else if (c == '\n') sb.append("\\n")
else if (c == '\r') sb.append("\\r")
else if (c == '\t') sb.append("\\t")
else if (c == '<') sb.append("\\u003c")
else if (c == '>') sb.append("\\u003e")
else if (c == '&') sb.append("\\u0026")
else if (c < 32) {
sb.append("\\u")
val hex = Integer.toHexString(c.toInt)
var pad = 4 - hex.length
while (pad > 0) {
sb.append('0')
pad -= 1
}
sb.append(hex)
} else sb.append(c)
i += 1
}
sb.toString
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape.jsString doesn’t escape Unicode line separators U+2028 and U+2029, which are treated as line terminators in JavaScript string literals and can break generated code. Consider escaping these to \u2028 / \u2029 (and add a regression test) so ToJs[String] is safe for all inputs.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +101
final case class CssLength(value: Double, unit: String) {
def render: String = {
val v = if (value == value.toLong.toDouble) value.toLong.toString else value.toString
v + unit
}
}

object CssLength {
implicit class CssLengthIntOps(private val n: Int) extends AnyVal {
def px: CssLength = CssLength(n.toDouble, "px")
def em: CssLength = CssLength(n.toDouble, "em")
def rem: CssLength = CssLength(n.toDouble, "rem")
def pct: CssLength = CssLength(n.toDouble, "%")
def vh: CssLength = CssLength(n.toDouble, "vh")
def vw: CssLength = CssLength(n.toDouble, "vw")
}

implicit class CssLengthDoubleOps(private val n: Double) extends AnyVal {
def px: CssLength = CssLength(n, "px")
def em: CssLength = CssLength(n, "em")
def rem: CssLength = CssLength(n, "rem")
def pct: CssLength = CssLength(n, "%")
def vh: CssLength = CssLength(n, "vh")
def vw: CssLength = CssLength(n, "vw")
}
}

sealed trait CssColor extends Product with Serializable {
def render: String
}

object CssColor {
final case class Hex(value: String) extends CssColor {
def render: String = "#" + value
}

final case class Rgb(r: Int, g: Int, b: Int) extends CssColor {
def render: String = "rgb(" + r + "," + g + "," + b + ")"
}

final case class Rgba(r: Int, g: Int, b: Int, a: Double) extends CssColor {
def render: String = "rgba(" + r + "," + g + "," + b + "," + a + ")"
}

final case class Hsl(h: Int, s: Int, l: Int) extends CssColor {
def render: String = "hsl(" + h + "," + s + "%," + l + "%)"
}

final case class Named(name: String) extends CssColor {
def render: String = name
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CssLength(unit: String) and CssColor.Named(name: String) are public and render their raw strings without validation/escaping of CSS syntax delimiters (e.g. ;, }), which can allow CSS injection when used with the css"" interpolator. Consider restricting units/colors to validated/whitelisted values (e.g. sealed ADTs + private constructors / smart constructors) or extending Escape.cssString / renderers to prevent breaking out of the intended CSS value context.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +45
sealed trait Dom extends Product with Serializable {
def render: String = {
val sb = new java.lang.StringBuilder(256)
Dom.renderTo(this, sb, minified = false)
sb.toString
}

def render(indentation: Boolean): String =
if (!indentation) render
else {
val sb = new java.lang.StringBuilder(256)
Dom.renderIndented(this, sb, level = 0)
sb.toString
}

def renderMinified: String = {
val sb = new java.lang.StringBuilder(256)
Dom.renderTo(this, sb, minified = true)
sb.toString
}

def collect(pf: PartialFunction[Dom, Dom]): List[Dom] = {
val buf = List.newBuilder[Dom]
Dom.collectImpl(this, pf, buf)
buf.result()
}

def filter(predicate: Dom => Boolean): Dom =
Dom.filterImpl(this, predicate)

def find(predicate: Dom => Boolean): Option[Dom] =
Dom.findImpl(this, predicate)

def transform(f: Dom => Dom): Dom =
Dom.transformImpl(this, f)

def isEmpty: Boolean = this match {
case Dom.Empty => true
case Dom.Text(c) => c.isEmpty
case Dom.RawHtml(h) => h.isEmpty
case Dom.Fragment(children) => children.forall(_.isEmpty)
case _: Dom.Element => false
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Dom ADT is a public API surface, but it currently has no Scaladoc. Given the size/complexity of the API (rendering modes, traversal methods, escaping semantics), please add Scaladoc for Dom (and other new public types in this module) describing key behaviors and security caveats (e.g. RawHtml and script/style escaping rules).

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +78
def htmlImpl(c: blackbox.Context)(args: c.Expr[Any]*): c.Expr[Dom] = {
import c.universe._

val processedArgs = args.map { argExpr =>
val argType = argExpr.actualType.widen
val toElementsTc = typeOf[ToElements[_]].typeConstructor
val toElementsType = appliedType(toElementsTc, argType)
val instance = c.inferImplicitValue(toElementsType, silent = true)
if (instance == EmptyTree) {
c.abort(argExpr.tree.pos, s"No ToElements instance found for type $argType")
}
q"$instance.toElements(${argExpr.tree})"
}

val scExpr = c.Expr[StringContext](c.prefix.tree.asInstanceOf[Apply].args.head)
val argsExpr = c.Expr[Seq[Vector[Dom]]](q"_root_.scala.Seq(..$processedArgs)")
reify(InterpolatorRuntime.buildHtml(scExpr.splice, argsExpr.splice))
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as Scala 3 macro: the html interpolator treats every interpolation as ToElements and does not distinguish tag/attribute contexts, so it can’t enforce SafeTagName/SafeAttrName and can be unsafe when used in attribute positions (e.g. unquoted attribute injection via spaces). This diverges from the PR description; please either implement position-aware parsing/typeclass selection or adjust the documented API/guarantees.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +25
def buildCss(sc: StringContext, args: Seq[String]): Css = {
val sb = new java.lang.StringBuilder
val partsIter = sc.parts.iterator
val argsIter = args.iterator
while (partsIter.hasNext) {
sb.append(partsIter.next())
if (argsIter.hasNext) sb.append(argsIter.next())
}
Css(sb.toString)
}

def buildJs(sc: StringContext, args: Seq[String]): Js = {
val sb = new java.lang.StringBuilder
val partsIter = sc.parts.iterator
val argsIter = args.iterator
while (partsIter.hasNext) {
sb.append(partsIter.next())
if (argsIter.hasNext) sb.append(argsIter.next())
}
Js(sb.toString)
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InterpolatorRuntime.buildCss/buildJs/buildHtml don’t validate that args.length == sc.parts.length - 1. If these are called directly (not via the interpolator syntax), extra args will be silently ignored and missing args will be silently omitted, which can hide bugs. Consider adding an arity check and throwing an IllegalArgumentException like StringContext.s does.

Copilot uses AI. Check for mistakes.
@987Nabil
Copy link
Contributor Author

987Nabil commented Mar 8, 2026

@jdegoes this is also a blocker but a more soft one

@@ -0,0 +1,3 @@
package zio.blocks.template

final case class Css(value: String)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we turn this into an ADT with full parsing (both compile time via interpolator, and runtime), and printing + pretty printing?

sb.toString
}

def render(indentation: Boolean): String =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indent: Int so you can control spaces per indentation level?

final case class Generic(
tag: String,
attributes: Vector[Attribute],
children: Vector[Dom]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change from Vector to Chunk everywhere.


final case class Text(content: String) extends Dom

final case class RawHtml(html: String) extends Dom
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an escape hatch -- and destroys all of our guarantees around well-formedness of render.


final case class RawHtml(html: String) extends Dom

final case class Fragment(children: Vector[Dom]) extends Dom
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one requires special handling everywhere it is used, I think.


def text(content: String): Text = Text(content)

def raw(html: String): RawHtml = RawHtml(html)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe, remove.

@@ -0,0 +1,90 @@
package zio.blocks.template

object Escape {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package private; consider moving to internal package.


object EventAttrName {

def apply(name: String): Option[EventAttrName] = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This smart constructor pattern argues for sealed trait / enum instead of the case class. But there are custom events, so 🤷

}
}

def html(modifiers: Modifier*): Dom.Element = el("html", modifiers)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each of these, the pattern I would use:

Suggested change
def html(modifiers: Modifier*): Dom.Element = el("html", modifiers)
def html: Dom.Element = el("html", Nil)
def html(modifier: Modifier, modifiers: Modifier*): Dom.Element = el("html", Seq(modifier) ++ modifiers)

val translate: PartialAttribute = new PartialAttribute("translate")
val citeAttr: PartialAttribute = new PartialAttribute("cite")
val slotAttr: PartialAttribute = new PartialAttribute("slot")
val xmlns: PartialAttribute = new PartialAttribute("xmlns")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double check we have all of them here and below (for the partial multi attributes).

@@ -0,0 +1,444 @@
package zio.blocks.template

object InterpolatorRuntime {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package private?

package zio.blocks.template

import zio.test._
import CssLength.CssLengthIntOps
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't love this -- bring in the extensions in the template package (object) directly.

assertTrue(result == Css("width: 100"))
}
),
suite("js interpolator")(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have many more suites for js interpolator, with abundant tests.

Separate specs for:

  • css
  • js
  • html

suite("css interpolator")(
test("static CSS string") {
val result = css"color: red"
assertTrue(result == Css("color: red"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be type-class based interpolation for CSS, JS, and HTML.

test("JS with interpolated String is quoted and escaped") {
val result = js"var s = ${"hello"}"
assertTrue(result == Js("var s = \"hello\""))
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToJs type class: automatic instance for anything which has a Schema (via JSON).

assertTrue(result == Js("var b = true"))
}
),
suite("html interpolator")(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open questions - can I interpolate:

  • inside attribute (names & values)
    • <a href="${url}"></a>
    • <tag ${name}="value"/>
  • inside body
    • <p>$text</p>
  • element names
    • <$tagName></$tagName>

987Nabil added a commit that referenced this pull request Mar 14, 2026
Address all PR #1164 review comments and add new features:

- CssSelector ADT with full combinators (>, >>, +, ~, &, |) and pseudo-classes
- Css structured ADT (Rule, Declaration, Sheet, Raw, Comment)
- CssSelectable trait mixed into Dom.Element
- Vector → Chunk migration throughout
- Remove RawHtml and Fragment from Dom ADT
- Element.apply(modifier, modifiers*) guarantees non-empty
- Escape and InterpolatorRuntime made private[template]
- Js sealed abstract case class with stripMargin
- ToJs Schema auto-derivation via JSON
- Position-aware html interpolator (ToAttrValue for attrs, ToElements for content)
- selector"" interpolator producing CssSelector
- render(indent: Int) for configurable indentation

Scala 3 compile-time optimizations:
- Constant folding: zero-arg css/js/html/selector produce compile-time constants
- Tag macro (optimizedApply): classifies modifiers at compile time,
  generates direct Dom.Element.Generic construction instead of
  sequential applyTo chain (eliminates N intermediate copies)
- PreRendered Dom variant for macro-generated static subtrees
- InlineTag with inline val element definitions

425 tests on Scala 3, 400 on Scala 2, all green on JVM + JS.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@987Nabil 987Nabil changed the title feat(template): add zio-blocks-template — type-safe HTML templating micro library feat(template): zio-blocks-template — type-safe HTML templating with compile-time optimizations Mar 14, 2026
987Nabil added a commit that referenced this pull request Mar 14, 2026
Address all PR #1164 review comments and add new features:

- CssSelector ADT with full combinators (>, >>, +, ~, &, |) and pseudo-classes
- Css structured ADT (Rule, Declaration, Sheet, Raw, Comment)
- CssSelectable trait mixed into Dom.Element
- Vector → Chunk migration throughout
- Remove RawHtml and Fragment from Dom ADT
- Element.apply(modifier, modifiers*) guarantees non-empty
- Escape and InterpolatorRuntime made private[template]
- Js sealed abstract case class with stripMargin
- ToJs Schema auto-derivation via JSON
- Position-aware html interpolator (ToAttrValue for attrs, ToElements for content)
- selector"" interpolator producing CssSelector
- render(indent: Int) for configurable indentation

Scala 3 compile-time optimizations:
- Constant folding: zero-arg css/js/html/selector produce compile-time constants
- Tag macro (optimizedApply): classifies modifiers at compile time,
  generates direct Dom.Element.Generic construction instead of
  sequential applyTo chain (eliminates N intermediate copies)
- PreRendered Dom variant for macro-generated static subtrees
- InlineTag with inline val element definitions

425 tests on Scala 3, 400 on Scala 2, all green on JVM + JS.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
987Nabil and others added 23 commits March 21, 2026 09:50
Add features discovered by comparing against zio-http template2 test suite:
- XHTML-style self-closing void elements (<br/> instead of <br>)
- Indentation rendering via render(indentation = true)
- AttributeSeparator sealed trait (Space, Comma, Semicolon, Custom)
- PartialMultiAttribute for multi-value attrs (class := ("a", "b"))
- Dom.boolAttr, Dom.multiAttr factory methods
- Element.apply for curried modifiers: form(action := ...)(children*)
- when/whenSome conditional methods on Element
- DOM traversal: collect, filter, find, transform, isEmpty
- Iterable[Modifier] implicit conversion
- raw, fragment, empty helper functions in HtmlElements
- ariaDescribedby, ariaLabelledby predefined multi-value attributes

219 tests passing on Scala 2.13.18, 3.7.4 (JVM + JS).
Add comprehensive tests to achieve 100% statement and branch coverage
for all runtime code (macro files excluded). Set coverage minimums to
95% statement / 90% branch in build.sbt.

315 tests passing on Scala 2.13.18, 3.7.4 (JVM + JS).
- HTML-escape JsValue in attribute rendering to prevent XSS (Dom.scala)
- HTML-escape Js in ToAttrValue typeclass instance (ToAttrValue.scala)
- Add U+2028/U+2029 Unicode line separator escaping to Escape.jsString
- Add arity check to InterpolatorRuntime build methods
- Replace unsafe asInstanceOf with defensive pattern match in elScript/elStyle

317 tests passing on Scala 2.13.18 + 3.7.4.
…d of RawHtml

Replace RawHtml usage in html"" interpolator with a full HTML parser that
produces the typed DOM tree. The interpolator now creates proper
Dom.Element.Generic, Dom.Element.Script, Dom.Element.Style, and Dom.Text
nodes — never Dom.RawHtml.

Uses sentinel-based approach for interpolation: concatenates parts with
sentinel markers, parses the full HTML, then substitutes sentinels with
actual interpolated args.

Parser handles: nested elements, void elements (self-closing), attributes,
script/style raw content (not parsed as HTML), mixed text and elements.

Add 18 structural equality tests verifying html"" output matches typed
DSL output (e.g. html"<div>hello</div>" == div("hello")).

335 tests passing on Scala 2.13.18, 3.7.4 (JVM + JS).
Add 25 edge case tests for the HTML parser covering malformed HTML,
DOCTYPE, comments, unclosed elements, stray closing tags, single-quoted
and unquoted attributes, truncated input, and bare < in text.

360 tests passing, 100% statement and branch coverage.
Address all PR #1164 review comments and add new features:

- CssSelector ADT with full combinators (>, >>, +, ~, &, |) and pseudo-classes
- Css structured ADT (Rule, Declaration, Sheet, Raw, Comment)
- CssSelectable trait mixed into Dom.Element
- Vector → Chunk migration throughout
- Remove RawHtml and Fragment from Dom ADT
- Element.apply(modifier, modifiers*) guarantees non-empty
- Escape and InterpolatorRuntime made private[template]
- Js sealed abstract case class with stripMargin
- ToJs Schema auto-derivation via JSON
- Position-aware html interpolator (ToAttrValue for attrs, ToElements for content)
- selector"" interpolator producing CssSelector
- render(indent: Int) for configurable indentation

Scala 3 compile-time optimizations:
- Constant folding: zero-arg css/js/html/selector produce compile-time constants
- Tag macro (optimizedApply): classifies modifiers at compile time,
  generates direct Dom.Element.Generic construction instead of
  sequential applyTo chain (eliminates N intermediate copies)
- PreRendered Dom variant for macro-generated static subtrees
- InlineTag with inline val element definitions

425 tests on Scala 3, 400 on Scala 2, all green on JVM + JS.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Remove InlineTag and optimizedApply. There is only Tag.apply:
- Scala 2: runtime sequential applyTo
- Scala 3: inline macro that classifies modifiers at compile time

Users write div(id := "main", "Hello") on both versions.
The optimization is transparent — zero API difference.
Add 95+ tests covering:
- CssSelector combinators and pseudo-classes/elements
- Css ADT rendering (Rule, Sheet, Comment)
- Attribute rendering (MultiValue, BooleanValue, JsValue)
- Element when/whenSome, script/style helpers
- InterpolatorRuntime parser edge cases
- ToJs Schema derivation

Exclude compile-time macro files from coverage (TagMacro, InlineTag).
- Validate attribute names in rendering (reject unsafe chars)
- Validate tag names in rendering (must match [a-zA-Z][a-zA-Z0-9-]*)
- Escape */ in Css.Comment to prevent CSS injection
- Escape values in CssSelector.Attribute to prevent selector injection
- Escape.jsString fast-path: no allocation when no escaping needed
- Escape.cssString: escape control characters (c < 32)
- CssLength: validate units via whitelist, guard against NaN/Infinity
- Deduplicate voidElements (shared definition in Dom companion)
- Fix collectImpl double evaluation (use pf.lift)
…rity caveats

- Fix CSS selector method names (attr* → withAttribute*)
- Fix ToJs[String] double-quoting example
- Fix self-referencing selector example
- Remove reference to private Dom.preRendered
- Add script/style security caveat
Boolean vs key-value attribute split (matches zio-http template2):
- PartialAttribute no longer extends Modifier (div(id) won't compile)
- BooleanAttribute extends Attribute with Modifier (div(disabled) works)
- HtmlElements: boolean attrs use Dom.boolAttr(), key-value use Dom.attr()

Full context-aware html"" interpolator:
- State machine detects 4 positions: tag name, attr name, attr value, content
- Summons ToTagName for <$tag>, ToAttrName for $attr=, ToAttrValue for =$value, ToElements for >$content<
- Both Scala 3 and Scala 2 macros updated

Security fixes:
- Script.inlineJs(String) escapes </script> breakout (</ → <\/)
- CssColor.Named validates against 148-color whitelist
- CssColor.Hex validates hex format

Tests: 572 (Scala 3) / 547 (Scala 2), including:
- 15 inline CSS/JS tests
- 33 context-aware html + boolean attr tests
Address Copilot review comments requesting documentation on:
- Dom ADT structure and rendering model
- Escaping semantics (Text=escaped, Script/Style=raw)
- PreRendered as internal-only type
- Tree traversal operations (collect, filter, find, transform)
- Security caveats for script/style content
…ToJs + fix docs

Address Copilot review comments:
- Scaladoc on Css ADT (variants, rendering)
- Scaladoc on CssSelector (operators, escaping)
- Scaladoc on TemplateInterpolators (interpolators, typeclasses)
- Scaladoc on ToJs (escaping rules, Schema derivation)
- Fix InterpolatorRuntime sentinel comment
- Remove Dom.Raw doc reference (not public API)
- Fix js"" examples (ToJs[String] already double-quotes)
Replace all .render.contains() assertions with == full string equality.
Split InterpolatorSpec into JsInterpolatorSpec, CssInterpolatorSpec,
HtmlInterpolatorSpec, SelectorInterpolatorSpec per jdegoes review.
Add ToTagName[Dom.Element] for natural html"\<$div>...<\/$div>" syntax.
Add render assertion rule to AGENTS.md.

Addresses jdegoes comment about EventAttrName: kept as case class with
smart constructor + unsafe escape hatch, since custom events exist.
…ToAttrName, ToTagName

Simplify html"" interpolator to 2 positions only:
- Attribute value (ToAttrValue) — after = in attributes
- Content (ToElements) — between tags

Tag names and attribute names must be literal strings in the template.
Dynamic tag/attr name interpolation removed (not needed in practice).
Can be added back later in a binary-compatible way if users request it.

Deleted: SafeAttrName.scala, EventAttrName.scala, SafeTagName.scala,
ToAttrName.scala, ToTagName.scala, SafeNameSpec.scala
…hema dep docs

DX improvements:
- Duplicate class attributes merged with space separator (div(className := "a", className := "b") renders class="a b")
- html"" with multiple top-level nodes now throws IllegalArgumentException instead of silently wrapping in <span>
- Schema dependency documented in template.md
Unified API across Scala 2 and 3:
- All elements: val div: Tag = new Tag("div")
- script/style: overloaded def (zero-arg + one-or-more)
- element(tag): returns Tag for custom elements
- Removed el() private helper (no longer needed)
… vals

Tag was a pointless wrapper. Dom.Element already has apply(modifier, modifiers*).

Deleted: Tag.scala (both S2/S3), TagMacro.scala, PreRendered Dom variant,
OptimizationEquivalenceSpec.scala

Elements are now simply:
  val div: Dom.Element = Dom.Element.Generic("div", Chunk.empty, Chunk.empty)

div alone = bare element. div(id := "main") = calls Element.apply.
No wrapper class, no implicit conversion, no macro indirection.
Address 987Nabil review: replace escaped quotes with raw strings
throughout all test files for readability.
New semantics for multi-value attributes (className, rel, etc.):
- := overrides (last wins): className := "a" then := "b" → class="b"
- += accumulates: className += "a" then += "b" → class="a b"
- Mixed: className := "base" then += "extra" → class="base extra"

Added Dom.Attribute.AppendValue case for accumulation semantics.
resolveAttributes handles both override and append at render time.
…lor, etc.)

Css.Declaration("margin", 10.px) now works directly.
Constructor uses ToCss typeclass to convert typed values to strings.
Pattern matching still works (unapply public, constructor private).
Elements: hgroup, menu
Attributes: blocking, enterKeyHint, exportParts, fetchPriority, inert,
inputMode, itemId, itemProp, itemRef, itemScope, itemType, nonce, part,
popover, popoverTarget, popoverTargetAction, writingSuggestions

Docs updated: += accumulation, Css.Declaration ToCss, constant folding,
removed references to deleted types (Tag, SafeTagName, etc.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants