Motivation
A real case from xemantic.com: toHtmlArticle() collects a table of contents while the headings stream and emits an aside with the TOC, while applyPageLayout() wraps the body content in BeerCSS's main.responsive. The desired output is:
<body>
<header>…</header>
<main class="responsive">…content…</main>
<aside>…toc…</aside>
</body>
This is currently not expressible in the transform DSL, because there is no way to emit content that is computed during subtree streaming into the slot between two deferred unmarks (here: between </main> and </body>). All three existing mechanisms miss:
- Direct emission after
children() is deferred to the right slot in pendingOps, but the emitting block runs eagerly, before the subtree streams — a TOC built there is still empty, and +text captures its string at registration time.
afterClose runs at the right time (after the subtree has streamed), but at the wrong place: afterCloseOps run after pendingOps in TransformerImpl's Unmark handling, so its output lands after the matched mark's own deferred close. For match("body") that means the TOC aside is emitted after </body> — invalid HTML that browsers silently reparent.
- Buffering the subtree (the
HeadingNode replay pattern) collects the content fine, but there is no hook to replay it into the deferred output stream.
Proposal
A lazily evaluated counterpart to deferred emission:
/**
* Schedules [block] to run at the matched mark's unmark, once the
* input subtree has streamed. Its emissions are spliced into the
* deferred output at the position of this call — unlike direct
* emission after [children], which defers the events but captures
* their content eagerly, and unlike [afterClose], which always lands
* after the matched mark's own close.
*/
public fun defer(block: suspend MatcherScope.() -> Unit)
Semantics
defer appends a lazy op to the frame's pendingOps at the current position, so ordering relative to other deferred emissions follows source order in the matcher block.
- Called after
children() at the top level of the matcher block, its output lands before the matched mark's deferred close — i.e. as the last child (this subsumes a would-be beforeClose).
- Called after a nested element containing
children(), its output lands between that element's close and the matched mark's close — the </main>…</body> slot.
- The block runs with sink-scope semantics (
children/afterClose/defer inside it are no-ops), same as afterClose today.
Implementation is small: MatcherScope.defer ≈ state.pendingOps += { block(sinkScope) }.
TDD examples
1. Lazily computed content as the last child
@Test
fun `should splice lazily computed content before the deferred close`() = runTest {
// given
val events = semanticEvents {
"body" {
"h2" { +"Alpha" }
"h2" { +"Beta" }
}
}
// when - the toc list is populated while the headings stream; defer
// runs at the body unmark, so it sees the complete list, and its
// output lands before the deferred </body> - as the last child
val transformed = events.transform {
val toc = mutableListOf<String>()
match("body") {
"body" {
children(mode = "content")
defer {
"nav" {
toc.forEach { title ->
"a" { +title }
}
}
}
}
}
match("h2", mode = "content") {
"h2" { children(mode = "heading") }
}
matchText(mode = "heading") {
toc += it
+it
}
}
// then
transformed sameAs semanticEvents {
"body" {
"h2" { +"Alpha" }
"h2" { +"Beta" }
"nav" {
"a" { +"Alpha" }
"a" { +"Beta" }
}
}
}
}
Today the two near-equivalents both fail this test: replacing defer { … } with a direct "nav" { … } emission compiles but produces an empty nav (the block runs before any heading has streamed), and afterClose { "nav" { … } } emits the populated nav after </body>.
2. Computed content between two deferred closes (the main/aside case)
@Test
fun `should emit computed content between two deferred closes`() = runTest {
// given
val events = semanticEvents {
"body" {
"h2" { +"Alpha" }
"p" { +"text" }
}
}
// when - the content is wrapped in main, and the collected toc must
// land outside of it: between </main> and </body>
val transformed = events.transform {
val toc = mutableListOf<String>()
match("body") {
"body" {
"main"("class" to "responsive") {
children(mode = "content")
}
defer {
"aside" {
toc.forEach { title ->
"a" { +title }
}
}
}
}
}
match("h2", mode = "content") {
"h2" { children(mode = "heading") }
}
match("p", mode = "content") {
"p" { children(mode = "content") }
}
matchText(mode = "heading") {
toc += it
+it
}
matchText(mode = "content") { +it }
}
// then
transformed sameAs semanticEvents {
"body" {
"main"("class" to "responsive") {
"h2" { +"Alpha" }
"p" { +"text" }
}
"aside" {
"a" { +"Alpha" }
}
}
}
}
3. Relocating a matched subtree outside its synthetic wrapper
@Test
fun `should relocate a matched subtree outside of its synthetic wrapper`() = runTest {
// given - an aside authored amidst the content
val events = semanticEvents {
"body" {
"p" { +"text" }
"aside" {
"a"("href" to "#alpha") { +"Alpha" }
}
}
}
// when - the content is wrapped in main, while the aside links are
// captured during streaming and replayed outside of the wrapper
val transformed = events.transform {
val links = mutableListOf<Pair<String, String>>()
var currentHref = ""
match("body") {
"body" {
"main" {
children(mode = "content")
}
defer {
"aside" {
links.forEach { (href, title) ->
"a"("href" to href) { +title }
}
}
}
}
}
// capture only - the aside is not re-emitted in place
match("aside", mode = "content") {
children(mode = "aside")
}
match("a", mode = "aside") { mark ->
currentHref = mark["href"] ?: ""
children(mode = "aside-link")
}
matchText(mode = "aside-link") { links += currentHref to it }
match("p", mode = "content") {
"p" { children(mode = "content") }
}
matchText(mode = "content") { +it }
}
// then - the aside ends up as a sibling of main, not inside it
transformed sameAs semanticEvents {
"body" {
"main" {
"p" { +"text" }
}
"aside" {
"a"("href" to "#alpha") { +"Alpha" }
}
}
}
}
Ordering rule (spec, not a test)
Deferred emissions and defer ops share one queue, so given:
match("section") {
"section" { children() }
"static-1" {}
defer { "lazy" {} }
"static-2" {}
}
the output after the section subtree is </section>, <static-1/>, <lazy/>, <static-2/> — source order preserved.
Alternatives considered
beforeClose { } — covers only the last-child case (example 1), not the between-two-closes slot (examples 2 and 3). defer subsumes it positionally.
- Running
afterCloseOps before pendingOps — would silently change the documented afterClose semantics ("right after the matching unmark has been processed") and break existing sibling-emission uses like the hr-after-section test.
Side effect
With defer, toHtmlArticle() in xemantic.com can place the TOC aside genuinely as the last child of body (today's afterClose variant emits it after </body>), and applyPageLayout() can relocate it next to main — making the "1st pass placeholder" comment in ArticleTemplate.kt finally true.
Motivation
A real case from xemantic.com:
toHtmlArticle()collects a table of contents while the headings stream and emits anasidewith the TOC, whileapplyPageLayout()wraps the body content in BeerCSS'smain.responsive. The desired output is:This is currently not expressible in the transform DSL, because there is no way to emit content that is computed during subtree streaming into the slot between two deferred unmarks (here: between
</main>and</body>). All three existing mechanisms miss:children()is deferred to the right slot inpendingOps, but the emitting block runs eagerly, before the subtree streams — a TOC built there is still empty, and+textcaptures its string at registration time.afterCloseruns at the right time (after the subtree has streamed), but at the wrong place:afterCloseOpsrun afterpendingOpsinTransformerImpl'sUnmarkhandling, so its output lands after the matched mark's own deferred close. Formatch("body")that means the TOCasideis emitted after</body>— invalid HTML that browsers silently reparent.HeadingNodereplay pattern) collects the content fine, but there is no hook to replay it into the deferred output stream.Proposal
A lazily evaluated counterpart to deferred emission:
Semantics
deferappends a lazy op to the frame'spendingOpsat the current position, so ordering relative to other deferred emissions follows source order in the matcher block.children()at the top level of the matcher block, its output lands before the matched mark's deferred close — i.e. as the last child (this subsumes a would-bebeforeClose).children(), its output lands between that element's close and the matched mark's close — the</main>…</body>slot.children/afterClose/deferinside it are no-ops), same asafterClosetoday.Implementation is small:
MatcherScope.defer≈state.pendingOps += { block(sinkScope) }.TDD examples
1. Lazily computed content as the last child
Today the two near-equivalents both fail this test: replacing
defer { … }with a direct"nav" { … }emission compiles but produces an emptynav(the block runs before any heading has streamed), andafterClose { "nav" { … } }emits the populatednavafter</body>.2. Computed content between two deferred closes (the
main/asidecase)3. Relocating a matched subtree outside its synthetic wrapper
Ordering rule (spec, not a test)
Deferred emissions and
deferops share one queue, so given:the output after the section subtree is
</section>,<static-1/>,<lazy/>,<static-2/>— source order preserved.Alternatives considered
beforeClose { }— covers only the last-child case (example 1), not the between-two-closes slot (examples 2 and 3).defersubsumes it positionally.afterCloseOpsbeforependingOps— would silently change the documentedafterClosesemantics ("right after the matching unmark has been processed") and break existing sibling-emission uses like thehr-after-sectiontest.Side effect
With
defer,toHtmlArticle()in xemantic.com can place the TOCasidegenuinely as the last child ofbody(today'safterClosevariant emits it after</body>), andapplyPageLayout()can relocate it next tomain— making the "1st pass placeholder" comment inArticleTemplate.ktfinally true.