Kotlin / kotlinx.html

Kotlin DSL for HTML
Apache License 2.0
1.62k stars 132 forks source link

Support generating HTML fragments with multiple elements (no containing element) #228

Open mikaelstaldal opened 1 year ago

mikaelstaldal commented 1 year ago

It would be useful to be able to generate HTML fragments with multiple elements without any containing element.

Use case is to generate HTML snippets on server to be requested with AJAX and inserted into the DOM on the client.

This code:

    val models = listOf(IdName("a1", "A1"), IdName("a3", "A3"), IdName("a6", "A6"))
    val html: String = createHTML()
        .select {
            for (model in models) {
                option {
                    value = model.id
                    +model.name
                }
            }
        }

Gives this:

<select>
  <option value="a1">A1</option>
  <option value="a3">A3</option>
  <option value="a6">A6</option>
</select>

But I want this:

<option value="a1">A1</option>
<option value="a3">A3</option>
<option value="a6">A6</option>

(Same issue with <tr> etc.)

mikaelstaldal commented 1 year ago

Possible solution:

@HtmlTagMarker
inline fun <T, C : TagConsumer<T>> C.fragment(crossinline block: TagConsumer<T>.() -> Unit): T {
    try {
        this.block()
    } catch (err: Throwable) {
        this.onTagError(HTMLTag("", this, emptyMap(), null, inlineTag = false, emptyTag = false), err)
    }
    return this.finalize()
}
mikaelstaldal commented 1 year ago

Or no, that does not work properly, it ends up calling TagConsumer.finalize() multiple times. Back to the drawing board.

devxzero commented 1 year ago

"non-well-formed" sounds like the use of elements that are either missing an opening tag or a closing tag, but not both. If you only read the title of this issue, it sounds like you are trying to use a bad (non-well-formed) approach, but the thing you ask for is perfectly fine. "multiple elements with no containing element" is clearer IMO.

I also need a solution for this. Every time you want to reuse code (by using a function call), you are forced to use an unwanted container element, such as a div. But I'm now in the situation where I need code reuse in the HTML head, so I cannot use div anymore....

mikaelstaldal commented 1 year ago

I am using the XML definition of well-formedness: https://www.w3.org/TR/2008/REC-xml-20081126/#sec-well-formed

mikaelstaldal commented 1 year ago

But OK, I tried to make it more clear.

devxzero commented 1 year ago

You could do something like this:

val result: String = buildString {
        appendLine(createHTML().option {})
        appendLine(createHTML().option {})
        appendLine(createHTML().option {})
}
println(result)

But these intermediate strings do limit performance and it's an ugly solution, especially when the number of root level elements grows.

Update: or better:

    val result: String = buildString {
        appendHTML().option {  }
        appendHTML().option {  }
        appendHTML().option {  }
    }
    println(result)

I believe that sooner or later, everybody who uses kotlinx.html on an intermediate level, will sooner or later bump into this problem. The best solution would be if there was an attribute to signal that the root level element should be skipped, like:

    val html: String = createHTML()
        .select {
            skipRoot = true // changed

            for (model in models) {
                option {
                    value = model.id
                    +model.name
                }
            }
        }

or something like this:

    val html: String = createHTML()
        .container { // changed

            for (model in models) {
                option {
                    value = model.id
                    +model.name
                }
            }
        }
devxzero commented 1 year ago

This solutions works, but it requires a pull request.

Change kotlinx.html.Tag.tagName from type String to String?. Then fix all usage of that property, which usually means code like:

        if (tag.tagName == null) {
            return
        }

(Only about 5 files have to be modified.)

Then you can use this:

fun main() {
    val r = createHTML().emptyRoot {
        div {  }
        div {  }
    }
    println(r) // prints: <div></div> <div></div>
}

inline fun <T, C : TagConsumer<T>> C.emptyRoot(classes : String? = null, crossinline block : EMPTY_ROOT.() -> Unit = {}) : T = EMPTY_ROOT(
    attributesMapOf(),
    this
).visitAndFinalize(this, block)

open class EMPTY_ROOT(initialAttributes : Map<String, String>, override val consumer : TagConsumer<*>) : HTMLTag(null, consumer, initialAttributes, null, false, false), HtmlBlockTag
gersomonline commented 11 months ago

Are there any updates? I'd like to see this implemented as well. I'm down to help out if you want.

