Repository: tipsy/javalin
Pull request #1526 (2022-04-23 20:44:27)

Added a solution to #1457 with pre and post render hooks.


Patch:
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt
index 1610ce00..ce49482b 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt
@@ -1,87 +1,21 @@
 package io.javalin.plugin.rendering.vue
 
-import io.javalin.core.util.Header
 import io.javalin.http.Context
-import io.javalin.http.Handler
-import io.javalin.http.InternalServerErrorResponse
-import io.javalin.plugin.json.jsonMapper
-import io.javalin.plugin.rendering.vue.FileInliner.inlineFiles
-import io.javalin.plugin.rendering.vue.JavalinVue.cacheControl
-import io.javalin.plugin.rendering.vue.JavalinVue.cachedDependencyResolver
-import io.javalin.plugin.rendering.vue.JavalinVue.cachedPaths
-import io.javalin.plugin.rendering.vue.JavalinVue.isDev
-import io.javalin.plugin.rendering.vue.JavalinVue.isDevFunction
-import io.javalin.plugin.rendering.vue.JavalinVue.optimizeDependencies
-import io.javalin.plugin.rendering.vue.JavalinVue.rootDirectory
-import io.javalin.plugin.rendering.vue.JavalinVue.stateFunction
-import io.javalin.plugin.rendering.vue.JavalinVue.walkPaths
-import java.net.URLEncoder
-import java.nio.file.Files
-import java.nio.file.Path
-import java.util.regex.Matcher
 
