Skip to content

Commit a50db73

Browse files
authored
formalize use of HTMX templates (#82)
1 parent 08285e5 commit a50db73

File tree

9 files changed

+163
-43
lines changed

9 files changed

+163
-43
lines changed

docs/htmx.md

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ class TestKotlinApplication : SpringFunkApplication {
4747
}
4848
```
4949

50+
### Templates
51+
Templates can be declared which define how a given binding response should be rendered to htmx.
52+
53+
```kotlin
54+
val myTemplate = htmxTemplate<ResponseDataClass> {
55+
div {
56+
span {
57+
+it.name
58+
}
59+
}
60+
}
61+
```
62+
5063
### Initial Load
5164

5265
You must declare the "landing page" for each page, which is the initial content to show on load. The `initialLoad` function lets you do this, which exposes the HTML DSL to define that view.
@@ -88,17 +101,45 @@ Each interaction which binds to a backend route will need a route declared to ha
88101

89102
### HTMX routes
90103

104+
Routes are server-side handers for handling HTMX interactions. They consist of 4 main components:
105+
106+
1) HttpVerb: the HTTP verb/method to listen for (eg, GET, POST, etc).
107+
2) route: the url path
108+
3) handler: a function reference to call. This method must take a single parameter. The incoming request from HTMX will be deserialized into this parameter, then passed to the function.
109+
4) renderer: a template for rendering the result of the handler call in HTMX. renderers can be templates, or defined inline.
110+
111+
#### Example route (inline)
112+
```kotlin
113+
route(HttpVerb.POST, helloWorldUrl, ExampleService::sayHello) {
114+
div {
115+
span {
116+
+it.message
117+
}
118+
}
119+
}
120+
```
121+
122+
#### Example route (template)
123+
```kotlin
124+
val myTemplate = htmxTemplate<ResponseDataClass> {
125+
div {
126+
span {
127+
+it.message
128+
}
129+
}
130+
}
131+
route(HttpVerb.POST, helloWorldUrl, ExampleService::sayHello, myTemplate)
132+
```
133+
91134
#### GET
92135
GET requests may have a parameter or no parameter.
93136

94137
##### GET requests with no parameter
95138
```kotlin
96139
page("/index") {
97140
get("things", TestController::getAll) {
98-
{
99-
span {
100-
+it.id.toString()
101-
}
141+
span {
142+
+it.id.toString()
102143
}
103144
}
104145
}
@@ -108,10 +149,8 @@ page("/index") {
108149
```kotlin
109150
page("/index") {
110151
route(HttpVerb.GET, "thing/1", TestController::get) {
111-
{
112-
span {
113-
+it.id.toString()
114-
}
152+
span {
153+
+it.id.toString()
115154
}
116155
}
117156
}

spring-funk-htmx/src/main/kotlin/com/github/wakingrufus/funk/htmx/HtmxPage.kt

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.github.wakingrufus.funk.core.SpringDslMarker
44
import com.github.wakingrufus.funk.htmx.route.HxRoute
55
import com.github.wakingrufus.funk.htmx.route.noParam
66
import com.github.wakingrufus.funk.htmx.route.withParam
7+
import com.github.wakingrufus.funk.htmx.template.HtmxTemplate
78
import kotlinx.html.BODY
89
import kotlinx.html.TagConsumer
910
import kotlinx.html.body
@@ -16,7 +17,6 @@ import org.springframework.beans.factory.BeanFactory
1617
import org.springframework.http.MediaType
1718
import org.springframework.web.servlet.function.RouterFunctionDsl
1819
import org.springframework.web.servlet.function.ServerResponse
19-
import org.w3c.dom.Document
2020

2121
class HtmxPage(val path: String) {
2222
var initialLoad: BODY.() -> Unit = {}
@@ -42,24 +42,44 @@ class HtmxPage(val path: String) {
4242
inline fun <reified CONTROLLER : Any, RESP : Any> get(
4343
path: String,
4444
noinline binding: CONTROLLER.() -> RESP,
45-
noinline renderer: (RESP) -> TagConsumer<Document>.() -> Document
45+
renderer: HtmxTemplate<RESP>
4646
): HxRoute {
47-
return noParam(RouterFunctionDsl::GET,
47+
return noParam(
48+
RouterFunctionDsl::GET,
4849
path = path,
4950
controllerClass = CONTROLLER::class.java,
5051
binding = binding,
5152
renderer = renderer
52-
)
53+
)
5354
.also { addRoute(it) }
5455
}
5556

57+
@SpringDslMarker
58+
inline fun <reified CONTROLLER : Any, RESP : Any> get(
59+
path: String,
60+
noinline binding: CONTROLLER.() -> RESP,
61+
crossinline renderer: TagConsumer<*>.(RESP) -> Unit
62+
): HxRoute {
63+
return get(path, binding) { appendable, input -> renderer.invoke(appendable, input) }
64+
}
65+
5666
@SpringDslMarker
5767
inline fun <reified CONTROLLER : Any, reified REQ : Record, RESP : Any> route(
5868
verb: HttpVerb,
5969
path: String,
6070
noinline binding: CONTROLLER.(REQ) -> RESP,
61-
noinline renderer: (RESP) -> TagConsumer<Document>.() -> Document
62-
): HxRoute{
71+
crossinline renderer: TagConsumer<*>.(RESP) -> Unit
72+
): HxRoute {
73+
return route(verb, path, binding) { appendable, input -> renderer.invoke(appendable, input) }
74+
}
75+
76+
@SpringDslMarker
77+
inline fun <reified CONTROLLER : Any, reified REQ : Record, RESP : Any> route(
78+
verb: HttpVerb,
79+
path: String,
80+
noinline binding: CONTROLLER.(REQ) -> RESP,
81+
renderer: HtmxTemplate<RESP>
82+
): HxRoute {
6383
return withParam(
6484
routerFunction = when (verb) {
6585
HttpVerb.GET -> RouterFunctionDsl::GET

spring-funk-htmx/src/main/kotlin/com/github/wakingrufus/funk/htmx/route/HxRoute.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package com.github.wakingrufus.funk.htmx.route
22

3-
import kotlinx.html.TagConsumer
3+
import com.github.wakingrufus.funk.htmx.template.HtmxTemplate
44
import org.springframework.beans.factory.BeanFactory
55
import org.springframework.web.servlet.function.RouterFunctionDsl
66
import org.springframework.web.servlet.function.ServerRequest
77
import org.springframework.web.servlet.function.ServerResponse
8-
import org.w3c.dom.Document
98

109
interface HxRoute {
1110
fun registerRoutes(beanFactory: BeanFactory, dsl: RouterFunctionDsl)
@@ -17,7 +16,7 @@ fun <CONTROLLER : Any, REQ : Record, RESP : Any> withParam(
1716
requestClass: Class<REQ>,
1817
controllerClass: Class<CONTROLLER>,
1918
binding: CONTROLLER.(REQ) -> RESP,
20-
renderer: (RESP) -> TagConsumer<Document>.() -> Document
19+
renderer: HtmxTemplate<RESP>
2120
): HxRoute {
2221
return ParamRoute(routerFunction, path, requestClass, controllerClass, binding, renderer)
2322
}
@@ -27,7 +26,7 @@ fun <CONTROLLER : Any, RESP : Any> noParam(
2726
path: String,
2827
controllerClass: Class<CONTROLLER>,
2928
binding: CONTROLLER.() -> RESP,
30-
renderer: (RESP) -> TagConsumer<Document>.() -> Document
29+
renderer: HtmxTemplate<RESP>
3130
): HxRoute {
3231
return NoParamRoute(routerFunction, path, controllerClass, binding, renderer)
3332
}

spring-funk-htmx/src/main/kotlin/com/github/wakingrufus/funk/htmx/route/NoParamRoute.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
package com.github.wakingrufus.funk.htmx.route
22

3-
import kotlinx.html.TagConsumer
4-
import kotlinx.html.dom.createHTMLDocument
5-
import kotlinx.html.dom.serialize
3+
import com.github.wakingrufus.funk.htmx.template.HtmxTemplate
4+
import kotlinx.html.stream.appendHTML
65
import org.springframework.beans.factory.BeanFactory
76
import org.springframework.http.MediaType
87
import org.springframework.web.servlet.function.RouterFunctionDsl
98
import org.springframework.web.servlet.function.ServerRequest
109
import org.springframework.web.servlet.function.ServerResponse
1110
import org.springframework.web.servlet.function.contentTypeOrNull
12-
import org.w3c.dom.Document
1311

1412
class NoParamRoute<CONTROLLER : Any, RESP : Any>(
1513
val routerFunction: RouterFunctionDsl.(String, (ServerRequest) -> ServerResponse) -> Unit,
1614
val path: String,
1715
private val controllerClass: Class<CONTROLLER>,
1816
val binding: CONTROLLER.() -> RESP,
19-
val renderer: (RESP) -> TagConsumer<Document>.() -> Document
17+
val renderer: HtmxTemplate<RESP>
2018
) : HxRoute {
2119
override fun registerRoutes(beanFactory: BeanFactory, dsl: RouterFunctionDsl) {
2220
dsl.apply {
@@ -31,7 +29,10 @@ class NoParamRoute<CONTROLLER : Any, RESP : Any>(
3129
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(resp)
3230
} else {
3331
ServerResponse.ok().contentType(MediaType.TEXT_HTML)
34-
.body(createHTMLDocument().run { renderer.invoke(resp).invoke(this) }.serialize(false))
32+
.body(buildString {
33+
appendHTML(false)
34+
.apply { renderer.render(this, resp) }
35+
})
3536
}
3637
}
3738
}

spring-funk-htmx/src/main/kotlin/com/github/wakingrufus/funk/htmx/route/ParamRoute.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
package com.github.wakingrufus.funk.htmx.route
22

33
import com.fasterxml.jackson.databind.ObjectMapper
4+
import com.github.wakingrufus.funk.htmx.template.HtmxTemplate
45
import io.github.oshai.kotlinlogging.KotlinLogging
5-
import kotlinx.html.TagConsumer
6-
import kotlinx.html.dom.createHTMLDocument
7-
import kotlinx.html.dom.serialize
6+
import kotlinx.html.stream.appendHTML
87
import org.springframework.beans.factory.BeanFactory
98
import org.springframework.beans.factory.getBean
109
import org.springframework.http.MediaType
1110
import org.springframework.web.servlet.function.RouterFunctionDsl
1211
import org.springframework.web.servlet.function.ServerRequest
1312
import org.springframework.web.servlet.function.ServerResponse
1413
import org.springframework.web.servlet.function.contentTypeOrNull
15-
import org.w3c.dom.Document
1614

1715
class ParamRoute<CONTROLLER : Any, REQ : Record, RESP : Any>(
1816
val routerFunction: RouterFunctionDsl.(String, (ServerRequest) -> ServerResponse) -> Unit,
1917
val path: String,
2018
private val requestClass: Class<REQ>,
2119
private val controllerClass: Class<CONTROLLER>,
2220
val binding: CONTROLLER.(REQ) -> RESP,
23-
val renderer: (RESP) -> TagConsumer<Document>.() -> Document
21+
val renderer: HtmxTemplate<RESP>
2422
) : HxRoute {
2523
private val log = KotlinLogging.logger {}
2624
override fun registerRoutes(beanFactory: BeanFactory, dsl: RouterFunctionDsl) {
@@ -45,7 +43,10 @@ class ParamRoute<CONTROLLER : Any, REQ : Record, RESP : Any>(
4543
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(resp)
4644
} else {
4745
ServerResponse.ok().contentType(MediaType.TEXT_HTML)
48-
.body(createHTMLDocument().run { renderer.invoke(resp).invoke(this) }.serialize(false))
46+
.body(buildString {
47+
appendHTML(false)
48+
.apply { renderer.render(this, resp) }
49+
})
4950
}
5051
}
5152
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.github.wakingrufus.funk.htmx.template
2+
3+
import kotlinx.html.TagConsumer
4+
5+
fun interface HtmxTemplate<I> {
6+
fun render(appendable: TagConsumer<Any?>, input: I)
7+
}
8+
9+
fun <I> htmxTemplate(template: TagConsumer<*>.(I) -> Unit): HtmxTemplate<I> {
10+
return HtmxTemplate { appendable, data -> template.invoke(appendable, data) }
11+
}

spring-funk-htmx/src/test/kotlin/com/github/wakingrufus/funk/htmx/HtmxPageTest.kt

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.github.wakingrufus.funk.htmx
22

3+
import com.github.wakingrufus.funk.htmx.template.htmxTemplate
34
import kotlinx.html.span
45
import org.assertj.core.api.Assertions.assertThat
56
import org.junit.jupiter.api.Test
@@ -25,10 +26,8 @@ class HtmxPageTest {
2526
fun `test no param get`() {
2627
val htmxPage = HtmxPage("").apply {
2728
get("", TestController::noParamGet) {
28-
{
29-
span {
30-
+it.id.toString()
31-
}
29+
span {
30+
+it.id.toString()
3231
}
3332
}
3433
}
@@ -39,10 +38,8 @@ class HtmxPageTest {
3938
fun `test get with params`() {
4039
val htmxPage = HtmxPage("").apply {
4140
route(HttpVerb.GET, "", TestController::get) {
42-
{
43-
span {
44-
+it.id.toString()
45-
}
41+
span {
42+
+it.id.toString()
4643
}
4744
}
4845
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.github.wakingrufus.funk.htmx.template
2+
3+
import kotlinx.html.body
4+
import kotlinx.html.dom.createHTMLDocument
5+
import kotlinx.html.dom.serialize
6+
import kotlinx.html.head
7+
import kotlinx.html.html
8+
import kotlinx.html.span
9+
import kotlinx.html.stream.appendHTML
10+
import org.assertj.core.api.Assertions.assertThat
11+
import org.junit.jupiter.api.Test
12+
13+
class HtmxTemplateTest {
14+
@Test
15+
fun `test fragment`() {
16+
val template = htmxTemplate<String> {
17+
span {
18+
+it
19+
}
20+
}
21+
val output = buildString {
22+
appendHTML(false).apply {
23+
template.render(this, "Hello")
24+
}
25+
}
26+
assertThat(output).isEqualTo("<span>Hello</span>")
27+
}
28+
29+
@Test
30+
fun `test document`() {
31+
val template = htmxTemplate<String> {
32+
span {
33+
+it
34+
}
35+
}
36+
37+
val output = createHTMLDocument().html {
38+
head {
39+
}
40+
body {
41+
template.render(consumer, "Hello")
42+
}
43+
}.serialize(false)
44+
assertThat(output)
45+
.isEqualTo(
46+
"<!DOCTYPE html>\n" +
47+
"<html>" +
48+
"<head><META http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head>" +
49+
"<body><span>Hello</span></body>" +
50+
"</html>"
51+
)
52+
}
53+
}

test-application/src/main/kotlin/com/github/wakingrufus/funk/example/ExampleApplication.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.springframework.boot.logging.LogLevel
2020
import org.springframework.web.servlet.function.ServerResponse
2121

2222
const val helloWorldUrl = "/load"
23+
2324
class ExampleApplication : SpringFunkApplication {
2425
private val log = KotlinLogging.logger {}
2526
override fun dsl(): SpringDslContainer.() -> Unit = {
@@ -34,11 +35,9 @@ class ExampleApplication : SpringFunkApplication {
3435
page("/index") {
3536

3637
route(HttpVerb.POST, helloWorldUrl, ExampleService::sayHello) {
37-
{
38-
div {
39-
span {
40-
+it.message
41-
}
38+
div {
39+
span {
40+
+it.message
4241
}
4342
}
4443
}

0 commit comments

Comments
 (0)