Kotlin HTML DSL


#1

Hello everyone!

I wrote a little kotlin-html DSL:

@file:JvmName("DOM")

// flatten Element.attributes
fun Array<out Pair<String, String>>.join(quote: String = "'") = if (this.isEmpty()) ""
  else " " + this.map { "${it.first}=${quote + it.second + quote}" }.joinToString(" ") { it }

// Join element context into innerHTML
fun Array<out String>.join(separator: String = "") = if (this.isEmpty()) ""
  else this.joinToString(separator) { it }

/* DOM API */

fun html(vararg attributes: Pair<String, String> = arrayOf(),
         content: Array<String> = arrayOf(),
         func: (String) -> String) = "<html${attributes.join()}>${func(content.join())}</html>"

fun head(vararg attributes: Pair<String, String> = arrayOf(),
         content: Array<String> = arrayOf(),
         func: (String) -> String) = "<head${attributes.join()}>${func(content.join())}</head>"
fun title(vararg attributes: Pair<String, String> = arrayOf(),
          content: Array<String> = arrayOf(),
          func: (String) -> String) = "<title${attributes.join()}>${func(content.join())}</title>"

fun body(vararg attributes: Pair<String, String> = arrayOf(),
         content: Array<String> = arrayOf(),
         func: (String) -> String) = "<body${attributes.join()}>${func(content.join())}</body>"

fun div(vararg attributes: Pair<String, String> = arrayOf(),
        content: Array<String> = arrayOf(),
        //vararg content: String = arrayOf(),
        func: (String) -> String) = "<div${attributes.join()}>${func(content.join())}</div>"

//...

And it’s working perfectly fine if I’m explicitly concatenate element content head { /*...*/ } + body { /*...*/ }:

render(
  html("lang" to "en", "ng-app" to "my-app") {
    head {
      title {
        "Kotlin awesome!"
      }
    } +
    body {
      div("class" to "container-fluid") {
        "DSL as 1-2-3"
      }
    }
  }
)

output expected:

<html lang='en' ng-app='my-app'><head><title>Kotlin awesome!</title></head><body><div class='container-fluid'>DSL as 1-2-3</div></body></html>

what I need is understand if there is a way to make it possible avoid using + (plus operator)? I would like to use my DSL like so:

render(
  html("lang" to "en", "ng-app" to "my-app") {
    head {
      title {
        "Kotlin awesome!"
      }
    }
    body {
      div("class" to "container-fluid") {
        "DSL as 1-2-3"
      }
    }
  }
)

Code above compiling fine, but of course, output is wrong (only latest content item is rendering):

<html lang='en' ng-app='my-app'><body><div class='container-fluid'>DSL as 1-2-3</div></body></html>

Thanks for any help!


Regards,
Maksim


#2

One thing you could do is change your api so that the functions you pass to head, div etc are called with some context object as a receiver. Than you can modify this context instead of returning the result.

class HtmlBuilder {
    var html: String
        private set
    fun addCode(html: String) { html += code}
}

fun html (vararg attributes: Pair<String, String> = arrayOf(),
          content: Array<String> = arrayOf(),
          func: HtmlContext.(String) -> Unit): String{

	val context = HtmlContext()
	context.addCode("<html${attributes.join()}>")

	context.func(content.join())

	context.addCode("</html>")
	return context.html
}

fun HtmlContext.head(vararg attributes: Pair<String, String> = arrayOf(),
         content: Array<String> = arrayOf(),
		 func: HtmlContext.(String) -> Unit){

	addCode("<head${attributes.join()}>")
	
	func(content.join())
	
	addCode("</head>")
}

Also there is a existing implementation of a Html Domain by JetBrains here https://github.com/Kotlin/kotlinx.html
You might want to take a look at how they are doing this


#3

Hi, @Wasabi375!

Thanks for your feedback, but could you please be little bit more specific? Code you provided is not compiling

...func: HtmlBuilder.(String) -> String = addCode(...)

PS: I’m aware about kotlinx.html, but they are using more plus operators then mine DSL)) …and actually that task I’m doing in scope of learning Kotlin DSL, not as a yet another public library, at least at the moment…

Thanks


Regards


#4

Woops sry. I was not really paying to much attention to the code I posted above. I was a bit distracted. I fixed it above.


#5

If I remember correctly they use the + to add text to the htmlCode. In my example above you would do this by using addCode. As far as I can remember the html dsl by JetBrains uses the unary-plus-operator instead of calling my addCode. You could still decide to do this in order to differentiate maybe between text and html-code. You could for example add a addText extension which would do some formatting for special characters and escaping html for you which you could then access by the unary plus operator.
I personally would prefer the unary minus though. I think it would look more natural, just like you were listing stuff :slight_smile:

