Refactor JS
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 11 Aug 2024 16:33:17 +0000 (12:33 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 11 Aug 2024 22:27:32 +0000 (18:27 -0400)
14 files changed:
src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt
src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt
src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt
src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt
src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt
src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/jvmMain/resources/static/init.js
src/jvmMain/resources/static/style.css

index e81e3cf5da301f8fa70bdf621aceb6ad932a6017..9fe6dec4ad07c4cc3f74de332a77e940b6c337da 100644 (file)
@@ -5,6 +5,7 @@ import info.mechyrdia.Configuration
 import info.mechyrdia.data.*
 import info.mechyrdia.lore.page
 import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.redirectHrefWithError
 import info.mechyrdia.lore.standardNavBar
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
@@ -110,10 +111,10 @@ suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId
                        .token("mechyrdia_$nsToken")
                        .shards(NationShard.NAME, NationShard.FLAG_URL)
                        .executeSuspend()
-                       ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "That nation does not exist."))))
+                       ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.")
                
                if (!result.isVerified)
-                       redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "Checksum failed verification."))))
+                       redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.")
                
                NationData(Id(result.id), result.name, result.flagUrl).also { NationData.Table.put(it) }
        }
index b1701c947e057bb544df30020d1714b9d7590a53..fbbdbbf3b41194d0e95b326588cf366477bd7326 100644 (file)
@@ -5,6 +5,7 @@ import info.mechyrdia.data.*
 import info.mechyrdia.lore.adminPage
 import info.mechyrdia.lore.dateTime
 import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.redirectHrefWithError
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
@@ -35,7 +36,7 @@ data class WebDavToken(
 
 suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit {
        val nation = currentNation()
-               ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to request WebDAV tokens"))))
+               ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to request WebDAV tokens")
        
        val existingTokens = WebDavToken.Table
                .filter(Filters.eq(WebDavToken::holder.serialName, nation.id))
@@ -83,7 +84,7 @@ suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit {
 
 suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit {
        val nation = currentNation()
-               ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to generate WebDAV tokens"))))
+               ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to generate WebDAV tokens")
        
        val token = WebDavToken(
                holder = nation.id,
index 484bad331be41956029a85dd6eebae593c16171d..8916fbbdc8bbe62afb6d680c3b3595d74a3627dc 100644 (file)
@@ -173,6 +173,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                        installCsrfToken()
                        submitInput { value = "Edit Comment" }
                        button(classes = "comment-cancel-edit evil") {
+                               attributes["data-edit-id"] = "comment-edit-box-${comment.id}"
                                +"Cancel Editing"
                        }
                }
index bb2abc64c0f12aedf89d615c8f9c406c7164afb9..d9cd3f037e577e406c81cc88067f2e87f83e6a4e 100644 (file)
@@ -4,7 +4,6 @@ import com.mongodb.client.model.Sorts
 import info.mechyrdia.OwnerNationId
 import info.mechyrdia.auth.ForbiddenException
 import info.mechyrdia.lore.*
-import info.mechyrdia.route.ErrorMessageAttributeKey
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
@@ -64,10 +63,10 @@ suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
 }
 
 suspend fun ApplicationCall.newCommentRoute(pagePathParts: List<String>, contents: String): Nothing {
-       val loggedInAs = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to write comments"))))
+       val loggedInAs = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to write comments")
        
        if (contents.isBlank())
-               redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank")))
+               redirectHrefWithError(Root.LorePage(pagePathParts), error = "Comments may not be blank")
        
        val now = Instant.now()
        val comment = Comment(
@@ -97,20 +96,19 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
                throw NoSuchElementException("Shadowbanned comment")
        
        val pagePathParts = comment.submittedIn.split('/')
-       val errorMessage = attributes.getOrNull(ErrorMessageAttributeKey)
-       redirectHref(Root.LorePage(pagePathParts, root = Root(errorMessage)), hash = "comment-$commentId")
+       redirectHref(Root.LorePage(pagePathParts), hash = "comment-$commentId")
 }
 
 suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents: String): Nothing {
        val oldComment = Comment.Table.get(commentId)!!
        
-       val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("You must be logged in to edit comments"))))
+       val currNation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to edit comments")
        
        if (currNation.id != oldComment.submittedBy)
                throw ForbiddenException("Illegal attempt by ${currNation.id} to edit comment by ${oldComment.submittedBy}")
        
        if (newContents.isBlank())
-               redirectHref(Root.Comments.ViewPage(oldComment.id, Root.Comments(Root("Comments may not be blank"))))
+               redirectHrefWithError(Root.Comments.ViewPage(oldComment.id), error = "Comments may not be blank")
        
        // Check for null edits, i.e. edits that don't change anything
        if (newContents == oldComment.contents)
@@ -130,7 +128,7 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents
 }
 
 private suspend fun ApplicationCall.getCommentForDeletion(commentId: Id<Comment>): Pair<NationData, Comment> {
-       val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("You must be logged in to delete comments"))))
+       val currNation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), "You must be logged in to delete comments")
        val comment = Comment.Table.get(commentId)!!
        
        if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
index 566dba7d92551393abf67c097be14929004fcde4..1b46330d8b2d77a1ba57796bb66003eadb4e66bc 100644 (file)
@@ -1,5 +1,6 @@
 package info.mechyrdia.lore
 
+import info.mechyrdia.route.ErrorMessageCookieName
 import info.mechyrdia.route.href
 import io.ktor.server.application.*
 
@@ -7,4 +8,9 @@ data class HttpRedirectException(val url: String, val permanent: Boolean) : Runt
 
 fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent)
 
+inline fun <reified T : Any> ApplicationCall.redirectHrefWithError(resource: T, error: String, hash: String? = null): Nothing {
+       response.cookies.append(ErrorMessageCookieName, error, secure = true, httpOnly = false, extensions = mapOf("SameSite" to "Lax"))
+       redirect(href(resource, hash), false)
+}
+
 inline fun <reified T : Any> ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent)
index 7eefe4cfd7f165b5344374d8c0137e9b3aa4a6fb..842efe9bbfd919bb2a96014f6cffe06be2ff0b83 100644 (file)
@@ -5,7 +5,6 @@ import io.ktor.util.*
 import kotlinx.html.*
 import kotlinx.html.org.w3c.dom.events.Event
 import kotlinx.html.stream.createHTML
-import kotlinx.serialization.json.JsonPrimitive
 import java.time.Instant
 import kotlin.text.toCharArray
 
@@ -295,10 +294,9 @@ class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (String) -> Str
                        anchorId?.let { a { id = it } }
                        
                        tagCreator {
+                               attributes["data-redirect-id"] = anchorHash
                                +content
                        }
-                       
-                       script { unsafe { +"window.checkRedirectTarget(\"$anchorHash\");" } }
                }
        }
 }
@@ -448,7 +446,10 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
                        })
                } else {
                        ({
-                               script { unsafe { +"window.appendImageThumb(\"/assets/images/$url\", \"$styleValue\");" } }
+                               span(classes = "image-thumb") {
+                                       attributes["data-src"] = "/assets/images/$url"
+                                       attributes["data-style"] = styleValue
+                               }
                        })
                }
        }),
@@ -475,10 +476,12 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
                })
        }),
        QUIZ(HtmlTextBodyLexerTag { _, _, content ->
-               val contentJson = JsonStorageCodec.parseToJsonElement(content)
+               val contentJson = JsonStorageCodec.parseToJsonElement(content).toString()
                
                ({
-                       script { unsafe { +"window.renderQuiz($contentJson);" } }
+                       span(classes = "quiz") {
+                               attributes["data-quiz"] = contentJson
+                       }
                })
        }),
        
@@ -512,11 +515,14 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
        }),
        REDIRECT(HtmlTextBodyLexerTag { _, _, content ->
                val url = content.toInternalUrl()
-               val jsString = JsonPrimitive(url).toString()
                
                ({
-                       script {
-                               unsafe { +"window.factbookRedirect($jsString);" }
+                       p {
+                               style = "font-weight:800"
+                               +"Redirect to "
+                               a(href = url, classes = "redirect-link") {
+                                       +url
+                               }
                        }
                })
        }),
@@ -641,10 +647,12 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
                }
        ),
        VOCAB(HtmlTextBodyLexerTag { _, _, content ->
-               val contentJson = JsonStorageCodec.parseToJsonElement(content)
+               val contentJson = JsonStorageCodec.parseToJsonElement(content).toString()
                
                ({
-                       script { unsafe { +"window.renderVocab($contentJson);" } }
+                       span(classes = "vocab") {
+                               attributes["data-vocab"] = contentJson
+                       }
                })
        }),
        ;
index 3b27b41210e642bc7930530c5f50dfffa0b59702..60da713dd479cfb51b9ad7cf58e852d2ccffa006 100644 (file)
@@ -81,15 +81,6 @@ fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sideb
                        
                        link(rel = "stylesheet", type = "text/css", href = "/static/style.css")
                        
-                       request.queryParameters["redirect"]?.let { redirect ->
-                               if (redirect == "no")
-                                       script {
-                                               unsafe {
-                                                       raw("window.disableFactbookRedirect = true;")
-                                               }
-                                       }
-                       }
-                       
                        script(src = "/static/init.js") {}
                }
                body {
@@ -134,36 +125,6 @@ fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sideb
                                        }
                                }
                        }
