Skip to content

MatcherScope.defer {} — lazily evaluated emission spliced into the deferred output stream #72

Description

@morisil

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:

  1. 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.
  2. 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.
  3. 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.deferstate.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions