Skip to content

Introduce TagConsumer downcast extension point for DOM interop#296

Merged
bjhham merged 4 commits into
Kotlin:masterfrom
yoxjames:feature/jyAddDOMInterop
Apr 2, 2026
Merged

Introduce TagConsumer downcast extension point for DOM interop#296
bjhham merged 4 commits into
Kotlin:masterfrom
yoxjames:feature/jyAddDOMInterop

Conversation

@yoxjames

Copy link
Copy Markdown
Contributor

I am working on an implementation of the SVG 1.1 protocol. One thing I would really like to be able to do is seamless inter-operation with kotlinx-html. However I ran into an issue. For most targets, where the HTML being generated is really just text, this can be accomplished by simply using the unsafe raw text API:

val html = buildString {
            appendHTML(prettyPrint = true).html {
                body {
                    div {
                        unsafe {
                            raw(svgString { // My DSL Begins
                                svg {
                                  // Can use all the SVG elements you want in here
                                }
                            })
                        }
                    }
                }
            }
        }

I could easily refactor this into an extension function that simply taps into the raw call. In fact, there are quite a few examples of libraries that have done this. However on targets like JS or WasmJS, kotlinx-html actually generates DOM elements. If I attempt to tap into the raw(...) calls I'll just be adding text to DOM elements and not adding DOM elements!

I want the ability for one DSL to seamlessly interoperate with kotlinx-html on those targets! Obviously this was created due to a need for a library I am developing, but this should be generic enough to be used any library that would like to interop this way.

Right now, if I want to create an extension function it look something like this:

fun <T> TagConsumer<HTMLElement>.svg4k(
    block: context(dev.jamesyox.svg4k.TagConsumer<SVGElement>, RootContainer) () -> T
): T {
    val tagConsumer = JsDomTagConsumer(document)
    val output = block(tagConsumer, RootContainer)
    val svgDom = tagConsumer.output()
    val hackDiv = div { }
    val currentNode = hackDiv.parentNode
    currentNode?.removeChild(hackDiv)

    currentNode?.let {
        svgDom.forEach { child -> it.appendChild(child) }
    }
    return output
}

Essentially I create a div so that I can get what that method returns as I have no way to grab the HTML element inside of the DSL. I brought this up on slack as well:
https://kotlinlang.slack.com/archives/CKWA2MV8U/p1771194798125389

This solution "works" but I'll be honest that it has some aspects I don't love, I'll get into those with comments.

Comment thread src/commonMain/kotlin/api.kt Outdated
fun finalize(): R
}

interface InteroperableTagConsumer<I, out R> : TagConsumer<R> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Realistically this is only available on the JSDOMBuilder (js and wasmjs). I guess it's not a terrible idea to have the interface though.

@yoxjames yoxjames marked this pull request as draft March 23, 2026 01:26
Comment thread src/jsMain/kotlin/dom-js.kt Outdated
private fun HTMLElement.asR(): R = this.asDynamic()

override fun onRenderedContent(content: Node) {
path.last().appendChild(content)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This sort of assumes that all use cases of this would be appending to the DOM. That might be fine but part of me wondered if instead of a method like this, the DOM tag consumers should just expose path.last() somehow letting users modify the current last DOM leaf however they want?

Comment thread src/jsMain/kotlin/dom-js.kt Outdated
@OptIn(ExperimentalContracts::class)
inline fun FlowContent.dom(crossinline block: () -> Node) {
contract { callsInPlace(block, InvocationKind.AT_MOST_ONCE) }
val consumer = when (val topConsumer = consumer) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This I really don't like all that much. We have no way to offer this method since all the DSL methods dangle off HTMLTag which contains a TagConsumer<*>. Unless there wasa whole distinct set of Tags for JS which contained a more specific type of consumer. However that would be a lot more bloat. This method is only available on js and wasmJs which limits the blast radius.


wrapper.append.div {
dom {
document.createElement("svg")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a standin for what a sister library could do.

import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

interface TagConsumer<out R> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am thinking maybe the best way to go here would be to introduce a val out: Any? = null here where this points to path.last() in JSDOMBuilder and the generic appendable in the streaming one (and delegates in the delegating ones). This should give us something to easily hook into for HTMLElement functions using as? / is operators.

I think you could introduce this here with an opt-in annotation, since we can probably come up with something better in a later release when significant breaking changes are on the table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree. I actually was planning to refactor into something like that. It probably makes more sense to keep some of this awkward implementation in consumers for now.

I assume this library will be significantly refactored for context params. Might be better to consider options then.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. I think it's a lot less problematic this way.

Comment thread src/jvmMain/kotlin/dom-jvm.kt Outdated
override fun finalize() = lastLeaved ?: throw IllegalStateException("No tags were emitted")

@ExperimentalKotlinxHtmlApi
override val last: Any? get() = path.lastOrNull()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Noticed that the JVM dom consumer could also use this. I never even knew this existed before. Interesting!

@bjhham bjhham left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yeah, this approach looks good to me 👍

Comment thread src/jsMain/kotlin/dom-js.kt Outdated
override fun finalize(): R = lastLeaved?.asR() ?: throw IllegalStateException("We can't finalize as there was no tags")

@ExperimentalKotlinxHtmlApi
override val last: Any? get() = path.lastOrNull()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We can use HtmlElement return type here and replace references to path.last() to tidy things here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I figured preventing a throw was ideal, though since this is an experimental opt in I am not against it. Realistically I would be hanging this off of a Tag function when I use it which means it shouldn't ever throw. I will follow the standards you deem ideal for this library though! Just wanted to explain my thought process.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It has been updated to use last().

Comment thread src/commonMain/kotlin/api.kt Outdated
Comment thread src/commonMain/kotlin/stream.kt Outdated
@yoxjames yoxjames marked this pull request as ready for review March 28, 2026 17:18

@bjhham bjhham left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lgtm 👍

@bjhham bjhham changed the title First draft of DOM interop Introduce TagConsumer downcast extension point for DOM interop Apr 2, 2026
@bjhham bjhham merged commit 76b16f0 into Kotlin:master Apr 2, 2026
2 checks passed
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.

2 participants