Implementation for unary minus extension, feel free to figure it out yourself first if you want
class HtmlContext {
    ...
    operator fun String.unaryMinus(){
        // TODO do some formatting
        addCode(this)
    }
}

#6

Thank you, @Wasabi375
Receiver did a trick. But now I have to explicitly pass text in title/div, otherwise output contains only markup with attributes, but not plain text in bottom of html-tree

extension looks like so:

@file:JvmName("DOM")

package daggerok.extensions

// flatten Element.attributes
fun Array<out Pair<String, String>>.join(quote: String = "'") = if (this.isEmpty()) ""
  else " " + this.map { "${it.first}=${quote + it.second + quote}" }
    .joinToString(" ") { it }

// collect element content into innerHTML
fun Array<out String>.join(separator: String = "") = if (this.isEmpty()) ""
  else this.joinToString(separator) { it }

// HTML content receiver (appender) builder
class HtmlBuilder {
  var innerHTML: String = "<!DOCTYPE html>"
    private set
  fun text(content: String) {
    innerHTML += content
  }
}

/* DOM API */
fun html(vararg attributes: Pair<String, String> = arrayOf(),
         content: Array<String> = arrayOf(),
         func: HtmlBuilder.(String) -> Unit): String {

  val context = HtmlBuilder()
  context.text("<html${attributes.join()}>")
  context.func(content.join())
  context.text("</html>")
  return context.innerHTML
}

fun HtmlBuilder.head(vararg attributes: Pair<String, String> = arrayOf(),
                     content: Array<String> = arrayOf(),
                     func: HtmlBuilder.(String) -> Unit) {

  text("<head${attributes.join()}>")
  func(content.join())
  text("</head>")
}

fun HtmlBuilder.title(vararg attributes: Pair<String, String> = arrayOf(),
                      content: Array<String> = arrayOf(),
                      func: HtmlBuilder.(String) -> Unit) {

  text("<title${attributes.join()}>")
  func(content.join())
  text("</title>")
}

fun HtmlBuilder.body(vararg attributes: Pair<String, String> = arrayOf(),
                     content: Array<String> = arrayOf(),
                     func: HtmlBuilder.(String) -> Unit) {

  text("<body${attributes.join()}>")
  func(content.join())
  text("</body>")
}

fun HtmlBuilder.div(vararg attributes: Pair<String, String> = arrayOf(),
                    content: Array<String> = arrayOf(), //vararg content: String = arrayOf(),
                    func: HtmlBuilder.(String) -> Unit) {

  text("<div${attributes.join()}>")
  func(content.join())
  text("</div>")
}

But now it’s required using addCode function for text nodes

fun main(args: Array<String>) {
  println(
    html("lang" to "en", "ng-app" to "my-app") {
      head {
        title {
          text("Kotlin awesome!")
        }
      }
      body {
        div("class" to "container-fluid") {
          text("DSL as 1-2-3")
        }
      }
    }
  )
}

if I’m omitting addCode output is wrong:

<!DOCTYPE html><html lang='en' ng-app='my-app'><head><title></title></head><body><div class='container-fluid'></div></body></html>

I can make setter non private in HtmlBuilder, so code will be little bit different and more DSLish, but…

class HtmlBuilder {
  var innerHTML: String = "<!DOCTYPE html>"
  // private set
  fun text(content: String) {
    innerHTML += content
  }
}
// ...
fun main(args: Array<String>) {
  println(
    html("lang" to "en", "ng-app" to "my-app") {
      head {
        title {
          innerHTML += "Kotlin awesome!"
        }
      }
      body {
        div("class" to "container-fluid") {
          innerHTML += "DSL as 1-2-3"
        }
      }
    }
  )
}

But still, it’s more verbose than just using + operator, like from beginning of a story…
Do you have any idea how that might be fixed?
Thanks!


Regards,
Maksim


#7

As I said above, you could use any unary operator instead of text. Another option would be to change the text function to take another func returning the html instead of taking it directly as an argument.

fun HtmlBuilder.text(func: () -> String) {
    text(funct()) 
}


html {
    text {
        "someText" 
    }
}

I can’t think of any way right now, in which you would be able to write this

html {
    div {
        "some Text"
        div {
            "some more Text" 
         }
         "even more Text"
     }
}

I just don’t see how this could be achieved. I think the closest you can get is having a unary operator before each text.


#8

Thanks for help, @Wasabi375