Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
mikaelstaldal opened this issue Jul 7, 2023 · 11 comments

Comments

@mikaelstaldal
Copy link

mikaelstaldal commented Jul 7, 2023

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
Copy link
Author

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
Copy link
Author

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

@devxzero
Copy link

devxzero commented Sep 2, 2023

"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
Copy link
Author

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

@mikaelstaldal mikaelstaldal changed the title Support generating non-well-formed HTML fragments Support generating HTML fragments with multiple elements (no single containing element) Sep 2, 2023
@mikaelstaldal mikaelstaldal changed the title Support generating HTML fragments with multiple elements (no single containing element) Support generating HTML fragments with multiple elements (no containing element) Sep 2, 2023
@mikaelstaldal
Copy link
Author

But OK, I tried to make it more clear.

@devxzero
Copy link

devxzero commented Sep 2, 2023

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
Copy link

devxzero commented Sep 2, 2023

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
Copy link

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

suniala added a commit to suniala/pro-kotlin-web-apps-from-scratch that referenced this issue Feb 4, 2024
kotlinx.html does not really support html fragments. For example, I
can't respond with "<li>foo</li>". Instead I have to respond with
"<body><ul><li>foo</li></ul></body>" which does not work with htmx
when swapping a li element.

See: Kotlin/kotlinx.html#228
@suniala
Copy link

suniala commented Feb 5, 2024

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
Copy link

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
Copy link

imulab commented Apr 28, 2024

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!"
                }
            }

}

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 a pull request may close this issue.

6 participants