Repository: tipsy/javalin
Pull request #1221 (2021-04-09 22:58:58)

Vue 3 Support
basically, this solves #1123  . After linting the vue docs and migration guide, the only part that actually effects us is that we have Instance-based components instead of global components and the fact that there is  a different place to inject extras instead of manipulating the Vue prototype. This is the initial implementation, and i've made sure nothing is broken from it. However, need to add more testing before pushing out.

@tipsy  a quick review before i continue work would be nice.

tipsy: Looks good! I'm thinking 3 should be the default from now on. I'm wondering if it should be an int though, `vueVersion = 3`? 
tipsy: Looks good! I'm thinking 3 should be the default from now on. I'm wondering if it should be an int though, `vueVersion = 3`? 
TareqK: Well since vue releases come out once ever few years, I don't think it's that worth it - by the time vue4 comes out, vue2 would be deprecated, plus it adds a chance for error.

If we have to do it, maybe I can use an enum ? 
tipsy: We don't have too, I just thought it would read cleaner. "Not vue 3" is theoretically vue 1, 2 and >3 (although I agree it's most likely 2)
TareqK: @tipsy  I made it an enum at the end, because i felt like that made the most sense(having it as an int is functionally the same, but more error prone on the user's end). Ive also made the ```vueAppName``` safer to use with Vue2, so that even if you do change it without changing the Vue Version, the behavior of the dependency resolver stays the same.
TareqK: So i've added a test to make sure that the dependency resolver actually detects the custom regex based on appName, and it seems to work ok now. @tipsy  do we need more tests or is that enough to cover the difference?

Patch:
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
index f7391aaa..f55cec03 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
@@ -16,6 +16,12 @@ import java.nio.file.Paths
 import java.util.stream.Collectors
 import io.javalin.plugin.rendering.vue.JavalinVue.resourcesJarClass as jarClass
 
+enum class VueVersion{
+    VUE_2,
+    VUE_3
+}
+
+
 object JavalinVue {
     // @formatter:off
     internal var isDev: Boolean? = null // cached and easily accessible, is set on first request (can't be configured directly by end user)
@@ -24,16 +30,25 @@ object JavalinVue {
     @JvmField var resourcesJarClass: Class<*> = PathMaster::class.java // can be any class in the jar to look for resources in
     @JvmField var stateFunction: (Context) -> Any = { mapOf<String, String>() } // global state that is injected into all VueComponents
     @JvmField var cacheControl = "no-cache, no-store, must-revalidate"
+    internal var vueAppName = "Vue"
+    @JvmField var vueVersion = VueVersion.VUE_2
     @JvmField var rootDirectory: Path? = null // is set on first request (if not configured)
     @JvmStatic fun rootDirectory(path: String, location: Location) {
         rootDirectory = if (location == Location.CLASSPATH) PathMaster.classpathPath(path) else Paths.get(path)
     }
+    @JvmStatic fun vueAppName():String{
+        return if(JavalinVue.vueVersion == VueVersion.VUE_3) vueAppName else "Vue"
+    }
+    @JvmStatic fun vueAppName(name : String){
+        JavalinVue.vueAppName = name;
+    }
     internal fun walkPaths(): Set<Path> = Files.walk(rootDirectory, 20).collect(Collectors.toSet())
     internal val cachedPaths by lazy { walkPaths() }
-    internal val cachedDependencyResolver by lazy { VueDependencyResolver(cachedPaths) }
+    internal val cachedDependencyResolver by lazy { VueDependencyResolver(cachedPaths,JavalinVue.vueAppName()) }
     // @formatter:on
 }
 
+
 /**
  * By default, [jarClass] is PathMaster::class, which means this code will only
  * work if the resources are in the same jar as Javalin (i.e. in a fat-jar/uber-jar).
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 3c034bb4..ebec7107 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
@@ -26,7 +26,7 @@ class VueComponent @JvmOverloads constructor(val component: String, val state: A
         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) else cachedDependencyResolver }
+        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")
@@ -65,7 +65,7 @@ object FileInliner {
 }
 
 internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
-        "Vue.prototype.\$javalin = JSON.parse(decodeURIComponent(\"${
+        "${prototypeOrGlobalConfig()}.\$javalin = JSON.parse(decodeURIComponent(\"${
             urlEncodeForJavascript(JavalinJson.toJson(
                     mapOf(
                             "pathParams" to ctx.pathParamMap(),
@@ -79,6 +79,6 @@ internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
 // 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 8b805aa3..a88c2e28 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
@@ -31,11 +31,14 @@ public class VueDependencyResolver {
     private final Map<String, String> componentIdToOwnContent; // {component-id: component-content}
     private final Map<String, String> componentIdToDependencyContent; // {component-id: required-dependencies}
     private final Pattern tagRegex = Pattern.compile("<\\s*([a-z0-9|-]*).*?>", Pattern.DOTALL);
-    private final Pattern componentRegex = Pattern.compile("Vue.component\\s*\\(\\s*[\"|'](.*)[\"|']\\s*,.*");
+    private final Pattern componentRegex;
+    private final String appName;
 
-    public VueDependencyResolver(final Set<Path> paths) {
+    public VueDependencyResolver(final Set<Path> paths, String appVarName) {
+        appName = appVarName;
         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);
             Matcher matcher = componentRegex.matcher(fileContent); // check for a vue component
@@ -54,7 +57,7 @@ public class VueDependencyResolver {
      */
     public String resolve(final String componentId) {
         if (!componentIdToOwnContent.containsKey(componentId)) {
-            throw new IllegalArgumentException(String.format("Component %s not found", componentId));
+            throw new IllegalArgumentException(String.format("Component %s not found in app %s", componentId, appName));
         }
         if (componentIdToDependencyContent.containsKey(componentId)) {
             return componentIdToDependencyContent.get(componentId);


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVue.kt b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
index 65cf4a6c..0b4a772e 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVue.kt
+++ b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
@@ -9,6 +9,7 @@ package io.javalin
 import io.javalin.http.Context
 import io.javalin.http.staticfiles.Location
 import io.javalin.plugin.rendering.vue.JavalinVue
+import io.javalin.plugin.rendering.vue.VueVersion
 import io.javalin.plugin.rendering.vue.VueComponent
 import io.javalin.testing.TestUtil
 import io.mockk.every
@@ -30,6 +31,8 @@ class TestJavalinVue {
 
     companion object {
         fun before() {
+            JavalinVue.vueAppName("Vue")
+            JavalinVue.vueVersion = VueVersion.VUE_2
             JavalinVue.isDev = null // reset
             JavalinVue.stateFunction = { ctx -> mapOf<String, String>() } // reset
             JavalinVue.rootDirectory("src/test/resources/vue", Location.EXTERNAL) // src/main -> src/test
@@ -55,6 +58,7 @@ class TestJavalinVue {
         assertThat(res).contains(encodedState)
         assertThat(res).contains("""Vue.component("test-component", {template: "#test-component"});""")
         assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
     }
 
     @Test
@@ -65,6 +69,35 @@ class TestJavalinVue {
         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")
+    }
+
+    @Test
+    fun `vue3 component without state`() = TestUtil.test { app, http ->
+        JavalinVue.vueAppName("app")
+        JavalinVue.vueVersion = VueVersion.VUE_3
+        val encodedEmptyState = """{"pathParams":{},"queryParams":{},"state":{}}""".uriEncodeForJavascript()
+
+        app.get("/no-state", VueComponent("<test-component-3></test-component-3>"))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component-3></test-component-3></body>")
+        assertThat(res).contains("app.config.globalProperties.\$javalin")
+}
+
+
+    @Test
+    fun `vue3 component with state`() = TestUtil.test { app, http ->
+        JavalinVue.vueAppName("app")
+        JavalinVue.vueVersion = VueVersion.VUE_3
+        val encodedState = """{"pathParams":{"my-param":"test-path-param"},"queryParams":{"qp":["test-query-param"]},"state":{"user":{"name":"tipsy","email":"tipsy@tipsy.tipsy"},"role":{"name":"Maintainer"}}}""".uriEncodeForJavascript()
+        JavalinVue.stateFunction = { ctx -> state }
+        app.get("/vue/:my-param", VueComponent("<test-component-3></test-component-3>"))
+        val res = http.getBody("/vue/test-path-param?qp=test-query-param")
+        assertThat(res).contains(encodedState)
+        assertThat(res).contains("""app.component("test-component-3", {template: "#test-component-3"});""")
+        assertThat(res).contains("<body><test-component-3></test-component-3></body>")
+        assertThat(res).contains("app.config.globalProperties.\$javalin")
     }
 
     @Test
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java b/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
index b5e1ef56..a2e1e493 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
+++ b/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
@@ -18,6 +18,7 @@ package io.javalin;
 import io.javalin.http.staticfiles.Location;
 import io.javalin.plugin.rendering.vue.JavalinVue;
 import io.javalin.plugin.rendering.vue.VueComponent;
+import io.javalin.plugin.rendering.vue.VueVersion;
 import io.javalin.testing.TestUtil;
 import org.junit.Before;
 import org.junit.Test;
@@ -30,6 +31,8 @@ public class TestJavalinVueResolution {
 
     @Before
     public void resetJavalinVue() {
+        JavalinVue.vueVersion = VueVersion.VUE_2;
+        JavalinVue.vueAppName("Vue");
         JavalinVue.rootDirectory("src/test/resources/vue", Location.EXTERNAL); // src/main -> src/test
         JavalinVue.optimizeDependencies = true;
     }
@@ -71,6 +74,35 @@ public class TestJavalinVueResolution {
         });
     }
 
+    @Test
+    public void resolveVue3DependencyTest() {
+        TestUtil.test((server, httpUtil) -> {
+            JavalinVue.vueVersion = VueVersion.VUE_3;
+            JavalinVue.vueAppName("app");
+            server.get("/single-view", new VueComponent("<view-one-3></view-one-3>"));
+            String body = httpUtil.getBody("/single-view");
+            assertThat(body).contains("<body><view-one-3></view-one-3></body>");
+            assertThat(body).doesNotContain("<view-two-3>");
+            assertThat(body).doesNotContain("<view-three-3>");
+            assertThat(body).doesNotContain("<view-nested-dependency-3>");
+            assertThat(body).doesNotContain("<view-two>");
+            assertThat(body).doesNotContain("<view-three>");
+            assertThat(body).doesNotContain("<view-nested-dependency>");
+            assertThat(body).contains("dependency-one");
+            assertThat(body).contains("dependency-one-3");
+            assertThat(body).doesNotContain("dependency-two");
+            assertThat(body).doesNotContain("dependency-three");
+            assertThat(body).doesNotContain("dependency-four");
+            assertThat(body).doesNotContain("nested-dependency");
+            assertThat(body).doesNotContain("dependency-two-3");
+            assertThat(body).doesNotContain("dependency-three");
+            assertThat(body).doesNotContain("dependency-four");
+            assertThat(body).doesNotContain("nested-dependency");
+            assertThat(body).doesNotContain("Vue.component");
+            assertThat(body).contains("app.component");
+        });
+    }
+
     @Test
     public void resolveNestedDependencyTest() {
         TestUtil.test((server, httpUtil) -> {
diff --git a/javalin/src/test/resources/vue/dependency-one-3.vue b/javalin/src/test/resources/vue/dependency-one-3.vue
new file mode 100644
index 00000000..9c1897c8
--- /dev/null
+++ b/javalin/src/test/resources/vue/dependency-one-3.vue
@@ -0,0 +1,6 @@
+<template id="dependency-one-3">
+    <div>Dependency One</div>
+</template>
+<script>
+    app.component('dependency-one-3',{template:"#dependency-one-3"});
+</script>
diff --git a/javalin/src/test/resources/vue/dependency-two-3.vue b/javalin/src/test/resources/vue/dependency-two-3.vue
new file mode 100644
index 00000000..58826689
--- /dev/null
+++ b/javalin/src/test/resources/vue/dependency-two-3.vue
@@ -0,0 +1,7 @@
+<template id="dependency-two-3">
+    <div>Dependency Two</div>
+</template>
+<script>
+    app.component('dependency-two-3',{template:"#dependency-two-3"});
+</script>
+
diff --git a/javalin/src/test/resources/vue/test-component-3.vue b/javalin/src/test/resources/vue/test-component-3.vue
new file mode 100644
index 00000000..ecf24f67
--- /dev/null
+++ b/javalin/src/test/resources/vue/test-component-3.vue
@@ -0,0 +1,7 @@
+<template id="test-component-3">
+    <div>Test ÆØÅ</div>
+</template>
+<script>
+    app.component("test-component-3", {template: "#test-component-3"});
+</script>
+
diff --git a/javalin/src/test/resources/vue/view-one-3.vue b/javalin/src/test/resources/vue/view-one-3.vue
new file mode 100644
index 00000000..5357756b
--- /dev/null
+++ b/javalin/src/test/resources/vue/view-one-3.vue
@@ -0,0 +1,7 @@
+<template id="view-one-3">
+    <dependency-one-3></dependency-one-3>
+</template>
+<script>
+    app.component("view-one-3",{template:"#view-one-3"})
+</script>
+