-                       
-                       div {
-                               id = "thumb-view"
-                               div(classes = "bg")
-                               img(alt = "Click to close full size") {
-                                       title = "Click to close full size"
-                               }
-                       }
-                       
-                       script {
-                               unsafe {
-                                       raw("window.handleFullSizeImages();")
-                               }
-                       }
-                       
-                       this@page.attributes.getOrNull(ErrorMessageAttributeKey)?.let { errorMessage ->
-                               div {
-                                       id = "error-popup"
-                                       
-                                       val paramsWithoutError = parametersOf(request.queryParameters.toMap() - "error")
-                                       val newQueryString = paramsWithoutError.toQueryString()
-                                       attributes["data-redirect-url"] = "${request.path()}$newQueryString"
-                                       
-                                       div(classes = "bg")
-                                       div(classes = "msg") {
-                                               p { +errorMessage }
-                                               p { +"Click to close this popup" }
-                                       }
-                               }
-                       }
                }
        }
 }
index c098c316b49fa9fbb3aa1cc85d5f9c241c4a0956..f2d6a61954bc5866510395a037ca37681ae34354 100644 (file)
@@ -77,7 +77,7 @@ inline fun <reified E : Enum<E>> FlowOrInteractiveOrPhrasingContent.preference(i
        
        entries.joinToHtml(Tag::br) { option ->
                label {
-                       radioInput(name = inputName) {
+                       radioInput(name = inputName, classes = "pref-$inputName") {
                                value = serializer.getKey(option) ?: "null"
                                required = true
                                checked = current == option
index 4a00559da2b7b892035677969df79998e552e589..5ca4e0b1ae34fd45361f1216f4511bb133aa0abe 100644 (file)
@@ -93,14 +93,14 @@ inline fun <reified T> HttpRequestBuilder.setJsonBody(body: T) {
        setBody(body)
 }
 
-suspend inline fun poll(wait: Long = 1_000L, block: () -> Boolean) {
-       while (!block())
+suspend inline fun poll(wait: Long = 1_000L, until: () -> Boolean) {
+       while (!until())
                delay(wait)
 }
 
-suspend inline fun <T : Any> pollValue(wait: Long = 1_000L, block: () -> T?): T {
+suspend inline fun <T : Any> pollValue(wait: Long = 1_000L, value: () -> T?): T {
        while (true) {
-               block()?.let { return it }
+               value()?.let { return it }
                delay(wait)
        }
 }
index 486c87d582e07b45a6323a85ed796585ab741cca..830bf15df1f4d7624123a01b5ae9aa8ee44890c3 100644 (file)
@@ -108,7 +108,11 @@ class RobotService(
        }\r
        \r
        private suspend fun deleteThread(threadId: RobotThreadId) {\r
-               robotClient.deleteThread(threadId)\r
+               try {\r
+                       robotClient.deleteThread(threadId)\r
+               } catch (ex: ClientRequestException) {\r
+                       logger.warn("Unable to delete thread at ID $threadId", ex)\r
+               }\r
                (RobotGlobals.get() ?: RobotGlobals()).minusThread(threadId).save()\r
        }\r
        \r
@@ -241,7 +245,11 @@ class RobotService(
                        if (assistants.isEmpty()) break\r
                        \r
                        assistants.map { it.id }.forEach {\r
-                               robotClient.deleteAssistant(it)\r
+                               try {\r
+                                       robotClient.deleteAssistant(it)\r
+                               } catch (ex: ClientRequestException) {\r
+                                       logger.warn("Unable to delete assistant at ID $it", ex)\r
+                               }\r
                        }\r
                }\r
                \r
@@ -250,12 +258,25 @@ class RobotService(
                        if (vectorStores.isEmpty()) break\r
                        \r
                        vectorStores.map { it.id }.forEach {\r
-                               robotClient.deleteVectorStore(it)\r
+                               try {\r
+                                       robotClient.deleteVectorStore(it)\r
+                               } catch (ex: ClientRequestException) {\r
+                                       logger.warn("Unable to delete vector-store at ID $it", ex)\r
+                               }\r
                        }\r
                }\r
                \r
-               robotClient.listFiles().data.map { it.id }.forEach {\r
-                       robotClient.deleteFile(it)\r
+               while (true) {\r
+                       val files = robotClient.listFiles().data\r
+                       if (files.isEmpty()) break\r
+                       \r
+                       files.map { it.id }.forEach {\r
+                               try {\r
+                                       robotClient.deleteFile(it)\r
+                               } catch (ex: ClientRequestException) {\r
+                                       logger.warn("Unable to delete file at ID $it", ex)\r
+                               }\r
+                       }\r
                }\r
                \r
                initialize()\r
index 7e8b83ac2b1318e603a2042fa70ac09963887b4d..fa93817b6ae03ad26b0043f86937a15f552eb905 100644 (file)
@@ -4,7 +4,7 @@ import info.mechyrdia.auth.createCsrfToken
 import info.mechyrdia.data.currentNation
 import info.mechyrdia.lore.adminPage
 import info.mechyrdia.lore.page
-import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.redirectHrefWithError
 import info.mechyrdia.lore.standardNavBar
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.checkCsrfToken
@@ -15,10 +15,9 @@ import io.ktor.server.websocket.*
 import io.ktor.websocket.*
 import io.ktor.websocket.CloseReason.*
 import kotlinx.html.*
-import kotlinx.serialization.json.JsonPrimitive
 
 suspend fun ApplicationCall.robotPage(): HTML.() -> Unit {
-       val nation = currentNation()?.id ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to use the NUKE"))))
+       val nation = currentNation()?.id ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to use the NUKE")
        val exhausted = RobotUser.getTokens(nation) >= RobotUser.getMaxTokens(nation)
        
        val nukeRoute = href(Root.Nuke.WS())
@@ -34,20 +33,16 @@ suspend fun ApplicationCall.robotPage(): HTML.() -> Unit {
                                b { +"NUKE" }
                                +" (Natural-language Universal Knowledge Engine) is an interactive encyclopedia that answers questions about the galaxy."
                        }
-                       if (exhausted)
-                               p { +"You have exhausted your monthly limit of NUKE usage." }
-                       else
-                               when (robotServiceStatus) {
-                                       RobotServiceStatus.NOT_CONFIGURED -> p { +"Unfortunately, the NUKE is not configured on this website." }
-                                       RobotServiceStatus.LOADING -> p { +"The NUKE is still in the process of initializing." }
-                                       RobotServiceStatus.FAILED -> p { +"Tragically, the NUKE has failed to initialize due to an internal error." }
-                                       RobotServiceStatus.READY -> script {
-                                               unsafe {
-                                                       val jsToken = JsonPrimitive(token).toString()
-                                                       +"window.createNukeBox($jsToken);"
-                                               }
-                                       }
-                               }
+                       
+                       when (robotServiceStatus) {
+                               RobotServiceStatus.NOT_CONFIGURED -> p { +"Unfortunately, the NUKE is not configured on this website." }
+                               RobotServiceStatus.LOADING -> p { +"The NUKE is still in the process of initializing." }
+                               RobotServiceStatus.FAILED -> p { +"Tragically, the NUKE has failed to initialize due to an internal error." }
+                               RobotServiceStatus.READY -> if (exhausted)
+                                       p { +"You have exhausted your monthly limit of NUKE usage." }
+                               else
+                                       span(classes = "nuke-box") { attributes["data-ws-csrf-token"] = token }
+                       }
                }
        }
 }
index 32d89d7aa1c781db0b491cee5529032dcede8c53..e3fb1bc66647577c60b1bf5ffb4f203349b31b38 100644 (file)
@@ -19,12 +19,14 @@ import io.ktor.util.*
 import io.ktor.util.pipeline.*
 import kotlinx.coroutines.delay
 
+const val ErrorMessageCookieName = "ERROR_MSG"
+
 val ErrorMessageAttributeKey = AttributeKey<String>("Mechyrdia.ErrorMessage")
 
 @Resource("/")
-class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
+class Root : ResourceHandler, ResourceFilter {
        override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-               error?.let { call.attributes.put(ErrorMessageAttributeKey, it) }
+               call.request.cookies[ErrorMessageCookieName]?.let { call.attributes.put(ErrorMessageAttributeKey, it) }
        }
        
        override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
index 23fd68ff507f59003ddd6f50f8a6dd198d771b12..10a826e6d87a12ac26fc80a638ef1e3e42dec86b 100644 (file)
                });
        })();
 
+       /**
+        * @returns {Object.<string, string>}
+        */
+       function getCookieMap() {
+               return document.cookie
+                       .split(";")
+                       .reduce((obj, entry) => {
+                               const trimmed = entry.trim();
+                               const eqI = trimmed.indexOf('=');
+                               const key = trimmed.substring(0, eqI).trimEnd();
+                               const value = trimmed.substring(eqI + 1).trimStart();
+                               return {...obj, [key]: value};
+                       }, {});
+       }
+
+       /**
+        * @param {ParentNode} element
+        */
+       function clearChildren(element) {
+               while (element.hasChildNodes()) {
+                       element.firstChild.remove();
+               }
+       }
+
+       /**
+        * @param {number} amount
+        * @return {Promise<void>}
+        */
        function delay(amount) {
                return new Promise(resolve => window.setTimeout(resolve, amount));
        }
 
+       /**
+        * @return {Promise<DOMHighResTimeStamp>}
+        */
        function frame() {
                return new Promise(resolve => window.requestAnimationFrame(resolve));
        }
 
+       /**
+        * @param {string} url
+        * @return {Promise<void>}
+        */
        function loadScript(url) {
                return new Promise((resolve, reject) => {
                        const script = document.createElement('script');
-                       script.onload = () => resolve();
-                       script.onerror = event => reject(event);
+                       script.addEventListener("load", () => resolve());
+                       script.addEventListener("error", e => reject(e));
                        script.src = url;
                        document.head.appendChild(script);
                });
        }
 
+       /**
+        * @param {ParentNode} element
+        * @param {string} text
+        * @return {void}
+        */
        function appendWithLineBreaks(element, text) {
                const lines = text.split("\n");
                let isFirst = true;
                }
        }
 
-       window.addEventListener("load", function () {
-               // Mechyrdian font
-               async function mechyrdianToFont(input, boldOpt, italicOpt, alignOpt, output, delayLength) {
-                       const inText = input.value;
-
-                       await delay(delayLength);
-                       if (inText !== input.value) return;
-
-                       let outBlob;
-                       if (inText.trim().length === 0) {
-                               outBlob = new Blob([
-                                       "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
-                                       "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"0\">\n",
-                                       "</svg>\n"
-                               ], {type: "image/svg+xml"});
-                       } else {
-                               const urlParams = new URLSearchParams();
-                               if (boldOpt.checked) urlParams.set("bold", "true");
-                               if (italicOpt.checked) urlParams.set("italic", "true");
-                               urlParams.set("align", alignOpt.value);
-
-                               for (const line of inText.split("\n"))
-                                       urlParams.append("lines", line.trim());
-
-                               outBlob = await (await fetch('/utils/mechyrdia-sans', {
-                                       method: 'POST',
-                                       headers: {
-                                               'Content-Type': 'application/x-www-form-urlencoded',
-                                       },
-                                       body: urlParams,
-                               })).blob();
-
-                               if (inText !== input.value) return;
-                       }
-
-                       const prevObjectUrl = output.src;
-                       if (prevObjectUrl != null && prevObjectUrl.length > 0)
-                               URL.revokeObjectURL(prevObjectUrl);
-
-                       output.src = URL.createObjectURL(outBlob);
-               }
-
-               const mechyrdiaSansBoxes = document.getElementsByClassName("mechyrdia-sans-box");
-               for (const mechyrdiaSansBox of mechyrdiaSansBoxes) {
-                       const inputBox = mechyrdiaSansBox.getElementsByClassName("input-box")[0];
-                       const boldOpt = mechyrdiaSansBox.getElementsByClassName("bold-option")[0];
-                       const italicOpt = mechyrdiaSansBox.getElementsByClassName("ital-option")[0];
-                       const alignOpt = mechyrdiaSansBox.getElementsByClassName("align-opts")[0];
-                       const outputBox = mechyrdiaSansBox.getElementsByClassName("output-img")[0];
-
-                       const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 750);
-                       const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 250);
-                       inputBox.addEventListener("input", inputListener);
-                       boldOpt.addEventListener("change", optChangeListener);
-                       italicOpt.addEventListener("change", optChangeListener);
-                       alignOpt.addEventListener("change", optChangeListener);
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Tylan alphabet
-               async function tylanToFont(input, output) {
-                       const inText = input.value;
-
-                       const urlParams = new URLSearchParams();
-                       for (const line of inText.split("\n"))
-                               urlParams.append("lines", line.trim());
-
-                       const outText = await (await fetch('/utils/tylan-lang', {
-                               method: 'POST',
-                               headers: {
-                                       'Content-Type': 'application/x-www-form-urlencoded',
-                               },
-                               body: urlParams,
-                       })).text();
-
-                       if (inText === input.value)
-                               output.value = outText;
-               }
-
-               const tylanAlphabetBoxes = document.getElementsByClassName("tylan-alphabet-box");
-               for (const tylanAlphabetBox of tylanAlphabetBoxes) {
-                       const inputBox = tylanAlphabetBox.getElementsByClassName("input-box")[0];
-                       const outputBox = tylanAlphabetBox.getElementsByClassName("output-box")[0];
-
-                       inputBox.addEventListener("input", () => tylanToFont(inputBox, outputBox));
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Thedish alphabet
-               const thedishAlphabetBoxes = document.getElementsByClassName("thedish-alphabet-box");
-               for (const thedishAlphabetBox of thedishAlphabetBoxes) {
-                       const inputBox = thedishAlphabetBox.getElementsByClassName("input-box")[0];
-                       const outputBox = thedishAlphabetBox.getElementsByClassName("output-box")[0];
-
-                       inputBox.addEventListener("input", () => {
-                               outputBox.value = inputBox.value;
-                       });
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Kishari alphabet
-               const kishariAlphabetBoxes = document.getElementsByClassName("kishari-alphabet-box");
-               for (const kishariAlphabetBox of kishariAlphabetBoxes) {
-                       const inputBox = kishariAlphabetBox.getElementsByClassName("input-box")[0];
-                       const outputBox = kishariAlphabetBox.getElementsByClassName("output-box")[0];
-
-                       inputBox.addEventListener("input", () => {
-                               outputBox.value = inputBox.value;
-                       });
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Pokhwalish alphabet
-               async function pokhwalToFont(input, output) {
-                       const inText = input.value;
-
-                       const urlParams = new URLSearchParams();
-                       for (const line of inText.split("\n"))
-                               urlParams.append("lines", line.trim());
-
-                       const outText = await (await fetch('/utils/pokhwal-lang', {
-                               method: 'POST',
-                               headers: {
-                                       'Content-Type': 'application/x-www-form-urlencoded',
-                               },
-                               body: urlParams,
-                       })).text();
-
-                       if (inText === input.value)
-                               output.value = outText;
-               }
-
-               const pokhwalAlphabetBoxes = document.getElementsByClassName("pokhwal-alphabet-box");
-               for (const pokhwalAlphabetBox of pokhwalAlphabetBoxes) {
-                       const inputBox = pokhwalAlphabetBox.getElementsByClassName("input-box")[0];
-                       const outputBox = pokhwalAlphabetBox.getElementsByClassName("output-box")[0];
-
-                       inputBox.addEventListener("input", () => pokhwalToFont(inputBox, outputBox));
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Set client preferences when selected
-               const themeChoices = document.getElementsByName("theme");
-               for (const themeChoice of themeChoices) {
-                       themeChoice.addEventListener("click", e => {
-                               const theme = e.currentTarget.value;
-                               if (theme === "null") {
-                                       document.documentElement.removeAttribute("data-theme");
-                               } else {
-                                       document.documentElement.setAttribute("data-theme", theme);
-                               }
-                               document.cookie = "FACTBOOK_THEME=" + theme + "; Secure; SameSite=Lax; Max-Age=" + (Math.pow(2, 31) - 1).toString();
-                       });
-               }
-
-               const april1stChoices = document.getElementsByName("april1st");
-               for (const april1stChoice of april1stChoices) {
-                       april1stChoice.addEventListener("click", e => {
-                               const mode = e.currentTarget.value;
-                               document.cookie = "APRIL_1ST_MODE=" + mode + "; Secure; SameSite=None; Max-Age=" + (Math.pow(2, 31) - 1).toString();
-                       });
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Localize dates and times
-               const moments = document.getElementsByClassName("moment");
-               for (const moment of moments) {
-                       let date = new Date(Number(moment.textContent.trim()));
-                       moment.innerHTML = date.toLocaleString();
-                       moment.style.display = "inline";
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Login button
-               const viewChecksumButtons = document.getElementsByClassName("view-checksum");
-               for (const viewChecksumButton of viewChecksumButtons) {
-                       const token = viewChecksumButton.getAttribute("data-token");
-                       const url = (token != null && token !== "") ? ("https://www.nationstates.net/page=verify_login?token=" + token) : "https://www.nationstates.net/page=verify_login"
-                       viewChecksumButton.addEventListener("click", e => {
-                               e.preventDefault();
-                               window.open(url);
-                       });
-               }
-       });
-
-       window.appendImageThumb = function (src, sizeStyle) {
-               // Image previewing (1)
-               const imgElement = document.createElement("img");
-               imgElement.src = src;
-               imgElement.setAttribute("title", "Click to view full size");
-               imgElement.setAttribute("style", sizeStyle);
-
-               imgElement.onclick = e => {
-                       e.preventDefault();
-
-                       const thumbView = document.getElementById("thumb-view");
-                       const thumbViewImg = thumbView.getElementsByTagName("img")[0];
-                       thumbViewImg.src = e.currentTarget.src;
-                       thumbView.classList.add("visible");
-               }
-
-               document.currentScript.after(imgElement);
-       };
-
-       window.handleFullSizeImages = function () {
-               // Image previewing (2)
-               document.getElementById("thumb-view").addEventListener("click", e => {
-                       e.preventDefault();
-
-                       e.currentTarget.classList.remove("visible");
-                       e.currentTarget.getElementsByTagName("img")[0].src = "";
-               });
-       };
-
-       window.addEventListener("load", function () {
-               // Mesh viewing
-
-               async function loadThree() {
-                       await loadScript("/static/obj-viewer/three.js");
-                       await loadScript("/static/obj-viewer/three-examples.js");
-               }
-
+       /**
+        * @returns {Promise<function(string): Promise<THREE.Mesh>>}
+        */
+       async function loadThreeJs() {
+               await loadScript("/static/obj-viewer/three.js");
+               await loadScript("/static/obj-viewer/three-examples.js");
+
+               /**
+                * @param {string} modelName
+                * @returns {Promise<THREE.Mesh>}
+                */
                async function loadObj(modelName) {
+                       const THREE = window.THREE;
                        const mtlLib = await (new THREE.MTLLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").loadAsync(modelName + ".mtl");
                        mtlLib.preload();
                        return await (new THREE.OBJLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").setMaterials(mtlLib).loadAsync(modelName + ".obj");
                }
 
-               const canvases = document.getElementsByTagName("canvas");
-               if (canvases.length > 0) {
-                       (async () => {
-                               await loadThree();
-
-                               const promises = [];
-                               for (const canvas of canvases) {
-                                       const modelName = canvas.getAttribute("data-model");
-                                       if (modelName == null || modelName === "") continue;
-
-                                       promises.push((async () => {
-                                               const modelAsync = loadObj(modelName);
-
-                                               const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0);
-
-                                               const scene = new THREE.Scene();
-                                               scene.add(new THREE.AmbientLight("#555555", 1.0));
-
-                                               const renderer = new THREE.WebGLRenderer({"canvas": canvas, "antialias": true});
-
-                                               const controls = new THREE.OrbitControls(camera, canvas);
-
-                                               function render() {
-                                                       controls.update();
-                                                       renderer.render(scene, camera);
-                                                       window.requestAnimationFrame(render);
-                                               }
-
-                                               function onResize() {
-                                                       const dim = canvas.getBoundingClientRect();
-                                                       camera.aspect = dim.width / dim.height;
-                                                       camera.updateProjectionMatrix();
-                                                       renderer.setSize(dim.width, dim.height, false);
-                                               }
-
-                                               window.addEventListener('resize', onResize);
-                                               await frame();
-                                               onResize();
-
-                                               const model = await modelAsync;
-                                               scene.add(model);
-
-                                               const bbox = new THREE.Box3().setFromObject(scene);
-                                               bbox.dimensions = {
-                                                       x: bbox.max.x - bbox.min.x,
-                                                       y: bbox.max.y - bbox.min.y,
-                                                       z: bbox.max.z - bbox.min.z
-                                               };
-                                               model.position.sub(new THREE.Vector3(bbox.min.x + bbox.dimensions.x / 2, bbox.min.y + bbox.dimensions.y / 2, bbox.min.z + bbox.dimensions.z / 2));
-
-                                               camera.position.set(bbox.dimensions.x / 2, bbox.dimensions.y / 2, Math.max(bbox.dimensions.x, bbox.dimensions.y, bbox.dimensions.z));
-
-                                               const light = new THREE.PointLight("#AAAAAA", 1.0);
-                                               scene.add(camera);
-                                               camera.add(light);
-                                               light.position.set(0, 0, 0);
-
-                                               render();
-                                       })());
-                               }
-
-                               await Promise.all(promises);
-                       })().catch(reason => {
-                               console.error("Error rendering models", reason);
-                       });
-               }
-       });
-
-       window.addEventListener("load", function () {
-               // Allow POSTing with <a>s
-               const anchors = document.getElementsByTagName("a");
-               for (const anchor of anchors) {
-                       const method = anchor.getAttribute("data-method");
-                       if (method == null) continue;
-
-                       anchor.onclick = e => {
-                               e.preventDefault();
-
-                               let form = document.createElement("form");
-                               form.style.display = "none";
-                               form.action = e.currentTarget.href;
-                               form.method = e.currentTarget.getAttribute("data-method");
-
-                               const csrfToken = e.currentTarget.getAttribute("data-csrf-token");
-                               if (csrfToken != null) {
-                                       let csrfInput = document.createElement("input");
-                                       csrfInput.name = "csrfToken";
-                                       csrfInput.type = "hidden";
-                                       csrfInput.value = csrfToken;
-                                       form.append(csrfInput);
-                               }
-
-                               document.body.append(form);
-                               form.submit();
-                       };
-               }
-       });
+               return loadObj;
+       }
 
-       window.renderVocab = function (vocab) {
+       /**
+        * @typedef {{tag: string, attrs: Object.<string, *>, text: (string|{form: string, regexp: string, replacement: string})}} VocabInflectionTableCell
+        * @typedef {Array.<VocabInflectionTableCell>} VocabInflectionTableRow
+        * @typedef {Array.<VocabInflectionTableRow>} VocabInflectionTable
+        * @typedef {{type: string, inEnglish: Array.<string>, forms: Array.<string>, definitions: Array.<string>}} VocabWordEntry
+        * @typedef {Array.<VocabWordEntry>} VocabWord
+        * @typedef {{langName: string, inflections: Object.<string, VocabInflectionTable>, words: Object.<string, VocabWord>}} Vocab
+        *
+        * @param {Vocab} vocab
+        * @returns {HTMLDivElement}
+        */
+       function renderVocab(vocab) {
+               /**
+                * @param {string} word
+                * @param {number} index
+                * @returns {HTMLDivElement}
+                */
                function renderWord(word, index) {
                        const wordRoot = document.createElement("div");
 
                vocabSearchButton.type = "submit";
                vocabSearchButton.value = "Search";
 
-               vocabSearchRoot.onsubmit = function (ev) {
-                       ev.preventDefault();
+               vocabSearchRoot.addEventListener("submit", function (e) {
+                       e.preventDefault();
 
                        const searchTerm = vocabSearch.value.trim();
 
-                       while (vocabSearchResults.hasChildNodes()) {
-                               vocabSearchResults.firstChild.remove();
-                       }
+                       clearChildren(vocabSearchResults);
 
                        const searchResults = [];
                        if (vocabEnglishToLang.checked) {
                        for (const searchResult of searchResults) {
                                vocabSearchResults.append(renderWord(searchResult.word, searchResult.index));
                        }
-               };
+               });
 
-               document.currentScript.after(vocabRoot);
-       };
+               return vocabRoot;
+       }
 
-       window.renderQuiz = function (quiz) {
-               const quizFunctions = {};
+       /**
+        * @typedef {{name: string, desc: string, img: string, url: string}} QuizOutcome
+        * @typedef {{answer: string, result: Object.<string, number>}} QuizQuestionAnswer
+        * @typedef {{asks: string, answers: Object.<string, QuizQuestionAnswer>}} QuizQuestion
+        * @typedef {{title: string, intro: string, image: string, outcomes: Object.<string, QuizOutcome>, questions: Array.<QuizQuestion>}} Quiz
+        *
+        * @param {Quiz} quiz
+        * @returns {HTMLTableElement}
+        */
+       function renderQuiz(quiz) {
                const quizRoot = document.createElement("table");
                const questionAnswers = [];
 
-               quizFunctions.clearRoot = function () {
-                       while (quizRoot.hasChildNodes()) {
-                               quizRoot.firstChild.remove();
-                       }
-               };
-
-               quizFunctions.renderIntro = function () {
-                       quizFunctions.clearRoot();
+               function renderIntro() {
+                       clearChildren(quizRoot);
 
                        const firstRow = document.createElement("tr");
                        const firstCell = document.createElement("td");
                        firstCell.style.textAlign = "center";
                        firstCell.style.fontSize = "1.5em";
                        firstCell.style.fontWeight = "bold";
-                       firstCell.append(quiz.title.toString());
+                       firstCell.append(quiz.title);
                        firstRow.appendChild(firstCell);
                        quizRoot.appendChild(firstRow);
 
                        const secondCell = document.createElement("td");
                        secondCell.style.textAlign = "center";
                        secondCell.appendChild(document.createElement("img")).src = quiz.image;
-                       for (const paragraph of quiz.intro.toString().split('\n')) {
+                       for (const paragraph of quiz.intro.split('\n')) {
                                secondCell.appendChild(document.createElement("p")).append(paragraph);
                        }
                        secondRow.appendChild(secondCell);
                        const beginLink = thirdCell.appendChild(document.createElement("a"));
                        beginLink.href = "#";
                        beginLink.append("Begin Quiz (" + quiz.questions.length + " questions)");
-                       beginLink.onclick = e => {
+                       beginLink.addEventListener("click", e => {
                                e.preventDefault();
-                               quizFunctions.renderQuestion(0);
-                       };
+                               renderQuestion(0);
+                       });
                        thirdRow.appendChild(thirdCell);
                        quizRoot.appendChild(thirdRow);
-               };
+               }
 
-               quizFunctions.renderOutro = function (outcome) {
-                       quizFunctions.clearRoot();
+               /**
+                * @param {QuizOutcome} outcome
+                */
+               function renderOutro(outcome) {
+                       clearChildren(quizRoot);
 
                        const firstRow = document.createElement("tr");
                        const firstCell = document.createElement("td");
                        firstCell.style.textAlign = "center";
                        firstCell.style.fontSize = "1.5em";
                        firstCell.style.fontWeight = "bold";
-                       firstCell.append(outcome.name.toString());
+                       firstCell.append(outcome.name);
                        firstRow.appendChild(firstCell);
                        quizRoot.appendChild(firstRow);
 
                        const secondCell = document.createElement("td");
                        secondCell.style.textAlign = "center";
                        secondCell.appendChild(document.createElement("img")).src = outcome.img;
-                       for (const paragraph of outcome.desc.toString().split('\n')) {
+                       for (const paragraph of outcome.desc.split('\n')) {
                                secondCell.appendChild(document.createElement("p")).append(paragraph);
                        }
                        secondRow.appendChild(secondCell);
                        moreInfoLink.append("More Information");
                        thirdRow.appendChild(thirdCell);
                        quizRoot.appendChild(thirdRow);
-               };
+               }
 
-               quizFunctions.calculateResults = function () {
+               /**
+                * @returns {QuizOutcome}
+                */
+               function calculateResults() {
                        const total = {};
                        for (const result of questionAnswers) {
                                for (const resKey of Object.keys(result)) {
                        }
 
                        return quiz.outcomes[maxKey];
-               };
+               }
 
-               quizFunctions.renderQuestion = function (index) {
-                       quizFunctions.clearRoot();
+               /**
+                * @param {number} index
+                */
+               function renderQuestion(index) {
+                       clearChildren(quizRoot);
 
                        const question = quiz.questions[index];
 
                        firstCell.style.fontWeight = "bold";
                        firstCell.append("Question " + (index + 1) + "/" + quiz.questions.length);
                        firstCell.append(document.createElement("br"));
-                       firstCell.append(question.asks.toString());
+                       firstCell.append(question.asks);
                        firstRow.appendChild(firstCell);
                        quizRoot.appendChild(firstRow);
 
                                secondCell.style.textAlign = "center";
                                const answerLink = secondCell.appendChild(document.createElement("a"));
                                answerLink.href = "#";
-                               answerLink.append(answer.answer.toString());
-                               answerLink.onclick = e => {
+                               answerLink.append(answer.answer);
+                               answerLink.addEventListener("click", e => {
                                        e.preventDefault();
                                        questionAnswers[index] = answer.result;
                                        if (index === quiz.questions.length - 1) {
-                                               quizFunctions.renderOutro(quizFunctions.calculateResults());
+                                               renderOutro(calculateResults());
                                        } else {
-                                               quizFunctions.renderQuestion(index + 1);
+                                               renderQuestion(index + 1);
                                        }
-                               };
+                               });
                                secondRow.appendChild(secondCell);
                                quizRoot.appendChild(secondRow);
                        }
                        const prevLink = thirdCell.appendChild(document.createElement("a"));
                        prevLink.href = "#";
                        prevLink.append("Previous Question");
-                       prevLink.onclick = e => {
+                       prevLink.addEventListener("click", e => {
                                e.preventDefault();
                                if (index === 0) {
-                                       quizFunctions.renderIntro();
+                                       renderIntro();
                                } else {
-                                       quizFunctions.renderQuestion(index - 1);
+                                       renderQuestion(index - 1);
                                }
-                       };
+                       });
                        thirdRow.appendChild(thirdCell);
                        quizRoot.appendChild(thirdRow);
-               };
+               }
 
-               document.currentScript.after(quizRoot);
+               renderIntro();
 
-               quizFunctions.renderIntro();
-       };
+               return quizRoot;
+       }
 
-       window.addEventListener("load", function () {
-               // Comment previews
-               async function commentPreview(input, output) {
-                       const inText = input.value;
+       /**
+        * @param {HTMLElement} dom
+        */
+       function onDomLoad(dom) {
+               (function () {
+                       // Mechyrdian font
+
+                       /**
+                        * @param {HTMLInputElement} input
+                        * @param {HTMLInputElement} boldOpt
+                        * @param {HTMLInputElement} italicOpt
+                        * @param {HTMLSelectElement} alignOpt
+                        * @param {HTMLImageElement} output
+                        * @param {number} delayLength
+                        * @returns {Promise<void>}
+                        */
+                       async function mechyrdianToFont(input, boldOpt, italicOpt, alignOpt, output, delayLength) {
+                               const inText = input.value;
+
+                               await delay(delayLength);
+                               if (inText !== input.value) return;
 
-                       await delay(500);
-                       if (input.value !== inText)
-                               return;
+                               let outBlob;
+                               if (inText.trim().length === 0) {
+                                       outBlob = new Blob([
+                                               "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
+                                               "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0\" height=\"0\">\n",
+                                               "</svg>\n"
+                                       ], {type: "image/svg+xml"});
+                               } else {
+                                       const urlParams = new URLSearchParams();
+                                       if (boldOpt.checked) urlParams.set("bold", "true");
+                                       if (italicOpt.checked) urlParams.set("italic", "true");
+                                       urlParams.set("align", alignOpt.value);
+
+                                       for (const line of inText.split("\n"))
+                                               urlParams.append("lines", line.trim());
+
+                                       outBlob = await (await fetch('/utils/mechyrdia-sans', {
+                                               method: 'POST',
+                                               headers: {
+                                                       'Content-Type': 'application/x-www-form-urlencoded',
+                                               },
+                                               body: urlParams,
+                                       })).blob();
+
+                                       if (inText !== input.value) return;
+                               }
 
-                       if (inText.length === 0) {
-                               output.innerHTML = "";
-                               return;
+                               const prevObjectUrl = output.src;
+                               if (prevObjectUrl != null && prevObjectUrl.length > 0)
+                                       URL.revokeObjectURL(prevObjectUrl);
+
+                               output.src = URL.createObjectURL(outBlob);
                        }
 
-                       const urlParams = new URLSearchParams();
-                       for (const line of inText.split("\n"))
-                               urlParams.append("lines", line.trim());
+                       const mechyrdiaSansBoxes = dom.querySelectorAll("div.mechyrdia-sans-box");
+                       for (const mechyrdiaSansBox of mechyrdiaSansBoxes) {
+                               const inputBox = mechyrdiaSansBox.querySelector("textarea.input-box");
+                               const boldOpt = mechyrdiaSansBox.querySelector("input.bold-option");
+                               const italicOpt = mechyrdiaSansBox.querySelector("input.ital-option");
+                               const alignOpt = mechyrdiaSansBox.querySelector("select.align-opts");
+                               const outputBox = mechyrdiaSansBox.querySelector("img.output-img");
+
+                               const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 750);
+                               const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 250);
+                               inputBox.addEventListener("input", inputListener);
+                               boldOpt.addEventListener("change", optChangeListener);
+                               italicOpt.addEventListener("change", optChangeListener);
+                               alignOpt.addEventListener("change", optChangeListener);
+                       }
+               })();
 
-                       const outText = await (await fetch('/utils/preview-comment', {
-                               method: 'POST',
-                               headers: {
-                                       'Content-Type': 'application/x-www-form-urlencoded',
-                               },
-                               body: urlParams,
-                       })).text();
-                       if (input.value !== inText)
-                               return;
+               (function () {
+                       // Tylan alphabet
 
-                       output.innerHTML = "<h3>Preview:</h3>" + outText;
-               }
+                       /**
+                        * @param {HTMLTextAreaElement} input
+                        * @param {HTMLTextAreaElement} output
+                        * @returns {Promise<void>}
+                        */
+                       async function tylanToFont(input, output) {
+                               const inText = input.value;
 
-               const commentInputBoxes = document.getElementsByClassName("comment-input");
-               for (const commentInputBox of commentInputBoxes) {
-                       const inputBox = commentInputBox.getElementsByClassName("comment-markup")[0];
-                       const outputBox = commentInputBox.getElementsByClassName("comment-preview")[0];
+                               const urlParams = new URLSearchParams();
+                               for (const line of inText.split("\n"))
+                                       urlParams.append("lines", line.trim());
 
-                       inputBox.addEventListener("input", () => commentPreview(inputBox, outputBox));
-               }
-       });
+                               const outText = await (await fetch('/utils/tylan-lang', {
+                                       method: 'POST',
+                                       headers: {
+                                               'Content-Type': 'application/x-www-form-urlencoded',
+                                       },
+                                       body: urlParams,
+                               })).text();
 
-       window.addEventListener("load", function () {
-               // Comment editing
-               const commentEditLinks = document.getElementsByClassName("comment-edit-link");
-               for (const commentEditLink of commentEditLinks) {
-                       commentEditLink.onclick = e => {
-                               e.preventDefault();
+                               if (inText === input.value)
+                                       output.value = outText;
+                       }
 
-                               const elementId = e.currentTarget.getAttribute("data-edit-id");
-                               document.getElementById(elementId).classList.add("visible");
-                       };
-               }
+                       const tylanAlphabetBoxes = dom.querySelectorAll("div.tylan-alphabet-box");
+                       for (const tylanAlphabetBox of tylanAlphabetBoxes) {
+                               const inputBox = tylanAlphabetBox.querySelector("textarea.input-box");
+                               const outputBox = tylanAlphabetBox.querySelector("textarea.output-box");
 
-               const commentEditCancelButtons = document.getElementsByClassName("comment-cancel-edit");
-               for (const commentEditCancelButton of commentEditCancelButtons) {
-                       commentEditCancelButton.onclick = e => {
-                               e.preventDefault();
+                               inputBox.addEventListener("input", () => tylanToFont(inputBox, outputBox));
+                       }
+               })();
 
-                               e.currentTarget.parentElement.classList.remove("visible");
-                       };
-               }
-       });
+               (function () {
+                       // Thedish alphabet
 
-       window.addEventListener("load", function () {
-               // Copying text
-               const copyTextElements = document.getElementsByClassName("copy-text");
-               for (const copyTextElement of copyTextElements) {
-                       copyTextElement.onclick = e => {
-                               e.preventDefault();
+                       const thedishAlphabetBoxes = dom.querySelectorAll("div.thedish-alphabet-box");
+                       for (const thedishAlphabetBox of thedishAlphabetBoxes) {
+                               const inputBox = thedishAlphabetBox.querySelector("textarea.input-box");
+                               const outputBox = thedishAlphabetBox.querySelector("textarea.output-box");
 
-                               const thisElement = e.currentTarget;
-                               if (thisElement.hasAttribute("data-copying"))
-                                       return;
+                               inputBox.addEventListener("input", () => {
+                                       outputBox.value = inputBox.value;
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Kishari alphabet
+
+                       const kishariAlphabetBoxes = dom.querySelectorAll("div.kishari-alphabet-box");
+                       for (const kishariAlphabetBox of kishariAlphabetBoxes) {
+                               const inputBox = kishariAlphabetBox.querySelector("textarea.input-box");
+                               const outputBox = kishariAlphabetBox.querySelector("textarea.output-box");
+
+                               inputBox.addEventListener("input", () => {
+                                       outputBox.value = inputBox.value;
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Pokhwalish alphabet
+
+                       /**
+                        * @param {HTMLTextAreaElement} input
+                        * @param {HTMLTextAreaElement} output
+                        * @returns {Promise<void>}
+                        */
+                       async function pokhwalToFont(input, output) {
+                               const inText = input.value;
+
+                               const urlParams = new URLSearchParams();
+                               for (const line of inText.split("\n"))
+                                       urlParams.append("lines", line.trim());
+
+                               const outText = await (await fetch('/utils/pokhwal-lang', {
+                                       method: 'POST',
+                                       headers: {
+                                               'Content-Type': 'application/x-www-form-urlencoded',
+                                       },
+                                       body: urlParams,
+                               })).text();
+
+                               if (inText === input.value)
+                                       output.value = outText;
+                       }
+
+                       const pokhwalAlphabetBoxes = dom.querySelectorAll("div.pokhwal-alphabet-box");
+                       for (const pokhwalAlphabetBox of pokhwalAlphabetBoxes) {
+                               const inputBox = pokhwalAlphabetBox.querySelector("textarea.input-box");
+                               const outputBox = pokhwalAlphabetBox.querySelector("textarea.output-box");
+
+                               inputBox.addEventListener("input", () => pokhwalToFont(inputBox, outputBox));
+                       }
+               })();
+
+               (function () {
+                       // Set client preferences when selected
+                       const themeChoices = dom.querySelectorAll("input.pref-theme");
+                       for (const themeChoice of themeChoices) {
+                               themeChoice.addEventListener("click", e => {
+                                       const theme = e.currentTarget.value;
+                                       if (theme === "null") {
+                                               document.documentElement.removeAttribute("data-theme");
+                                       } else {
+                                               document.documentElement.setAttribute("data-theme", theme);
+                                       }
+                                       document.cookie = "FACTBOOK_THEME=" + theme + "; Secure; SameSite=Lax; Max-Age=" + (Math.pow(2, 31) - 1).toString();
+                               });
+                       }
+
+                       const april1stChoices = dom.querySelectorAll("input.pref-april1st");
+                       for (const april1stChoice of april1stChoices) {
+                               april1stChoice.addEventListener("click", e => {
+                                       const mode = e.currentTarget.value;
+                                       document.cookie = "APRIL_1ST_MODE=" + mode + "; Secure; SameSite=None; Max-Age=" + (Math.pow(2, 31) - 1).toString();
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Localize dates and times
+
+                       const moments = dom.querySelectorAll("span.moment");
+                       for (const moment of moments) {
+                               let date = new Date(Number(moment.textContent.trim()));
+                               moment.innerHTML = date.toLocaleString();
+                               moment.style.display = "inline";
+                       }
+               })();
+
+               (function () {
+                       // Login button
+
+                       const viewChecksumButtons = dom.querySelectorAll("button.view-checksum");
+                       for (const viewChecksumButton of viewChecksumButtons) {
+                               const token = viewChecksumButton.getAttribute("data-token");
+                               const url = (token != null && token !== "") ? ("https://www.nationstates.net/page=verify_login?token=" + token) : "https://www.nationstates.net/page=verify_login"
+                               viewChecksumButton.addEventListener("click", e => {
+                                       e.preventDefault();
+                                       window.open(url);
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Image previewing
+
+                       const imageThumbs = dom.querySelectorAll("span.image-thumb");
+                       for (const imageThumb of imageThumbs) {
+                               const imgElement = document.createElement("img");
+                               imgElement.src = imageThumb.getAttribute("data-src");
+                               imgElement.style.cssText = imageThumb.getAttribute("data-style");
+                               imgElement.title = "Click to view full size";
 
-                               const elementHtml = thisElement.innerHTML;
-
-                               thisElement.setAttribute("data-copying", "copying");
-
-                               const text = thisElement.getAttribute("data-text");
-                               navigator.clipboard.writeText(text)
-                                       .then(() => {
-                                               thisElement.innerHTML = "Text copied!";
-                                               window.setTimeout(() => {
-                                                       thisElement.innerHTML = elementHtml;
-                                                       thisElement.removeAttribute("data-copying");
-                                               }, 750);
-                                       })
-                                       .catch(reason => {
-                                               console.error("Error copying text to clipboard", reason);
-
-                                               thisElement.innerHTML = "Text copy failed";
-                                               window.setTimeout(() => {
-                                                       thisElement.innerHTML = elementHtml;
-                                                       thisElement.removeAttribute("data-copying");
-                                               }, 1500);
+                               imgElement.addEventListener("click", e => {
+                                       e.preventDefault();
+
+                                       const thumbView = document.createElement("div");
+                                       thumbView.id = "thumb-view";
+
+                                       const thumbViewBg = document.createElement("div");
+                                       thumbViewBg.classList.add("bg");
+
+                                       const thumbViewImg = document.createElement("img");
+                                       thumbViewImg.src = e.currentTarget.src;
+                                       thumbViewImg.title = thumbViewImg.alt = "Click to close full size";
+                                       thumbView.classList.add("visible");
+
+                                       thumbView.append(thumbViewBg, thumbViewImg);
+                                       thumbView.addEventListener("click", e => {
+                                               e.preventDefault();
+
+                                               e.currentTarget.remove();
                                        });
 
-                               thisElement.innerHTML = "Copying text...";
-                       };
-               }
-       });
+                                       document.body.append(thumbView);
+                               });
 
-       window.addEventListener("load", function () {
-               // Error popup
-               const errorPopup = document.getElementById("error-popup");
-               if (errorPopup != null) {
-                       errorPopup.addEventListener("click", e => {
-                               e.preventDefault();
+                               imageThumb.after(imgElement);
+                               imageThumb.remove();
+                       }
+               })();
 
-                               const thisElement = e.currentTarget;
-                               const newUrl = window.location.origin + thisElement.getAttribute("data-redirect-url") + window.location.hash;
-                               window.history.replaceState({}, '', newUrl);
+               (function () {
+                       // Mesh viewing
 
-                               thisElement.remove();
-                       });
-               }
-       });
+                       const canvases = dom.querySelectorAll("canvas[data-model]");
+                       if (canvases.length > 0) {
+                               (async function () {
+                                       const loadObj = await loadThreeJs();
+                                       const THREE = window.THREE;
 
-       window.factbookRedirect = function (redirectTo) {
-               if (window.disableFactbookRedirect) {
-                       const redirectTarget = new URL(redirectTo, window.location);
+                                       const promises = [];
+                                       for (const canvas of canvases) {
+                                               promises.push((async () => {
+                                                       const modelAsync = loadObj(canvas.getAttribute("data-model"));
 
-                       const redirectTargetUrl = redirectTarget.pathname + redirectTarget.search + redirectTarget.hash;
+                                                       const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0);
 
-                       const pElement = document.createElement("p");
-                       pElement.style.fontWeight = "800";
-                       pElement.append(document.createTextNode("Redirect to "));
+                                                       const scene = new THREE.Scene();
+                                                       scene.add(new THREE.AmbientLight("#555555", 1.0));
 
-                       const aElement = document.createElement("a");
-                       aElement.href = redirectTargetUrl;
-                       aElement.append(document.createTextNode(redirectTarget.pathname));
+                                                       const renderer = new THREE.WebGLRenderer({"canvas": canvas, "antialias": true});
 
-                       pElement.append(aElement);
-                       document.currentScript.after(pElement);
-               } else {
-                       window.localStorage.setItem("redirectedFrom", window.location.pathname);
-                       window.location = redirectTo;
-               }
-       };
+                                                       const controls = new THREE.OrbitControls(camera, canvas);
 
-       window.checkRedirectTarget = function (anchorHash) {
-               const redirectTargetValue = window.location.hash;
-               if (redirectTargetValue !== anchorHash) return;
+                                                       function render() {
+                                                               controls.update();
+                                                               renderer.render(scene, camera);
+                                                               window.requestAnimationFrame(render);
+                                                       }
 
-               const redirectSourceValue = window.localStorage.getItem("redirectedFrom");
-               if (redirectSourceValue != null) {
-                       const redirectSource = new URL(redirectSourceValue, window.location.origin);
-                       if (redirectSource.search.length > 0) {
-                               redirectSource.search += "&redirect=no";
-                       } else {
-                               redirectSource.search = "?redirect=no";
+                                                       function onResize() {
+                                                               const dim = canvas.getBoundingClientRect();
+                                                               camera.aspect = dim.width / dim.height;
+                                                               camera.updateProjectionMatrix();
+                                                               renderer.setSize(dim.width, dim.height, false);
+                                                       }
+
+                                                       window.addEventListener('resize', onResize);
+                                                       await frame();
+                                                       onResize();
+
+                                                       const model = await modelAsync;
+                                                       scene.add(model);
+
+                                                       const bbox = new THREE.Box3().setFromObject(scene);
+                                                       bbox.dimensions = {
+                                                               x: bbox.max.x - bbox.min.x,
+                                                               y: bbox.max.y - bbox.min.y,
+                                                               z: bbox.max.z - bbox.min.z
+                                                       };
+                                                       model.position.sub(new THREE.Vector3(bbox.min.x + bbox.dimensions.x / 2, bbox.min.y + bbox.dimensions.y / 2, bbox.min.z + bbox.dimensions.z / 2));
+
+                                                       camera.position.set(bbox.dimensions.x / 2, bbox.dimensions.y / 2, Math.max(bbox.dimensions.x, bbox.dimensions.y, bbox.dimensions.z));
+
+                                                       const light = new THREE.PointLight("#AAAAAA", 1.0);
+                                                       scene.add(camera);
+                                                       camera.add(light);
+                                                       light.position.set(0, 0, 0);
+
+                                                       render();
+                                               })());
+                                       }
+
+                                       await Promise.all(promises);
+                               })().catch(reason => {
+                                       console.error("Error rendering models", reason);
+                               });
                        }
+               })();
 
-                       const redirectSourceUrl = redirectSource.pathname + redirectSource.search + redirectSource.hash;
+               (function () {
+                       // Allow POSTing with <a>s
 
-                       const pElement = document.createElement("p");
-                       pElement.style.fontSize = "0.8em";
-                       pElement.append(document.createTextNode("Redirected from "));
+                       // TODO implement Fetch+History loading
+                       const anchors = dom.querySelectorAll("a[data-method]");
+                       for (const anchor of anchors) {
+                               anchor.addEventListener("click", e => {
+                                       e.preventDefault();
 
-                       const aElement = document.createElement("a");
-                       aElement.href = redirectSourceUrl;
-                       aElement.append(document.createTextNode(redirectSource.pathname));
+                                       let form = document.createElement("form");
+                                       form.style.display = "none";
+                                       form.action = e.currentTarget.href;
+                                       form.method = e.currentTarget.getAttribute("data-method");
+
+                                       const csrfToken = e.currentTarget.getAttribute("data-csrf-token");
+                                       if (csrfToken != null) {
+                                               let csrfInput = document.createElement("input");
+                                               csrfInput.name = "csrfToken";
+                                               csrfInput.type = "hidden";
+                                               csrfInput.value = csrfToken;
+                                               form.append(csrfInput);
+                                       }
 
-                       pElement.append(aElement);
-                       document.currentScript.after(pElement);
-               }
+                                       document.body.appendChild(form).submit();
+                               });
+                       }
+               })();
 
-               window.localStorage.removeItem("redirectedFrom");
-       };
-
-       window.createNukeBox = function (csrfToken) {
-               const chatHistory = document.createElement("blockquote");
-               chatHistory.style.overflowY = "scroll";
-               chatHistory.style.height = "40vh";
-
-               const inputBox = document.createElement("input");
-               inputBox.classList.add("inline");
-               inputBox.style.flexGrow = "1";
-               inputBox.type = "text";
-               inputBox.placeholder = "Enter your message";
-
-               const enterBtn = document.createElement("input");
-               enterBtn.classList.add("inline");
-               enterBtn.style.flexShrink = "0";
-               enterBtn.type = "submit";
-               enterBtn.value = "Send";
-               enterBtn.disabled = true;
-
-               const inputForm = document.createElement("form");
-               inputForm.style.display = "flex";
-               inputForm.append(inputBox, enterBtn);
-
-               const container = document.createElement("div");
-               container.append(chatHistory, inputForm);
-               document.currentScript.after(container);
-
-               const targetUrl = "ws" + window.location.href.substring(4) + "/ws?csrfToken=" + csrfToken;
-               const webSock = new WebSocket(targetUrl);
-
-               inputForm.onsubmit = (ev) => {
-                       ev.preventDefault();
-                       if (!ev.submitter.disabled) {
-                               webSock.send(inputBox.value);
-                               inputBox.value = "";
-                               enterBtn.disabled = true;
+               (function () {
+                       // Render vocab
+
+                       const vocabSpans = dom.querySelectorAll("span.vocab");
+                       for (const vocabSpan of vocabSpans) {
+                               const vocab = JSON.parse(vocabSpan.getAttribute("data-vocab"));
+                               vocabSpan.after(renderVocab(vocab));
+                               vocabSpan.remove();
                        }
-               };
-
-               webSock.onmessage = (ev) => {
-                       const data = JSON.parse(ev.data);
-                       if (data.type === "ready") {
-                               enterBtn.disabled = false;
-                       } else if (data.type === "user") {
-                               const userP = document.createElement("p");
-                               userP.style.textAlign = "right";
-                               userP.style.paddingLeft = "50%";
-                               appendWithLineBreaks(userP, data.text);
-                               chatHistory.appendChild(userP);
-
-                               const robotP = document.createElement("p");
-                               robotP.style.textAlign = "left";
-                               robotP.style.paddingRight = "50%";
-                               chatHistory.appendChild(robotP);
-                       } else if (data.type === "robot") {
-                               const robotP = chatHistory.lastElementChild;
-                               appendWithLineBreaks(robotP, data.text);
-                       } else if (data.type === "cite") {
-                               const robotP = chatHistory.lastElementChild;
-                               const robotCiteList = robotP.appendChild(document.createElement("ol"));
-                               for (const url of data.urls) {
-                                       const urlLink = robotCiteList.appendChild(document.createElement("li")).appendChild(document.createElement("a"));
-                                       urlLink.href = url;
-                                       urlLink.append(url);
+               })();
+
+               (function () {
+                       // Render quizzes
+
+                       const quizSpans = dom.querySelectorAll("span.quiz");
+                       for (const quizSpan of quizSpans) {
+                               const quiz = JSON.parse(quizSpan.getAttribute("data-quiz"));
+                               quizSpan.after(renderQuiz(quiz));
+                               quizSpan.remove();
+                       }
+               })();
+
+               (function () {
+                       // Comment previews
+
+                       /**
+                        * @param {HTMLTextAreaElement} input
+                        * @param {HTMLDivElement} output
+                        * @returns {Promise<void>}
+                        */
+                       async function commentPreview(input, output) {
+                               const inText = input.value;
+
+                               await delay(500);
+                               if (input.value !== inText)
+                                       return;
+
+                               if (inText.length === 0) {
+                                       output.innerHTML = "";
+                                       return;
                                }
+
+                               const urlParams = new URLSearchParams();
+                               for (const line of inText.split("\n"))
+                                       urlParams.append("lines", line.trim());
+
+                               const outText = await (await fetch('/utils/preview-comment', {
+                                       method: 'POST',
+                                       headers: {
+                                               'Content-Type': 'application/x-www-form-urlencoded',
+                                       },
+                                       body: urlParams,
+                               })).text();
+                               if (input.value !== inText)
+                                       return;
+
+                               output.innerHTML = "<h3>Preview:</h3>" + outText;
                        }
-               };
 
-               webSock.onclose = (ev) => {
-                       const statusP = document.createElement("p");
-                       statusP.style.textAlign = "center";
-                       statusP.style.paddingLeft = "25%";
-                       statusP.style.paddingRight = "25%";
-                       appendWithLineBreaks(statusP, "The connection has been closed\n" + ev.reason);
-                       chatHistory.appendChild(statusP);
+                       const commentInputBoxes = dom.querySelectorAll("form.comment-input");
+                       for (const commentInputBox of commentInputBoxes) {
+                               const inputBox = commentInputBox.querySelector("textarea.comment-markup");
+                               const outputBox = commentInputBox.querySelector("div.comment-preview");
+
+                               inputBox.addEventListener("input", () => commentPreview(inputBox, outputBox));
+                       }
+               })();
+
+               (function () {
+                       // Comment editing
+
+                       const commentEditLinks = dom.querySelectorAll("a.comment-edit-link");
+                       for (const commentEditLink of commentEditLinks) {
+                               const targetElement = dom.querySelector("#" + commentEditLink.getAttribute("data-edit-id"));
+                               commentEditLink.addEventListener("click", e => {
+                                       e.preventDefault();
+
+                                       targetElement.classList.add("visible");
+                               });
+                       }
+
+                       const commentEditCancelButtons = dom.querySelectorAll("button.comment-cancel-edit");
+                       for (const commentEditCancelButton of commentEditCancelButtons) {
+                               const targetElement = dom.querySelector("#" + commentEditCancelButton.getAttribute("data-edit-id"));
+
+                               commentEditCancelButton.addEventListener("click", e => {
+                                       e.preventDefault();
+
+                                       targetElement.classList.remove("visible");
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Copying text
+
+                       const copyTextElements = dom.querySelectorAll("a.copy-text");
+                       for (const copyTextElement of copyTextElements) {
+                               copyTextElement.addEventListener("click", e => {
+                                       e.preventDefault();
+
+                                       const thisElement = e.currentTarget;
+                                       if (thisElement.hasAttribute("data-copying"))
+                                               return;
+
+                                       const elementHtml = thisElement.innerHTML;
+
+                                       thisElement.setAttribute("data-copying", "copying");
+
+                                       const text = thisElement.getAttribute("data-text");
+                                       navigator.clipboard.writeText(text)
+                                               .then(() => {
+                                                       thisElement.innerHTML = "Text copied!";
+                                                       window.setTimeout(() => {
+                                                               thisElement.innerHTML = elementHtml;
+                                                               thisElement.removeAttribute("data-copying");
+                                                       }, 750);
+                                               })
+                                               .catch(reason => {
+                                                       console.error("Error copying text to clipboard", reason);
+
+                                                       thisElement.innerHTML = "Text copy failed";
+                                                       window.setTimeout(() => {
+                                                               thisElement.innerHTML = elementHtml;
+                                                               thisElement.removeAttribute("data-copying");
+                                                       }, 1500);
+                                               });
+
+                                       thisElement.innerHTML = "Copying text...";
+                               });
+                       }
+               })();
+
+               (function () {
+                       // Error popup
+
+                       const errorMsg = getCookieMap()["ERROR_MSG"];
+                       if (errorMsg != null) {
+                               document.cookie = "ERROR_MSG=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure";
+
+                               const errorPopup = document.createElement("div");
+                               errorPopup.id = "error-popup";
+
+                               const errorPopupBg = document.createElement("div");
+                               errorPopupBg.classList.add("bg");
+
+                               const errorPopupMsg = document.createElement("div");
+                               errorPopupMsg.classList.add("msg");
+
+                               const msgP = document.createElement("p");
+                               msgP.append(errorMsg);
+                               const c2cP = document.createElement("p");
+                               c2cP.append("Click to close this popup");
+
+                               errorPopupMsg.append(msgP, c2cP);
+                               errorPopup.append(errorPopupBg, errorPopupMsg);
 
-                       enterBtn.disabled = true;
-               };
-       };
+                               errorPopup.addEventListener("click", e => {
+                                       e.preventDefault();
+
+                                       e.currentTarget.remove();
+                               });
+
+                               document.body.append(errorPopup);
+                       }
+               })();
+
+               (function () {
+                       // Factbook redirecting (1)
+
+                       const redirectLink = dom.querySelector("a.redirect-link");
+                       if (redirectLink != null) {
+                               const redirectTarget = new URL(redirectLink.href, window.location);
+                               const redirectTargetUrl = redirectTarget.pathname + redirectTarget.search + redirectTarget.hash;
+
+                               if (window.localStorage.getItem("disableRedirect") === "true") {
+                                       clearChildren(redirectLink);
+                                       redirectLink.append(redirectTarget.pathname);
+                               } else {
+                                       // The scope-block immediately below - labeled "Factbook redirecting (2)"
+                                       // checks if the key "redirectedFrom" is present in localStorage and, if
+                                       // so, removes it after some other processing. We don't want that to happen
+                                       // if we're setting it and then redirecting, so we have to put this code
+                                       // into a microtask so it waits until after the rest of this function executes.
+                                       window.queueMicrotask(() => {
+                                               window.localStorage.setItem("redirectedFrom", window.location.pathname);
+                                               window.location = redirectTargetUrl;
+                                       });
+                               }
+                       }
+
+                       window.localStorage.removeItem("disableRedirect");
+               })();
+
+               (function () {
+                       // Factbook redirecting (2)
+
+                       const redirectSourceValue = window.localStorage.getItem("redirectedFrom");
+                       if (redirectSourceValue != null) {
+                               const redirectSource = new URL(redirectSourceValue, window.location.origin);
+                               const redirectSourceUrl = redirectSource.pathname + redirectSource.search + redirectSource.hash;
+
+                               const redirectIdValue = window.location.hash;
+                               const redirectIds = dom.querySelectorAll("h1[data-redirect-id], h2[data-redirect-id], h3[data-redirect-id], h4[data-redirect-id], h5[data-redirect-id], h6[data-redirect-id]");
+                               for (const redirectId of redirectIds) {
+                                       if (redirectId.getAttribute("data-redirect-id") !== redirectIdValue)
+                                               continue;
+
+                                       const pElement = document.createElement("p");
+                                       pElement.style.fontSize = "0.8em";
+                                       pElement.append("Redirected from ");
+
+                                       const aElement = document.createElement("a");
+                                       aElement.href = redirectSourceUrl;
+                                       aElement.append(redirectSource.pathname);
+                                       aElement.addEventListener("click", e => {
+                                               e.preventDefault();
+                                               window.localStorage.setItem("disableRedirect", "true");
+
+                                               // TODO implement Fetch+History loading
+                                               window.location = e.currentTarget.href;
+                                       });
+
+                                       pElement.append(aElement);
+                                       redirectId.after(pElement);
+                               }
+
+                               window.localStorage.removeItem("redirectedFrom");
+                       }
+               })();
+
+               (function () {
+                       // NUKE
+
+                       const nukeBoxes = dom.querySelectorAll("span.nuke-box");
+                       for (const nukeBox of nukeBoxes) {
+                               const chatHistory = document.createElement("blockquote");
+                               chatHistory.style.overflowY = "scroll";
+                               chatHistory.style.height = "40vh";
+
+                               const inputBox = document.createElement("input");
+                               inputBox.classList.add("inline");
+                               inputBox.style.flexGrow = "1";
+                               inputBox.type = "text";
+                               inputBox.placeholder = "Enter your message";
+
+                               const enterBtn = document.createElement("input");
+                               enterBtn.classList.add("inline");
+                               enterBtn.style.flexShrink = "0";
+                               enterBtn.type = "submit";
+                               enterBtn.value = "Send";
+                               enterBtn.disabled = true;
+
+                               const inputForm = document.createElement("form");
+                               inputForm.style.display = "flex";
+                               inputForm.append(inputBox, enterBtn);
+
+                               const container = document.createElement("div");
+                               container.append(chatHistory, inputForm);
+                               nukeBox.after(container);
+                               nukeBox.remove();
+
+                               const targetUrl = "ws" + window.location.href.substring(4) + "/ws?csrfToken=" + nukeBox.getAttribute("data-ws-csrf-token");
+                               const webSock = new WebSocket(targetUrl);
+
+                               inputForm.addEventListener("submit", e => {
+                                       e.preventDefault();
+                                       if (!e.submitter.disabled) {
+                                               webSock.send(inputBox.value);
+                                               inputBox.value = "";
+                                               enterBtn.disabled = true;
+                                       }
+                               });
+
+                               webSock.addEventListener("message", e => {
+                                       const data = JSON.parse(e.data);
+                                       if (data.type === "ready") {
+                                               enterBtn.disabled = false;
+                                       } else if (data.type === "user") {
+                                               const userP = document.createElement("p");
+                                               userP.style.textAlign = "right";
+                                               userP.style.paddingLeft = "50%";
+                                               appendWithLineBreaks(userP, data.text);
+                                               chatHistory.appendChild(userP);
+
+                                               const robotP = document.createElement("p");
+                                               robotP.style.textAlign = "left";
+                                               robotP.style.paddingRight = "50%";
+                                               chatHistory.appendChild(robotP);
+                                       } else if (data.type === "robot") {
+                                               const robotP = chatHistory.lastElementChild;
+                                               appendWithLineBreaks(robotP, data.text);
+                                       } else if (data.type === "cite") {
+                                               const robotP = chatHistory.lastElementChild;
+                                               const robotCiteList = robotP.appendChild(document.createElement("ol"));
+                                               for (const url of data.urls) {
+                                                       const urlLink = robotCiteList.appendChild(document.createElement("li")).appendChild(document.createElement("a"));
+                                                       urlLink.href = url;
+                                                       urlLink.append(url);
+                                               }
+                                       }
+                               });
+
+                               webSock.addEventListener("close", e => {
+                                       const statusP = document.createElement("p");
+                                       statusP.style.textAlign = "center";
+                                       statusP.style.paddingLeft = "25%";
+                                       statusP.style.paddingRight = "25%";
+                                       appendWithLineBreaks(statusP, "The connection has been closed\n" + e.reason);
+                                       chatHistory.appendChild(statusP);
+
+                                       enterBtn.disabled = true;
+                               });
+                       }
+               })();
+       }
+
+       window.addEventListener("load", () => {
+               onDomLoad(document.documentElement);
+       });
 })();
index 10458f9a1f42b4f36b7a8412c14de802eac0f7f8..f1796c6c573d0ff03acb84a50783a66a52533470 100644 (file)
@@ -353,11 +353,11 @@ aside.mobile img {
        div#bg {
                display: unset;
 
-               width: 100vw;
-               height: 100vh;
                position: fixed;
                top: 0;
                left: 0;
+               right: 0;
+               bottom: 0;
 
                background-image: var(--bgimg);
                background-attachment: fixed;
@@ -671,18 +671,18 @@ iframe {
        z-index: 998;
 
        position: fixed;
-       width: 100vw;
-       height: 100vh;
-       left: 0;
        top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
 }
 
 #error-popup > .bg {
        position: fixed;
-       width: 100vw;
-       height: 100vh;
-       left: 0;
        top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
 
        background-color: rgba(0, 0, 0, 40%);
 }
@@ -776,27 +776,21 @@ textarea.lang-pokhwal {
 }
 
 #thumb-view {
-       display: none;
-}
-
-#thumb-view.visible {
        z-index: 998;
 
-       display: unset;
-
        position: fixed;
-       width: 100vw;
-       height: 100vh;
-       left: 0;
        top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
 }
 
 #thumb-view > .bg {
        position: fixed;
-       width: 100vw;
-       height: 100vh;
-       left: 0;
        top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
 
        background-color: rgba(0, 0, 0, 40%);
 }