-class VueComponent @JvmOverloads constructor(val component: String, val state: Any? = null) : Handler {
-    override fun handle(ctx: Context) {
-        isDev = isDev ?: isDevFunction(ctx)
-        rootDirectory = rootDirectory ?: PathMaster.defaultLocation(isDev)
-        val routeComponent = if (component.startsWith("<")) component else "<$component></$component>"
-        val allFiles = if (isDev == true) walkPaths() else cachedPaths
-        val resolver by lazy { if (isDev == true) VueDependencyResolver(allFiles, JavalinVue.vueAppName) else cachedDependencyResolver }
-        val componentId = routeComponent.removePrefix("<").takeWhile { it !in setOf('>', ' ') }
-        val dependencies = if (optimizeDependencies) resolver.resolve(componentId) else allFiles.joinVueFiles()
-        if (componentId !in dependencies) throw InternalServerErrorResponse("Route component not found: $routeComponent")
-        ctx.html(
-            allFiles.find { it.endsWith("vue/layout.html") }!!.readText() // we start with the layout file
-                .inlineFiles(allFiles.filterNot { it.isVueFile() }) // we then inline css/js files
-                .replace("@componentRegistration", "@loadableData@componentRegistration@serverState") // add anchors for later
-                .replace("@loadableData", loadableDataScript) // add loadable data class
-                .replace("@componentRegistration", dependencies) // add all dependencies
-                .replace("@serverState", getState(ctx, state)) // add escaped params and state
-                .replace("@routeComponent", routeComponent) // finally, add the route component itself
-                .replace("@cdnWebjar/", if (isDev == true) "/webjars/" else "https://cdn.jsdelivr.net/webjars/org.webjars.npm/")
-        ).header(Header.CACHE_CONTROL, cacheControl)
+class VueComponent  @JvmOverloads constructor(val component: String, val state: Any? = null, val renderer: VueRenderer? = null) : VueHandler() {
+    override fun component(ctx: Context): String {
+       return this.component;
     }
-}
 
-private fun Set<Path>.joinVueFiles() = this.filter { it.isVueFile() }.joinToString("") { "\n<!-- ${it.fileName} -->\n" + it.readText() }
+    override fun state(ctx: Context): Any? {
+        return this.state;
+    }
 
-object FileInliner {
-    private val newlineRegex = Regex("\\r?\\n")
-    private val unconditionalRegex = Regex("""@inlineFile\(".*"\)""")
-    private val devRegex = Regex("""@inlineFileDev\(".*"\)""")
-    private val notDevRegex = Regex("""@inlineFileNotDev\(".*"\)""")
+    override fun preRender(template: String, ctx: Context): String {
+        return renderer?.preRender(template, ctx) ?: template;
+    }
 
-    fun String.inlineFiles(nonVueFiles: List<Path>): String {
-        val pathMap = nonVueFiles.associateBy { """"/vue/${it.toString().replace("\\", "/").substringAfter("/vue/")}"""" } // normalize keys
-        return this.split(newlineRegex).joinToString("\n") { line ->
-            if (!line.contains("@inlineFile")) return@joinToString line // nothing to inline
-            val matchingKey = pathMap.keys.find { line.contains(it) } ?: throw IllegalStateException("Invalid path found: $line")
-            val matchingFileContent by lazy { Matcher.quoteReplacement(pathMap[matchingKey]!!.readText()) }
-            when {
-                devRegex.containsMatchIn(line) -> if (isDev == true) line.replace(devRegex, matchingFileContent) else ""
-                notDevRegex.containsMatchIn(line) -> if (isDev == false) line.replace(notDevRegex, matchingFileContent) else ""
-                else -> line.replace(unconditionalRegex, matchingFileContent)
-            }
-        }
+    override fun postRender(template: String, ctx:Context):String {
+        return renderer?.postRender(template, ctx) ?: template;
     }
 }
-
-internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
-        "${prototypeOrGlobalConfig()}.\$javalin = JSON.parse(decodeURIComponent(\"${
-            urlEncodeForJavascript(
-                ctx.jsonMapper().toJsonString(
-                    mapOf(
-                        "pathParams" to ctx.pathParamMap(),
-                        "state" to (state ?: stateFunction(ctx))
-                    )
-                )
-            )
-        }\"))\n</script>\n"
-
-// Unfortunately, Java's URLEncoder does not encode the space character in the same way as Javascript.
-// Javascript expects a space character to be encoded as "%20", whereas Java encodes it as "+".
-// All other encodings are implemented correctly, therefore we can simply replace the character in the encoded String.
-private fun urlEncodeForJavascript(string: String) = URLEncoder.encode(string, Charsets.UTF_8.name()).replace("+", "%20")
-private fun prototypeOrGlobalConfig() = if (JavalinVue.vueVersion == VueVersion.VUE_3) "${JavalinVue.vueAppName}.config.globalProperties" else "${JavalinVue.vueAppName}.prototype"
-internal fun Path.readText() = String(Files.readAllBytes(this))
-internal fun Path.isVueFile() = this.toString().endsWith(".vue")
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
index a88c2e28..866bfacb 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
@@ -39,8 +39,8 @@ public class VueDependencyResolver {
         componentIdToOwnContent = new HashMap<>();
         componentIdToDependencyContent = new HashMap<>();
         componentRegex = Pattern.compile(appVarName + ".component\\s*\\(\\s*[\"|'](.*)[\"|']\\s*,.*");
-        paths.stream().filter(VueComponentKt::isVueFile).forEach(path -> {
-            String fileContent = VueComponentKt.readText(path);
+        paths.stream().filter(VueHandlerKt::isVueFile).forEach(path -> {
+            String fileContent = VueHandlerKt.readText(path);
             Matcher matcher = componentRegex.matcher(fileContent); // check for a vue component
             while (matcher.find()) {
                 componentIdToOwnContent.put(matcher.group(1), fileContent); // cache the file content, bound to the component name
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueHandler.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueHandler.kt
new file mode 100644
index 00000000..0115d37e
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueHandler.kt
@@ -0,0 +1,109 @@
+package io.javalin.plugin.rendering.vue
+
+import io.javalin.core.util.Header
+import io.javalin.http.Context
+import io.javalin.http.Handler
+import io.javalin.http.InternalServerErrorResponse
+import io.javalin.plugin.json.jsonMapper
+import io.javalin.plugin.rendering.vue.FileInliner.inlineFiles
+import io.javalin.plugin.rendering.vue.JavalinVue.cacheControl
+import io.javalin.plugin.rendering.vue.JavalinVue.cachedDependencyResolver
+import io.javalin.plugin.rendering.vue.JavalinVue.cachedPaths
+import io.javalin.plugin.rendering.vue.JavalinVue.isDev
+import io.javalin.plugin.rendering.vue.JavalinVue.isDevFunction
+import io.javalin.plugin.rendering.vue.JavalinVue.optimizeDependencies
+import io.javalin.plugin.rendering.vue.JavalinVue.rootDirectory
+import io.javalin.plugin.rendering.vue.JavalinVue.stateFunction
+import io.javalin.plugin.rendering.vue.JavalinVue.walkPaths
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.regex.Matcher
+
+abstract class VueHandler : Handler {
+
+    abstract fun component(ctx: Context): String;
+
+    open fun state(ctx: Context):Any?{
+        return null;
+    }
+    open fun preRender(template: String, ctx:Context): String{
+        return template;
+    }
+    open fun postRender(template: String, ctx:Context): String{
+        return template;
+    }
+    private fun String.preRenderHook(ctx:Context): String{
+        return preRender(this,ctx);
+    }
+
+    private fun String.postRenderHook(ctx: Context): String{
+        return postRender(this,ctx);
+    }
+    override fun handle(ctx: Context) {
+        isDev = isDev ?: isDevFunction(ctx)
+        rootDirectory = rootDirectory ?: PathMaster.defaultLocation(isDev)
+        val component = component(ctx);
+        val routeComponent = if (component.startsWith("<")) component else "<$component></$component>"
+        val allFiles = if (isDev == true) walkPaths() else cachedPaths
+        val resolver by lazy { if (isDev == true) VueDependencyResolver(allFiles, JavalinVue.vueAppName) else cachedDependencyResolver }
+        val componentId = routeComponent.removePrefix("<").takeWhile { it !in setOf('>', ' ') }
+        val dependencies = if (optimizeDependencies) resolver.resolve(componentId) else allFiles.joinVueFiles()
+        if (componentId !in dependencies) throw InternalServerErrorResponse("Route component not found: $routeComponent")
+        ctx.html(
+            allFiles.find { it.endsWith("vue/layout.html") }!!.readText() // we start with the layout file
+                .preRenderHook(ctx)
+                .inlineFiles(allFiles.filterNot { it.isVueFile() }) // we then inline css/js files
+                .replace("@componentRegistration", "@loadableData@componentRegistration@serverState") // add anchors for later
+                .replace("@loadableData", loadableDataScript) // add loadable data class
+                .replace("@componentRegistration", dependencies) // add all dependencies
+                .replace("@serverState", getState(ctx, state(ctx))) // add escaped params and state
+                .replace("@routeComponent", routeComponent) // finally, add the route component itself
+                .replace("@cdnWebjar/", if (isDev == true) "/webjars/" else "https://cdn.jsdelivr.net/webjars/org.webjars.npm/")
+                .postRenderHook(ctx)
+        ).header(Header.CACHE_CONTROL, cacheControl)
+    }
+}
+
+private fun Set<Path>.joinVueFiles() = this.filter { it.isVueFile() }.joinToString("") { "\n<!-- ${it.fileName} -->\n" + it.readText() }
+
+object FileInliner {
+    private val newlineRegex = Regex("\\r?\\n")
+    private val unconditionalRegex = Regex("""@inlineFile\(".*"\)""")
+    private val devRegex = Regex("""@inlineFileDev\(".*"\)""")
+    private val notDevRegex = Regex("""@inlineFileNotDev\(".*"\)""")
+
+    fun String.inlineFiles(nonVueFiles: List<Path>): String {
+        val pathMap = nonVueFiles.associateBy { """"/vue/${it.toString().replace("\\", "/").substringAfter("/vue/")}"""" } // normalize keys
+        return this.split(newlineRegex).joinToString("\n") { line ->
+            if (!line.contains("@inlineFile")) return@joinToString line // nothing to inline
+            val matchingKey = pathMap.keys.find { line.contains(it) } ?: throw IllegalStateException("Invalid path found: $line")
+            val matchingFileContent by lazy { Matcher.quoteReplacement(pathMap[matchingKey]!!.readText()) }
+            when {
+                devRegex.containsMatchIn(line) -> if (isDev == true) line.replace(devRegex, matchingFileContent) else ""
+                notDevRegex.containsMatchIn(line) -> if (isDev == false) line.replace(notDevRegex, matchingFileContent) else ""
+                else -> line.replace(unconditionalRegex, matchingFileContent)
+            }
+        }
+    }
+}
+
+internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
+        "${prototypeOrGlobalConfig()}.\$javalin = JSON.parse(decodeURIComponent(\"${
+            urlEncodeForJavascript(
+                ctx.jsonMapper().toJsonString(
+                    mapOf(
+                        "pathParams" to ctx.pathParamMap(),
+                        "state" to (state ?: stateFunction(ctx))
+                    )
+                )
+            )
+        }\"))\n</script>\n"
+
+// Unfortunately, Java's URLEncoder does not encode the space character in the same way as Javascript.
+// Javascript expects a space character to be encoded as "%20", whereas Java encodes it as "+".
+// All other encodings are implemented correctly, therefore we can simply replace the character in the encoded String.
+private fun urlEncodeForJavascript(string: String) = URLEncoder.encode(string, Charsets.UTF_8.name()).replace("+", "%20")
+private fun prototypeOrGlobalConfig() = if (JavalinVue.vueVersion == VueVersion.VUE_3) "${JavalinVue.vueAppName}.config.globalProperties" else "${JavalinVue.vueAppName}.prototype"
+internal fun Path.readText() = String(Files.readAllBytes(this))
+internal fun Path.isVueFile() = this.toString().endsWith(".vue")
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueRenderer.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueRenderer.kt
new file mode 100644
index 00000000..26dc6292
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueRenderer.kt
@@ -0,0 +1,15 @@
+package io.javalin.plugin.rendering.vue
+
+import io.javalin.http.Context
+
+open class VueRenderer {
+
+    open fun preRender(template: String, ctx: Context): String{
+        return template;
+    }
+
+    open fun postRender(template: String, ctx:Context) : String{
+        return template;
+    }
+
+}


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVue.kt b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
index db470083..47a5bc7f 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVue.kt
+++ b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
@@ -6,8 +6,10 @@
 
 package io.javalin
 
+import io.javalin.http.Context
 import io.javalin.plugin.rendering.vue.JavalinVue
 import io.javalin.plugin.rendering.vue.VueComponent
+import io.javalin.plugin.rendering.vue.VueRenderer
 import io.javalin.testing.TestUtil
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.jupiter.api.BeforeEach
@@ -66,6 +68,70 @@ class TestJavalinVue {
         assertThat(res).contains("Vue.prototype.\$javalin")
     }
 
+    @Test
+    fun `vue component without state with pre renderer`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"state":{}}""".uriEncodeForJavascript()
+        app.get("/no-state", VueComponent("test-component", null, object:  VueRenderer(){
+            override fun preRender(template: String, ctx: Context): String{
+                return template.plus("PRE_RENDER");
+            }
+        }))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
+        assertThat(res).contains("PRE_RENDER")
+        assertThat(res).doesNotContain("POST_RENDER")
+    }
+
+    @Test
+    fun `vue component without state with post renderer`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"state":{}}""".uriEncodeForJavascript()
+        app.get("/no-state", VueComponent("test-component", null, object:  VueRenderer(){
+            override fun postRender(template: String, ctx: Context): String{
+                return template + "POST_RENDER";
+            }
+        }))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
+        assertThat(res).doesNotContain("PRE_RENDER")
+        assertThat(res).contains("POST_RENDER")
+    }
+
+    @Test
+    fun `vue component without state with default renderer`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"state":{}}""".uriEncodeForJavascript()
+        app.get("/no-state", VueComponent("test-component", VueRenderer()))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
+        assertThat(res).doesNotContain("PRE_RENDER")
+        assertThat(res).doesNotContain("POST_RENDER")
+    }
+
+    @Test
+    fun `vue component without state with pre and post renderer`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"state":{}}""".uriEncodeForJavascript()
+        app.get("/no-state", VueComponent("test-component",null, object:  VueRenderer(){
+            override fun postRender(template: String, ctx: Context): String{
+                return template + "POST_RENDER";
+            }
+
+            override fun preRender(template: String, ctx: Context): String{
+                return template + "PRE_RENDER";
+            }
+        }))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
+        assertThat(res).contains("POST_RENDER")
+        assertThat(res).contains("PRE_RENDER")
+    }
+
     @Test
     fun `vue3 component without state`() = TestUtil.test { app, http ->
         JavalinVue.vueVersion { it.vue3("app") }
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVueHandler.java b/javalin/src/test/java/io/javalin/TestJavalinVueHandler.java
new file mode 100644
index 00000000..f135a723
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestJavalinVueHandler.java
@@ -0,0 +1,109 @@
+package io.javalin;
+
+import io.javalin.http.Context;
+import io.javalin.plugin.rendering.vue.JavalinVue;
+import io.javalin.plugin.rendering.vue.VueComponent;
+import io.javalin.plugin.rendering.vue.VueHandler;
+import io.javalin.plugin.rendering.vue.VueRenderer;
+import io.javalin.testing.TestUtil;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TestJavalinVueHandler {
+
+    @BeforeEach
+    public void resetJavalinVue() {
+        TestJavalinVue.Companion.before();
+        JavalinVue.optimizeDependencies = true;
+    }
+    @Test
+    public void testDefaultPreAndPostRenderer() {
+        TestUtil.test((server, httpUtil) -> {
+            server.get("/no-state", new VueHandler(){
+
+                @NotNull
+                @Override
+                public String component(@NotNull Context ctx) {
+                    return "test-component";
+                }
+            });
+            String body = httpUtil.getBody("/no-state");
+            assertThat(body).contains("<body><test-component></test-component></body>");
+            assertThat(body).doesNotContain("PRE_RENDER");
+            assertThat(body).doesNotContain("POST_RENDER");
+        });
+    }
+
+    @Test
+    public void testPreRenderer() {
+        TestUtil.test((server, httpUtil) -> {
+            server.get("/no-state", new VueHandler(){
+
+                @NotNull
+                @Override
+                public String component(@NotNull Context ctx) {
+                    return "test-component";
+                }
+
+                @NotNull
+                @Override
+                public String preRender(@NotNull String template, @NotNull Context ctx) {
+                    return template.concat("PRE_RENDER");
+                }
+            });
+            String body = httpUtil.getBody("/no-state");
+            assertThat(body).contains("<body><test-component></test-component></body>");
+            assertThat(body).contains("PRE_RENDER");
+            assertThat(body).doesNotContain("POST_RENDER");
+        });
+    }
+
+    @Test
+    public void testPostRenderer() {
+        TestUtil.test((server, httpUtil) -> {
+            server.get("/no-state", new VueHandler(){
+
+                @NotNull
+                @Override
+                public String component(@NotNull Context ctx) {
+                    return "test-component";
+                }
+
+                @NotNull
+                @Override
+                public String postRender(@NotNull String template, @NotNull Context ctx) {
+                    return template.concat("POST_RENDER");
+                }
+            });
+            String body = httpUtil.getBody("/no-state");
+            assertThat(body).contains("<body><test-component></test-component></body>");
+            assertThat(body).doesNotContain("PRE_RENDER");
+            assertThat(body).contains("POST_RENDER");
+        });
+    }
+
+    @Test
+    public void testVueRenderer() {
+        TestUtil.test((server, httpUtil) -> {
+            server.get("/no-state", new VueComponent("test-component",null, new VueRenderer(){
+                @NotNull
+                @Override
+                public String postRender(@NotNull String template, @NotNull Context ctx) {
+                    return template.concat("POST_RENDER");
+                }
+
+                @Override
+                public String preRender(@NotNull String template, @NotNull Context ctx){
+                    return template.concat("PRE_RENDER");
+                }
+            }));
+            String body = httpUtil.getBody("/no-state");
+            assertThat(body).contains("<body><test-component></test-component></body>");
+            assertThat(body).contains("POST_RENDER");
+            assertThat(body).contains("PRE_RENDER");
+        });
+    }
+}