Wiki: Render WikiWord links and autolinks on the server side.

Change-Id: I46f972bcebf765a3d9fb55b7b35f40deb978dc5d
diff --git a/build.gradle b/build.gradle
index f0b037c..ae57661 100644
--- a/build.gradle
+++ b/build.gradle
@@ -108,6 +108,8 @@
 
 processResources {
     exclude("META-INF/resources/node_modules/**/*")
+    exclude("META-INF/resources/package.json")
+    exclude("META-INF/resources/yarn.lock")
 }
 
 quarkusBuild.dependsOn compileWeb
diff --git a/pom.xml b/pom.xml
index 0f26d3e..3fcdc0d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -298,6 +298,8 @@
         <directory>src/main/resources</directory>
         <excludes>
           <exclude>META-INF/resources/node_modules/**/*</exclude>
+          <exclude>META-INF/resources/package.json</exclude>
+          <exclude>META-INF/resources/yarn.lock</exclude>
         </excludes>
         <filtering>false</filtering>
       </resource>
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/wiki/WikiPageRevision.java b/src/main/java/eu/mulk/mulkcms2/benki/wiki/WikiPageRevision.java
index 5783166..4054312 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/wiki/WikiPageRevision.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/wiki/WikiPageRevision.java
@@ -3,6 +3,9 @@
 import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.time.OffsetDateTime;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -12,6 +15,11 @@
 import javax.persistence.JoinColumn;
 import javax.persistence.ManyToOne;
 import javax.persistence.Table;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.TextNode;
+import org.jsoup.parser.Tag;
 
 @Entity
 @Table(name = "wiki_page_revisions", schema = "benki")
@@ -53,9 +61,76 @@
       User author) {
     this.date = date;
     this.title = title;
-    this.content = content;
+    this.content = unhrefify(unwikilinkify(Jsoup.parse(content))).select("body").html();
     this.format = format;
     this.page = page;
     this.author = author;
   }
+
+  public String enrichedContent() {
+    return wikilinkify(hrefify(Jsoup.parse(content))).select("body").html();
+  }
+
+  private static Document tagsoupMapText(Document soup, Function<String, String> fn) {
+    for (var subnode :
+        soup.select(":not(a):not(a *)").stream()
+            .flatMap(node -> node.childNodes().stream())
+            .collect(Collectors.toUnmodifiableList())) {
+      if (subnode instanceof TextNode) {
+        var newNode = new Element(Tag.valueOf("span"), "");
+        newNode.html(fn.apply(((TextNode) subnode).text()));
+        subnode.replaceWith(newNode);
+        newNode.unwrap();
+      }
+    }
+    return soup;
+  }
+
+  private static Pattern WIKIWORD_REGEX =
+      Pattern.compile(
+          "\\p{javaUpperCase}+\\p{javaLowerCase}+\\p{javaUpperCase}+\\p{javaLowerCase}+\\w+");
+  private static Pattern URL_REGEX =
+      Pattern.compile("\\(?\\bhttps?://[-A-Za-z0-9+&@#/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#/%=~_()|]");
+
+  private static Document hrefify(Document soup) {
+    return tagsoupMapText(
+        soup,
+        x ->
+            URL_REGEX
+                .matcher(x)
+                .replaceAll(
+                    match -> {
+                      var s = match.group();
+                      var leftParen = s.startsWith("(");
+                      var rightParen = s.endsWith(")");
+                      var url =
+                          s.substring(leftParen ? 1 : 0, rightParen ? s.length() - 1 : s.length());
+                      return String.format(
+                          "%s<a href=\"%s\" class=\"benkiautohref\">%s</a>%s",
+                          leftParen ? "(" : "", url, url, rightParen ? ")" : "");
+                    }));
+  }
+
+  private static Document unhrefify(Document soup) {
+    soup.select(".benkiautohref").unwrap();
+    return soup;
+  }
+
+  private static Document wikilinkify(Document soup) {
+    return tagsoupMapText(
+        soup,
+        x ->
+            WIKIWORD_REGEX
+                .matcher(x)
+                .replaceAll(
+                    match ->
+                        String.format(
+                            "<a href=\"/wiki/%s\" class=\"benkilink\">%s</a>",
+                            match.group(), match.group())));
+  }
+
+  private static Document unwikilinkify(Document soup) {
+    soup.select(".benkilink").unwrap();
+    return soup;
+  }
 }
diff --git a/src/main/resources/templates/benki/wiki/wikiPage.html b/src/main/resources/templates/benki/wiki/wikiPage.html
index f9f5214..901b300 100644
--- a/src/main/resources/templates/benki/wiki/wikiPage.html
+++ b/src/main/resources/templates/benki/wiki/wikiPage.html
@@ -25,10 +25,11 @@
         requestParams.append(name, regions[name]);
       }
 
-      var response = await fetch("/wiki/{page.title}", {
+      let response = await fetch("/wiki/{page.title}", {
         method: 'POST',
         body: requestParams
       });
+
       this.busy(false);
     });
   });
@@ -45,7 +46,7 @@
 
   <main>
     <div data-editable data-name="wiki-content">
-      {#with page}{content.raw}{/}
+      {#with page}{enrichedContent.raw}{/}
     </div>
   </main>