suniala commented 8 months ago

I bumped into this issue when trying https://htmx.org with a ktor+kotlinx.html backend. Note that the issue does not only concern "multiple elements" as I might want to replace a single <li> in a list. kotlinx.html does not allow me to build a <li> without a containing <ol> or <ul>.

Luckily there is a workaround in https://htmx.org : using a hx-select attribute I can ignore the containing <ol> in the response and only "select" the <li> element. This also works with multiple elements.

rossdanderson commented 7 months ago

It's not perfect but you can copy the li method onto FlowContent, e.g.

inline fun FlowContent.li(classes: String? = null, crossinline block: LI.() -> Unit = {}) =
    LI(attributesMapOf("class", classes), consumer).visit(block)

My solution to the empty root problem:

inline fun partial(crossinline block: FlowContent.() -> Unit) = createHTML {
    object : FlowContent {
        override val attributes = DelegatingMap(emptyMap(), this) { this@createHTML }
        override val attributesEntries: Collection<Map.Entry<String, String>>
            get() = this.attributes.immutableEntries
        override val consumer: TagConsumer<*>
            get() = this@createHTML
        override val emptyTag: Boolean
            get() = false
        override val inlineTag: Boolean
            get() = false
        override val namespace: String?
            get() = null
        override val tagName: String
            get() = ""
    }.block()
}
imulab commented 5 months ago

Came across this issue while researching. Posting my solution here if anyone needs it:

import kotlinx.html.*

class FRAGMENT(override val consumer: TagConsumer<*>) : HTMLTag(
    tagName = "fragment",
    consumer = consumer,
    initialAttributes = emptyMap(),
    emptyTag = false,
    inlineTag = false,
    namespace = null,
), FlowContent

@HtmlTagMarker
inline fun <T, C : TagConsumer<T>> C.fragment(
    crossinline block: FRAGMENT.() -> Unit = {}
): T = FragmentAwareTagConsumer(this).let { FRAGMENT(it).visitAndFinalize(it, block) }

class FragmentAwareTagConsumer<T>(private val delegate: TagConsumer<T>) : TagConsumer<T> by delegate {
    override fun onTagStart(tag: Tag) {
        if (tag !is FRAGMENT) delegate.onTagStart(tag)
    }

    override fun onTagEnd(tag: Tag) {
        if (tag !is FRAGMENT) delegate.onTagEnd(tag)
    }
}

Usage:

buildString {
            appendHTML().fragment {
                div {
                    +"Hello, world from div!"
                }
            }
            appendHTML().fragment {
                p {
                    +"Hello, world from paragraph!"
                }
            }

}
integer222 commented 3 months ago

Столкнулся с такой же проблемой. Моё решение:

import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.response.*
import kotlinx.html.*
import kotlinx.html.consumers.filter
import kotlinx.html.stream.appendHTML

suspend fun <TTemplate : Template<FlowContent>> ApplicationCall.respondFragmentTemplate(
    template: TTemplate,
    status: HttpStatusCode = HttpStatusCode.OK,
    body: TTemplate.() -> Unit
) {
    template.body()
    respondFragment(status) { with(template) { apply() } }
}

suspend fun ApplicationCall.respondFragment(status: HttpStatusCode = HttpStatusCode.OK, block: FlowContent.() -> Unit) {
    val text = buildString {
        appendHTML().filter { if (it is FRAGMENT) SKIP else PASS }.fragment(block = block)
    }
    respond(TextContent(text, ContentType.Text.Html.withCharset(Charsets.UTF_8), status))
}

@Suppress("unused")
private class FRAGMENT(override val consumer: TagConsumer<*>) :
    HTMLTag("fragment", consumer, emptyMap(), null, false, false), HtmlBlockTag {

}

@HtmlTagMarker
private inline fun <T, C : TagConsumer<T>> C.fragment(crossinline block: FRAGMENT.() -> Unit = {}): T =
    FRAGMENT(this).visitAndFinalize(this, block)

Использование:

        get("/fragment_template") {
            call.respondFragmentTemplate(
                TestTemplate()
            ) {}
        }

        get("/fragment") {
            call.respondFragment { 
                div { 
                    +"test"
                }
            }
        }

class TestTemplate : Template<FlowContent> {
    override fun FlowContent.apply() {
        div {
            +"testTemplate"
        }
    }
}