Building a multiplatform DOM DSL in Kotlin
09 Apr, 2022
For a project I needed to generate a lot of SVG's both in JS as well as in a backend. I assumed that this could easily be solved by using Kotlin Multiplatform, but I ran into some problems. Eventually I decided to build a minimal DSL to solve the problem I had.
Generating DOM Documents in JS and the JVM
In this project I needed to generate dynamic SVG files based on some parameters. Since identical images needed to be created in the frontend (JS), as well as in the backend (JVM) and we were already using Kotlin, the obvious choice was just to make a Kotlin Multiplatform library. By connecting to platform specific api's I thought I could easily wrap the Document
API's of both the JVM and the browser.
However, as I soon found out, they aren't completely identical. Besides, I didn't feel like having to create a whole bunch of expect
/actual
things for a hiararchy of interfaces and or classes.
I thought Jetbrains already solved this issue with the kotilinx.html library, but that only focusses on HTML, and not on SVG's or DOM Documents in general.
Use cases
What I wanted was at least the following:
- Generate DOM elements in the browser
- Generate DOM elements in the backend
- Have the same codebase for both
- Have support for namespaces (for SVG's)
- Have a more ergonomic API than the classical imperative API
- Support a Virtual DOM
Basic setup
Okay, so I created a new Kotlin Multiplatform library and started working only on the Common module, to be as language agnostic as possible.
Definition and writers
The first thing was that I wanted to separate the content or definition of the DOM Document from the actual implementation. I solved this by creating an interface named Definition
that can write to a Writer
. They are initially defined as follows:
fun interface Definition {
fun write(writer: Writer<*>)
}
interface Writer<T> {
fun beginElement(name: String, namespace: String?)
fun endElement()
fun writeAttribute(name: String, value: String)
fun writeText(text: String)
fun result(): T
}
The Definition
interface has only 1 method and can as such be written as a functional interface. The Writer
encapsulates the side-effects, whereas the Definition
contains the structure of the DOM Document.
Definition Builder DSL
The most important thing to do is to be able to generate a Definition
. One could just implement them for every DOM Document I want to generate but that makes it very cumbersome and verbose. More interesting would be to create a nice builder DSL that writer to a Writer
and wire the Builder
up to a Definition
later.
We start with the following Builder
:
class Builder(
private val writer: Writer<*>,
private val namespace: String?
) {
fun element(name: String, block: Builder.() -> Unit = {}) {
writer.beginElement(name, namespace)
block(this)
writer.endElement()
}
fun attribute(name: String, value: String) {
writer.writeAttribute(name, value)
}
fun text(raw: String) {
writer.writeText(raw)
}
}
The Builder
requires a Writer
instance and a namespace
after which it is possible to write to a Writer
. This wouldn't be very interesting if it wasn't for the block: Builder.() -> Unit
arguments in the element
method. This is a function literal with a receiver, which means that the this
in the scope of the lambda refers to the Builder
instance that is passed as first argument. So, we can use it like follows:
val writer: Writer<*> = ...
val builder = Builder(writer, namespace="ns")
with(builder) { // `this` is builder
element("root") { // `this` is builder still
attribute("foo", "bar")
element("child")
}
}
// this translates to:
writer.beginElement("root", "ns")
writer.writeAttribute("foo", "bar", "ns")
writer.beginElement("child")
writer.endElement()
writer.endElement()
Wiring up the Builder to the Definition
It is pretty trivial to add the builder to the definition now:
fun interface Definition {
fun write(writer: Writer<*>)
companion object {
fun build(
namespace: String?,
block: Builder.() -> Unit = {}
): Definition = Definition { writer ->
block(Builder(writer, namespace))
}
}
}
And now we can create a definition like this:
val domDefinition = Definition.build("ns") {
element("root") { // `this` is builder still
attribute("foo", "bar")
element("child")
}
}
// and write to it like this
val writer : Writer<*> = ....
domDefinition.write(writer)
Improving the DSL
It is still a bit verbose, so by modifying the Builder
we can make it a little bit more ergonomic:
class Builder(
private val writer: Writer<*>,
private val namespace: String?
) {
private fun element(name: String, block: Builder.() -> Unit = {}) {
writer.beginElement(name, namespace)
block(this)
writer.endElement()
}
private fun attribute(name: String, value: String) {
writer.writeAttribute(name, value, namespace)
}
fun text(raw: String) {
writer.writeText(raw)
}
// Adding these operator functions
operator fun String.invoke(block: Builder.() -> Unit = {}) {
element(this, block)
}
operator fun String.invoke(
vararg args: Pair<String?, Any?>?,
block: Builder.() -> Unit = {})
{
element(this) {
args.filterNotNull()
.filter { (name, value) -> name != null && value != null }
.forEach { (name, value) -> attribute(name!!, value.toString()!!) }
block(this)
}
}
}
We are using extension functions on String
inside the Builder
class. We add the invoke
operator function to String
which allows us to 'call' String
inside the scope of Builder
. Together with varargs
we have a very simple expressive DSL, which results into:
val domDefinition = Definition.build("ns") {
"root" ("foo" to "bar") {
"child" ("baz" to "bat", "quux" to 1234)
"single" ()
}
}
Now, by adding some utility functions we can improve our DSL a little more:
fun xml(
root: String,
namespace: String?,
vararg args: Pair<String?, Any?>?,
block: Builder.() -> Unit = {}
): Definition {
return Definition.build(namespace) {
root.invoke(*args, block=block)
}
}
fun xml(root: String, block: Builder.() -> Unit = {}) = xml(root, namespace=null, args= arrayOf(), block = block)
fun xml(root: String, vararg args: Pair<String?, Any?>?, block: Builder.() -> Unit = {}) = xml(root, namespace=null, args=args, block = block)
fun svg(
width: Int?,
height: Int?,
viewBox: Boolean = true,
block: Builder.() -> Unit = {}
): Definition {
return Definition.build("http://www.w3.org/2000/svg") {
"svg"(
"xmlns" to "http://www.w3.org/2000/svg",
"version" to "1.1",
"width" to width?.toString(),
"height" to height?.toString(),
if (viewBox && width != null && height != null) "viewBox" to "0 0 $width $height" else null,
block = block
)
}
}
As can be seen we can now eaily generate XML elements (or SVG) elements using these simple functions:
val test = svg(width = 300, height = 200) {
"rect" ("width" to "100%", "height" to "100%", "fill" to "red")
"circle" ("cx" to 150, "cy" to 100, "r" to 80, "fill" to "green")
"text" ("x" to 150, "y" to 125, "font-size" to 60, "text-anchor" to "middle", "fill" to "white") {
text("SVG")
}
}
which will be rendered to:
<svg version="1.1" width="300" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="red" />
<circle cx="150" cy="100" r="80" fill="green" />
<text x="150" y="125" font-size="60" text-anchor="middle" fill="white">SVG</text>
</svg>
To be honest, this isn't necessarily less verbose than XML itself is. But the power comes from the flexibility of creating writers.
Creating Writers
Appendable
The first one is a common
-module writer which just writes the definition to an Appendable
:
class AppendableWriter(
private val appendable: Appendable,
private val prettyPrint: Boolean,
private val prettyPrintIndent: Int
) : Writer<String> {
private val tags = ArrayDeque<String>()
private val hasChildren = ArrayDeque<Boolean>()
private var level = 0
private val indent = if (prettyPrint) " ".repeat(prettyPrintIndent) else ""
private val newline = if (prettyPrint) "\n" else ""
override fun beginElement(tag: String, namespace: String?) {
if (!hasChildren.isEmpty()) {
val currentHasChildren = hasChildren.removeFirst()
if (!currentHasChildren) {
appendable.append(">$newline")
}
hasChildren.addFirst(true)
}
level += 1
appendable.append("${indent()}<$tag")
tags.addFirst(tag)
hasChildren.addFirst(false)
}
override fun endElement() {
val tag = tags.removeFirst()
when (hasChildren.removeFirst()) {
true -> appendable.append("${indent()}</$tag>$newline")
else -> appendable.append(" />$newline")
}
level -= 1
}
override fun writeAttribute(name: String, value: String) {
appendable.append(" $name=\"$value\"")
}
override fun writeText(text: String) {
appendable.append(text)
}
override fun result(): String = appendable.toString()
private fun indent(): String = indent.repeat(level - 1)
}
// and a convencience extension function:
fun Definition.renderAsString(
prettyPrint: Boolean = false,
prettyPrintIndent: Int = 4
): String {
return AppendableWriter(StringBuilder(), prettyPrint, prettyPrintIndent).result()
}
// and we can now write:
val test : String = xml(....).renderAsString();
This one will now work in all platforms, since it is purely a common
-module.
Browser DOM
A writer that is used in the JS
module only is easily written as well:
import kotlinx.browser.document
import org.w3c.dom.Element
class BrowserWriter : Writer<Element> {
private var _result: Element? = null
private val stack = ArrayDeque<Element>()
override fun beginElement(name: String, namespace: String?) {
val element = document.createElementNS(namespace, name)
stack.firstOrNull()?.appendChild(element)
stack.addFirst(element)
}
override fun endElement() {
_result = stack.removeFirst()
}
override fun writeAttribute(name: String, value: String) {
stack.first().setAttribute(name, value)
}
override fun writeText(text: String) {
stack.first().textContent = text
}
override fun result(): Element = _result!!
}
// and a convenience extension function
fun Definition.toElement(): Element = BrowserWriter().also { writer ->
this.write(writer)
}.result()
// and we can now write:
val test : Element = svg(....).toElement();
document.body.appendChild(test);
We use a simple ArrayDeque
as a stack here, to keep track of the current element and its parents. We can now test this in our browser eaily by creating a main
in our JS module and creating an SVG in it.
fun demo(angle: Double): Definition = svg(width = 300, height = 200) {
"rect" ("width" to "100%", "height" to "100%", "fill" to "red")
"g" ("transform" to "rotate($angle, 150, 100)") {
"circle"("cx" to 150, "cy" to 100, "r" to 80, "fill" to "green")
"text"("x" to 150, "y" to 125, "font-size" to 60, "text-anchor" to "middle", "fill" to "white") {
text("SVG")
}
}
}
fun main() {
val app = document.getElementById("app")!!
// render them as normal elements
repeat(4) { i ->
app.appendChild(demo(i * 90.0).toElement())
}
}
Making a Virtual DOM Writer
The goal was to also support a Virtual DOM, and this can now easily be done by levering a virtual-dom library.
First we need to add this npm
dependency in our JS project:
// in our build.gradle.kts
val jsMain by getting {
dependencies {
implementation(npm("virtual-dom", "2.1.1"))
}
}
We are going to use the hyperdom-package for generating so-called VNodes. To make this properly work in Kotlin we need to make sure we expose the required functions and classes in Kotlin via externals, which are pretty well described in the docs. To reference the hyperdom and VNodes we add the following rules to a file:
package externals.virtualDom
import kotlin.js.Json
@JsModule("virtual-dom/vnode/vnode")
@JsNonModule
external interface VNode
@JsModule("virtual-dom/h")
@JsNonModule
external fun h(selector: String, properties: Json = definedExternally, children: Array<dynamic> = definedExternally): VNode
@JsModule("virtual-dom/create-element")
@JsNonModule
external fun createElement(vNode: VNode): Element
external interface Patches
@JsModule("virtual-dom/diff")
@JsNonModule
external fun diff(a: VNode, b: VNode): Patches
@JsModule("virtual-dom/patch")
@JsNonModule
external fun patch(element: Element, patches: Patches): Element
And here is the VirtuaDOMWriter:
class VirtualDomWriter : Writer<VNode> {
private var _result: VNode? = null
private val elements = ArrayDeque<Pair<String, String?>>()
private val children = ArrayDeque<MutableList<dynamic>>()
private val attributes = ArrayDeque<MutableList<Pair<String, String>>>()
override fun beginElement(name: String, namespace: String?) {
elements.addFirst(name to namespace)
children.addFirst(mutableListOf())
attributes.addFirst(mutableListOf())
}
override fun endElement() {
val (element, namespace) = elements.removeFirst()
val attributes = json(*attributes.removeFirst().toTypedArray())
val children = children.removeFirst().toTypedArray()
if (namespace != null) {
attributes["attributes"] = JSON.parse(JSON.stringify(attributes)) // ugly way to clone attributes
attributes["namespace"] = namespace
}
val vnode = h(selector = element, properties = attributes, children = children)
// add to parent
_result = vnode
this.children.firstOrNull()?.add(_result)
}
override fun writeAttribute(name: String, value: String) {
attributes.first().add(name to value)
}
override fun writeText(text: String) {
children.first().add(text)
}
override fun result(): VNode = _result!!
}
and some accompanying extensions to make our life easier:
fun Definition.toVNode(): VNode = VirtualDomWriter().also { writer ->
this.write(writer)
}.result()
fun VNode.toElement(): Element = createElement(this)
fun VNode.diff(other: VNode): Patches = externals.virtualDom.diff(this, other)
fun Element.patch(patches: Patches): Element = externals.virtualDom.patch(this, patches)
And now, with the magic of a virtual-dom we can make an easy rotating SVG:
var currentAngle = 0.0
var tree = demo(currentAngle).toVNode()
var element = tree.toElement()
app.appendChild(element)
window.setInterval({
currentAngle += 0.6
currentAngle %= 360.0
val newTree = demo(currentAngle).toVNode()
val patches = newTree.diff(tree)
element = element.patch(patches)
tree = newTree
}, 1000 / 60)
and this results in:
Closing remarks
So far I am pretty happy with the results, but there are some caveats of course:
- No escaping at this moment
- No support for events
However, it only took me a short time to come up with something relatively functional in common code, and with heavy flexibility. For me this blog post served more as a brain-dump than a very thorough blog. However, I am always open to questions. So contact me via e-mail, Twitter @avwie, or look at my repository of the coding examples.