feat(template): zio-blocks-template — type-safe HTML templating with compile-time optimizations#1164
feat(template): zio-blocks-template — type-safe HTML templating with compile-time optimizations#1164
Conversation
There was a problem hiding this comment.
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
DomADT 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. |
| 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] | ||
| } |
There was a problem hiding this comment.
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).
| sb.append(' ') | ||
| sb.append(name) | ||
| sb.append("=\"") | ||
| sb.append(js.value) |
There was a problem hiding this comment.
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.
| sb.append(js.value) | |
| sb.append(Escape.html(js.value)) |
| } | ||
|
|
||
| implicit val jsToAttrValue: ToAttrValue[Js] = new ToAttrValue[Js] { | ||
| def toAttrValue(a: Js): String = a.value |
There was a problem hiding this comment.
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 " / < / &).
| def toAttrValue(a: Js): String = a.value | |
| def toAttrValue(a: Js): String = Escape.html(a.value) |
| 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}") | ||
| } |
There was a problem hiding this comment.
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.
| Dom.Attribute.KeyValue(attrName, Dom.AttributeValue.MultiValue(values, separator)) | ||
|
|
||
| def applyTo(element: Dom.Element): Dom.Element = | ||
| element.withAttributes(element.attributes :+ Dom.Attribute.BooleanAttribute(attrName)) |
There was a problem hiding this comment.
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.
| element.withAttributes(element.attributes :+ Dom.Attribute.BooleanAttribute(attrName)) | |
| element.withAttributes( | |
| element.attributes :+ Dom.Attribute.KeyValue(attrName, Dom.AttributeValue.BooleanValue(true)) | |
| ) |
| 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 |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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).
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
|
@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) | |||
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
indent: Int so you can control spaces per indentation level?
| final case class Generic( | ||
| tag: String, | ||
| attributes: Vector[Attribute], | ||
| children: Vector[Dom] |
There was a problem hiding this comment.
Change from Vector to Chunk everywhere.
|
|
||
| final case class Text(content: String) extends Dom | ||
|
|
||
| final case class RawHtml(html: String) extends Dom |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
| @@ -0,0 +1,90 @@ | |||
| package zio.blocks.template | |||
|
|
|||
| object Escape { | |||
There was a problem hiding this comment.
Package private; consider moving to internal package.
|
|
||
| object EventAttrName { | ||
|
|
||
| def apply(name: String): Option[EventAttrName] = { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
For each of these, the pattern I would use:
| 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") |
There was a problem hiding this comment.
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 { | |||
| package zio.blocks.template | ||
|
|
||
| import zio.test._ | ||
| import CssLength.CssLengthIntOps |
There was a problem hiding this comment.
Don't love this -- bring in the extensions in the template package (object) directly.
| assertTrue(result == Css("width: 100")) | ||
| } | ||
| ), | ||
| suite("js interpolator")( |
There was a problem hiding this comment.
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")) |
There was a problem hiding this comment.
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\"")) | ||
| }, |
There was a problem hiding this comment.
ToJs type class: automatic instance for anything which has a Schema (via JSON).
| assertTrue(result == Js("var b = true")) | ||
| } | ||
| ), | ||
| suite("html interpolator")( |
There was a problem hiding this comment.
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>
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>
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>
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.)
Summary
Comprehensive implementation of zio-blocks-template — a zero-dependency, type-safe HTML templating micro library with compile-time optimizations.
Features
div(id := "main", className := "container", p("Hello!"))html"",css"",js"",selector""with position-aware escaping>,>>,+,~,&,|) and pseudo-classesCss.Rule,Css.Declaration,Css.Sheetwith pretty-printingToAttrValuefor attribute positions,ToElementsfor contentToJsinstances from Schema via JSONdiv.hover,div > span)Compile-Time Optimizations (Scala 3)
css"",js"",selector""produce compile-time constantsoptimizedApplyclassifies modifiers at compile time, generates directDom.Element.Genericconstruction (eliminates N intermediate copies)Review Comments Addressed
All 17 jdegoes comments from previous review:
render(indent: Int)Tests